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,22 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { getCurrentUserId } from "@/lib/session";
|
||||
|
||||
// Löscht einen Ist-Satz. Ein Ist-Satz ist eine Beobachtung, keine Planänderung -- deshalb
|
||||
// gibt es hier weder Versionierung noch Wiederherstellung.
|
||||
export async function DELETE(
|
||||
_request: NextRequest,
|
||||
{ params }: { params: Promise<{ planId: string; setId: string }> }
|
||||
) {
|
||||
const userId = await getCurrentUserId();
|
||||
if (!userId) return NextResponse.json({ error: "Nicht authentifiziert." }, { status: 401 });
|
||||
const { planId, setId } = await params;
|
||||
|
||||
const set = await prisma.actualsSet.findFirst({
|
||||
where: { id: setId, planId, plan: { userId } },
|
||||
});
|
||||
if (!set) return NextResponse.json({ error: "Datensatz nicht gefunden." }, { status: 404 });
|
||||
|
||||
await prisma.actualsSet.delete({ where: { id: setId } });
|
||||
return NextResponse.json({ ok: true });
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { z } from "zod";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { getCurrentUserId } from "@/lib/session";
|
||||
|
||||
// Ein Ist-Wert je Wurzel-Element. Beide Felder optional: Wer eine Zahl nicht kennt, lässt sie
|
||||
// weg -- die Lücke fällt in der Berechnung auf die Plandaten zurück.
|
||||
const valueSchema = z.object({
|
||||
value: z.number().min(-1_000_000_000).max(1_000_000_000).optional(),
|
||||
mortgage: z.number().min(0).max(1_000_000_000).optional(),
|
||||
});
|
||||
|
||||
const createSchema = z.object({
|
||||
recordedOn: z.string().regex(/^\d{4}-\d{2}-\d{2}$/, "Datum im Format JJJJ-MM-TT"),
|
||||
comment: z.string().max(500).optional(),
|
||||
cash: z.number().min(-1_000_000_000).max(1_000_000_000).nullable().optional(),
|
||||
values: z.record(z.string(), valueSchema),
|
||||
});
|
||||
|
||||
async function ownedPlan(planId: string, userId: string) {
|
||||
return prisma.plan.findFirst({ where: { id: planId, userId } });
|
||||
}
|
||||
|
||||
// Alle Ist-Sätze eines Plans, neueste zuerst.
|
||||
export async function GET(_request: NextRequest, { params }: { params: Promise<{ planId: string }> }) {
|
||||
const userId = await getCurrentUserId();
|
||||
if (!userId) return NextResponse.json({ error: "Nicht authentifiziert." }, { status: 401 });
|
||||
const { planId } = await params;
|
||||
|
||||
const plan = await ownedPlan(planId, userId);
|
||||
if (!plan) return NextResponse.json({ error: "Plan nicht gefunden." }, { status: 404 });
|
||||
|
||||
const rows = await prisma.actualsSet.findMany({
|
||||
where: { planId },
|
||||
orderBy: [{ year: "desc" }, { recordedOn: "desc" }],
|
||||
include: { createdBy: { select: { username: true } } },
|
||||
});
|
||||
|
||||
return NextResponse.json({
|
||||
sets: rows.map((r) => ({
|
||||
id: r.id,
|
||||
recordedOn: r.recordedOn.toISOString().slice(0, 10),
|
||||
year: r.year,
|
||||
comment: r.comment,
|
||||
cash: r.cash,
|
||||
values: r.values,
|
||||
author: r.createdBy.username,
|
||||
createdAt: r.createdAt,
|
||||
})),
|
||||
});
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest, { params }: { params: Promise<{ planId: string }> }) {
|
||||
const userId = await getCurrentUserId();
|
||||
if (!userId) return NextResponse.json({ error: "Nicht authentifiziert." }, { status: 401 });
|
||||
const { planId } = await params;
|
||||
|
||||
const plan = await ownedPlan(planId, userId);
|
||||
if (!plan) return NextResponse.json({ error: "Plan nicht gefunden." }, { status: 404 });
|
||||
|
||||
const parsed = createSchema.safeParse(await request.json());
|
||||
if (!parsed.success) return NextResponse.json({ error: "Ungültige Eingabe." }, { status: 400 });
|
||||
|
||||
const { recordedOn, comment, cash, values } = parsed.data;
|
||||
// Für die Berechnung zählt nur die Jahreszahl -- der Rechenkern arbeitet in ganzen Jahren
|
||||
// ab Planbeginn. Das exakte Datum bleibt für Liste und Zeitachse erhalten.
|
||||
const year = Number(recordedOn.slice(0, 4));
|
||||
|
||||
const created = await prisma.actualsSet.create({
|
||||
data: {
|
||||
planId,
|
||||
recordedOn: new Date(`${recordedOn}T00:00:00.000Z`),
|
||||
year,
|
||||
comment: comment?.trim() || null,
|
||||
cash: typeof cash === "number" ? cash : null,
|
||||
values,
|
||||
createdById: userId,
|
||||
},
|
||||
});
|
||||
|
||||
return NextResponse.json({ set: { id: created.id, year: created.year } }, { status: 201 });
|
||||
}
|
||||
@@ -26,10 +26,33 @@ export async function GET(_request: NextRequest, { params }: { params: Promise<{
|
||||
if (parent) base = toPlanInput(parent);
|
||||
}
|
||||
|
||||
// Ist-Werte des Plans plus die Element-Herkunft ALLER Szenarien: Nur damit lässt sich die
|
||||
// auf Wurzel-IDs erfasste Realität auf dieses Szenario abbilden (siehe lib/actuals.ts).
|
||||
// Die Ist-Rechnung selbst passiert im Browser -- computePlan ist rein.
|
||||
const [actualsRows, siblings] = await Promise.all([
|
||||
prisma.actualsSet.findMany({
|
||||
where: { planId: scenario.planId },
|
||||
orderBy: [{ year: "asc" }, { recordedOn: "asc" }],
|
||||
}),
|
||||
prisma.scenario.findMany({
|
||||
where: { planId: scenario.planId },
|
||||
select: { elements: { select: { id: true, sourceElementId: true } } },
|
||||
}),
|
||||
]);
|
||||
|
||||
return NextResponse.json({
|
||||
plan: planInput,
|
||||
computed,
|
||||
base,
|
||||
actuals: actualsRows.map((r) => ({
|
||||
id: r.id,
|
||||
recordedOn: r.recordedOn.toISOString().slice(0, 10),
|
||||
year: r.year,
|
||||
comment: r.comment,
|
||||
cash: r.cash,
|
||||
values: r.values,
|
||||
})),
|
||||
elementOrigins: siblings.flatMap((s) => s.elements),
|
||||
meta: {
|
||||
id: scenario.id,
|
||||
planId: scenario.planId,
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -0,0 +1,239 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { computePlan } from "@/lib/calculations";
|
||||
import {
|
||||
actualKindOf,
|
||||
actualsForYear,
|
||||
latestPlanYear,
|
||||
rebaseFlow,
|
||||
resolveActuals,
|
||||
toPlanYear,
|
||||
type ActualsSetInput,
|
||||
} from "@/lib/actuals";
|
||||
import type { PlanInput } from "@/lib/types";
|
||||
|
||||
// Plan ab 2020: ein Fonds mit 100'000 und 5 % Rendite, keine Zu-/Abflüsse. Das ist bewusst
|
||||
// das Beispiel aus der Anforderung, nur in ganzen Franken.
|
||||
function fundPlan(): PlanInput {
|
||||
return {
|
||||
id: "s",
|
||||
name: "T",
|
||||
householdType: "SINGLE",
|
||||
inflationRateDefault: 0,
|
||||
initialCash: 0,
|
||||
startYear: 2020,
|
||||
persons: [{ id: "A", role: "PERSON_A", name: null, age: 40, retirementAge: 65 }],
|
||||
phases: [{ id: "p1", sequenceNumber: 1, name: "Erwerb", durationYears: 10, cashTransition: {} }],
|
||||
elements: [
|
||||
{
|
||||
id: "fonds",
|
||||
category: "OTHER_ASSET",
|
||||
name: "Fonds",
|
||||
ownerRole: "HOUSEHOLD",
|
||||
orderIndex: 1,
|
||||
phaseValues: { p1: { startValue: 100000, expectedReturn: 5 } },
|
||||
transitionValues: {},
|
||||
sourceElementId: null,
|
||||
},
|
||||
],
|
||||
} as unknown as PlanInput;
|
||||
}
|
||||
|
||||
const valueAt = (computed: ReturnType<typeof computePlan>, elementId: string, year: number) =>
|
||||
computed.phases
|
||||
.flatMap((p) => p.elements.filter((e) => e.elementId === elementId).flatMap((e) => e.yearly))
|
||||
.find((y) => y.year === year)?.value ?? null;
|
||||
|
||||
const setAt = (year: number, values: Record<string, { value?: number; mortgage?: number }>, cash?: number): ActualsSetInput => ({
|
||||
id: `set-${year}`,
|
||||
recordedOn: `${year}-08-18`,
|
||||
year,
|
||||
cash: cash ?? null,
|
||||
values,
|
||||
});
|
||||
|
||||
describe("toPlanYear", () => {
|
||||
it("rechnet das Kalenderjahr auf das Planjahr um", () => {
|
||||
expect(toPlanYear(2020, 2020)).toBe(1);
|
||||
expect(toPlanYear(2026, 2020)).toBe(7);
|
||||
});
|
||||
|
||||
it("liefert nichts ohne Planstartjahr", () => {
|
||||
expect(toPlanYear(2026, null)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("actualKindOf", () => {
|
||||
it("trennt Bestände von Flüssen", () => {
|
||||
expect(actualKindOf("OTHER_ASSET")).toBe("STOCK");
|
||||
expect(actualKindOf("PENSION_FUND")).toBe("STOCK");
|
||||
expect(actualKindOf("REAL_ESTATE")).toBe("PROPERTY");
|
||||
expect(actualKindOf("INCOME")).toBe("FLOW");
|
||||
expect(actualKindOf("EXPENSE")).toBe("FLOW");
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveActuals", () => {
|
||||
const plan = fundPlan();
|
||||
|
||||
it("bildet Wurzel-IDs auf die Elemente des Szenarios ab", () => {
|
||||
const res = resolveActuals([setAt(2022, { fonds: { value: 120000 } })], plan, plan.elements);
|
||||
expect(res).toHaveLength(1);
|
||||
expect(res[0].planYear).toBe(3);
|
||||
expect(res[0].byElementId.fonds.value).toBe(120000);
|
||||
});
|
||||
|
||||
it("findet das Element auch über eine Kopie-Kette hinweg", () => {
|
||||
// Kind-Szenario: eigene Element-ID, zeigt über sourceElementId auf das Original.
|
||||
const child: PlanInput = {
|
||||
...plan,
|
||||
id: "child",
|
||||
elements: [{ ...plan.elements[0], id: "fonds-kopie", sourceElementId: "fonds" }],
|
||||
} as unknown as PlanInput;
|
||||
|
||||
// Der Ist-Satz ist auf die WURZEL-ID erfasst -- er muss trotzdem greifen.
|
||||
const res = resolveActuals([setAt(2022, { fonds: { value: 120000 } })], child, [...plan.elements, ...child.elements]);
|
||||
expect(res[0].byElementId["fonds-kopie"].value).toBe(120000);
|
||||
});
|
||||
|
||||
it("verwirft Sätze ausserhalb des Planzeitraums", () => {
|
||||
const res = resolveActuals(
|
||||
[setAt(2019, { fonds: { value: 1 } }), setAt(2099, { fonds: { value: 1 } })],
|
||||
plan,
|
||||
plan.elements
|
||||
);
|
||||
expect(res).toEqual([]);
|
||||
});
|
||||
|
||||
it("lässt Elemente ohne Ist-Wert weg -- sie laufen auf der Planlinie weiter", () => {
|
||||
const res = resolveActuals([setAt(2022, {})], plan, plan.elements);
|
||||
expect(res[0].byElementId).toEqual({});
|
||||
});
|
||||
|
||||
it("sortiert nach Planjahr", () => {
|
||||
const res = resolveActuals(
|
||||
[setAt(2024, { fonds: { value: 140000 } }), setAt(2022, { fonds: { value: 120000 } })],
|
||||
plan,
|
||||
plan.elements
|
||||
);
|
||||
expect(res.map((r) => r.year)).toEqual([2022, 2024]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("actualsForYear / latestPlanYear", () => {
|
||||
const plan = fundPlan();
|
||||
const res = resolveActuals(
|
||||
[setAt(2022, { fonds: { value: 120000 } }), setAt(2024, { fonds: { value: 140000 } })],
|
||||
plan,
|
||||
plan.elements
|
||||
);
|
||||
|
||||
it("findet den Satz des Jahres", () => {
|
||||
expect(actualsForYear(res, 3)!.year).toBe(2022);
|
||||
expect(actualsForYear(res, 4)).toBeNull();
|
||||
});
|
||||
|
||||
it("kennt das jüngste erfasste Planjahr", () => {
|
||||
expect(latestPlanYear(res)).toBe(5);
|
||||
expect(latestPlanYear([])).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Berechnung mit Ist-Werten", () => {
|
||||
const plan = fundPlan();
|
||||
|
||||
it("lässt die Plan-Sicht unangetastet", () => {
|
||||
// Der wichtigste Test überhaupt: Ohne actuals-Option muss auf die Zahl dasselbe
|
||||
// herauskommen wie vorher.
|
||||
const a = computePlan(plan);
|
||||
const b = computePlan(plan, undefined, {});
|
||||
expect(a.phases[0].endWealthNominal).toBe(b.phases[0].endWealthNominal);
|
||||
});
|
||||
|
||||
it("springt im Ist-Jahr auf den erfassten Wert", () => {
|
||||
const actuals = resolveActuals([setAt(2022, { fonds: { value: 120000 } })], plan, plan.elements);
|
||||
const computed = computePlan(plan, undefined, { actuals });
|
||||
|
||||
// Planwert 2022 wäre 100'000 x 1.05^3 = 115'762. Erfasst sind 120'000.
|
||||
expect(valueAt(computePlan(plan), "fonds", 3)).toBe(115763);
|
||||
expect(valueAt(computed, "fonds", 3)).toBe(120000);
|
||||
});
|
||||
|
||||
it("rechnet ab dem Ist-Wert planmässig weiter", () => {
|
||||
const actuals = resolveActuals([setAt(2022, { fonds: { value: 120000 } })], plan, plan.elements);
|
||||
const computed = computePlan(plan, undefined, { actuals });
|
||||
|
||||
// Zwei Jahre nach dem Sprung: 120'000 x 1.05^2 = 132'300 -- genau die Erwartung aus der
|
||||
// Anforderung ("in 2024 wäre man dann bei ca. 132").
|
||||
expect(valueAt(computed, "fonds", 5)).toBe(132300);
|
||||
});
|
||||
|
||||
it("springt bei mehreren Sätzen an jeder erfassten Stelle", () => {
|
||||
const actuals = resolveActuals(
|
||||
[setAt(2022, { fonds: { value: 120000 } }), setAt(2024, { fonds: { value: 140000 } })],
|
||||
plan,
|
||||
plan.elements
|
||||
);
|
||||
const computed = computePlan(plan, undefined, { actuals });
|
||||
|
||||
expect(valueAt(computed, "fonds", 3)).toBe(120000);
|
||||
expect(valueAt(computed, "fonds", 5)).toBe(140000); // statt 132'300
|
||||
expect(valueAt(computed, "fonds", 6)).toBe(147000); // 140'000 x 1.05
|
||||
});
|
||||
|
||||
it("führt den Sprung als eigene Position, nicht als Rendite", () => {
|
||||
// Sonst erschiene eine Planabweichung als Anlageerfolg -- und die Brücke ginge nicht auf.
|
||||
const actuals = resolveActuals([setAt(2022, { fonds: { value: 120000 } })], plan, plan.elements);
|
||||
const withActuals = computePlan(plan, undefined, { actuals }).phases[0].wealthBridge;
|
||||
const planOnly = computePlan(plan).phases[0].wealthBridge;
|
||||
|
||||
// Planwert 2022 exakt: 100'000 x 1.05^3 = 115'762.50 -> Korrektur 4'237.50, gerundet 4'238.
|
||||
expect(withActuals.actualsCorrection).toBe(4238);
|
||||
// Die Rendite selbst bleibt unberührt: Der Sprung ist KEIN Anlageerfolg. Sie ist nur
|
||||
// grösser, weil ab 2022 auf einem höheren Kapital verzinst wird.
|
||||
expect(withActuals.investmentReturn).toBeGreaterThan(planOnly.investmentReturn);
|
||||
expect(planOnly.actualsCorrection).toBe(0);
|
||||
});
|
||||
|
||||
it("hält die Vermögensbrücke auch bei mehreren Sprüngen geschlossen", () => {
|
||||
const actuals = resolveActuals(
|
||||
[setAt(2022, { fonds: { value: 120000 } }), setAt(2024, { fonds: { value: 90000 } })],
|
||||
plan,
|
||||
plan.elements
|
||||
);
|
||||
const bridge = computePlan(plan, undefined, { actuals }).phases[0].wealthBridge;
|
||||
// Der zweite Sprung geht nach UNTEN -- die Korrektur muss negativ sein können.
|
||||
expect(bridge.actualsCorrection).toBeLessThan(0);
|
||||
// Der Restposten bleibt reine Rundung. Die Toleranz entspricht der, ab der die
|
||||
// Detailansicht eine Fehlermeldung zeigt (ResidualNote: > 2 Franken).
|
||||
expect(Math.abs(bridge.residual)).toBeLessThanOrEqual(2);
|
||||
});
|
||||
|
||||
it("übernimmt den effektiven Cash-Bestand und hält die Cash-Brücke geschlossen", () => {
|
||||
const actuals = resolveActuals([setAt(2022, {}, 55000)], plan, plan.elements);
|
||||
const computed = computePlan(plan, undefined, { actuals });
|
||||
const cb = computed.phases[0].cashBridge;
|
||||
|
||||
expect(cb.actualsCorrection).not.toBe(0);
|
||||
expect(Math.abs(cb.residual)).toBeLessThanOrEqual(2);
|
||||
// Der Cash-Bestand am Phasenende trägt den Sprung wirklich mit.
|
||||
expect(computed.phases[0].cashBridge.cashEnd).toBe(55000);
|
||||
});
|
||||
});
|
||||
|
||||
describe("rebaseFlow", () => {
|
||||
it("trifft im Ist-Jahr genau den erfassten Betrag", () => {
|
||||
// Basis so, dass basis * (1+idx)^(t-1) === Ist-Wert.
|
||||
const basis = rebaseFlow(120000, 2, 5);
|
||||
expect(basis * Math.pow(1.02, 4)).toBeCloseTo(120000, 6);
|
||||
});
|
||||
|
||||
it("berücksichtigt bei Ausgaben zusätzlich die Teuerung", () => {
|
||||
const basis = rebaseFlow(50000, 1, 3, 1.1);
|
||||
expect(basis * Math.pow(1.01, 2) * 1.1).toBeCloseTo(50000, 6);
|
||||
});
|
||||
|
||||
it("weicht nicht auf NaN aus, wenn kein Wachstum vorliegt", () => {
|
||||
expect(rebaseFlow(1000, 0, 1)).toBe(1000);
|
||||
expect(rebaseFlow(1000, -100, 3)).toBe(1000);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,151 @@
|
||||
// Effektive (Ist-)Werte -- Roadmap Nr. 5, Plan-/Ist-Vergleich.
|
||||
//
|
||||
// Grundgedanke: Der Plan bleibt unangetastet. Parallel dazu läuft eine ZWEITE Berechnung mit
|
||||
// derselben Mechanik, aber korrigierter Ausgangsbasis: In jedem Jahr, für das ein Ist-Satz
|
||||
// erfasst wurde, schnappen die Werte auf die Realität und laufen von dort planmässig weiter.
|
||||
//
|
||||
// Beispiel: Fonds startet 2020 mit 100 bei 5 % Rendite. Ohne Ist-Daten steht 2022 rechnerisch
|
||||
// 110 da. Wird für 2022 ein Ist-Wert von 120 erfasst, rechnet die Ist-Sicht ab 2022 mit 120
|
||||
// weiter und steht 2024 bei ~132. Kommt für 2024 ein Ist-Wert von 140 dazu, springt sie dort
|
||||
// erneut.
|
||||
//
|
||||
// Ein Ist-Satz gehört zum PLAN, nicht zum Szenario: Die Realität ist dieselbe, egal gegen
|
||||
// welches Szenario man sie hält. Die Zuordnung auf die szenario-eigenen Element-IDs läuft
|
||||
// über dieselbe Herkunfts-Kette (`sourceElementId`), die auch der Diff und die
|
||||
// Monte-Carlo-Gruppierung benutzen.
|
||||
|
||||
import { resolveRootElementId } from "@/lib/montecarlo";
|
||||
import type { ElementCategory } from "@/lib/elements";
|
||||
import type { PlanInput } from "@/lib/types";
|
||||
|
||||
// Was für ein Ist-Wert je Element erfasst werden kann. Bestände und Flüsse verhalten sich
|
||||
// verschieden: Ein Bestand ersetzt den laufenden Stand, ein Fluss die Basis für alle
|
||||
// Folgejahre.
|
||||
export type ActualKind = "STOCK" | "FLOW" | "PROPERTY" | "NONE";
|
||||
|
||||
export function actualKindOf(category: ElementCategory): ActualKind {
|
||||
switch (category) {
|
||||
case "PENSION_FUND":
|
||||
case "PILLAR_3A":
|
||||
case "OTHER_ASSET":
|
||||
return "STOCK";
|
||||
case "OTHER_DEBT":
|
||||
return "STOCK"; // Restschuld
|
||||
case "REAL_ESTATE":
|
||||
return "PROPERTY"; // Verkehrswert UND Resthypothek
|
||||
case "INCOME":
|
||||
case "EXPENSE":
|
||||
return "FLOW";
|
||||
case "AHV":
|
||||
// Die Rente folgt der amtlichen Formel aus der Beitragskarriere. Erfassbar ist sie nur,
|
||||
// wenn sie zum Stichtag bereits LÄUFT -- vorher gibt es keinen Stand. Das entscheidet
|
||||
// sich am Plan, nicht an der Kategorie (siehe `actualFieldsFor`).
|
||||
return "FLOW";
|
||||
}
|
||||
}
|
||||
|
||||
// Ein erfasster Wert je Element.
|
||||
export interface ActualElementValue {
|
||||
// Bestand bzw. Verkehrswert bei Immobilien; bei Flüssen der NOMINALE Jahresbetrag
|
||||
// (Einkommen: was aufs Konto kam; Ausgaben: was tatsächlich ausgegeben wurde).
|
||||
value?: number;
|
||||
// Nur Immobilie: tatsächliche Restschuld.
|
||||
mortgage?: number;
|
||||
}
|
||||
|
||||
// Ein Ist-Satz, wie er gespeichert wird. Schlüssel sind WURZEL-Element-IDs.
|
||||
export interface ActualsSetInput {
|
||||
id: string;
|
||||
recordedOn: string; // exaktes Datum (ISO) -- nur für Liste und Zeitachse
|
||||
year: number; // Kalenderjahr; nur dieses geht in die Rechnung ein
|
||||
comment?: string | null;
|
||||
cash?: number | null;
|
||||
values: Record<string, ActualElementValue>;
|
||||
}
|
||||
|
||||
// Auf ein konkretes Szenario aufgelöster Ist-Satz: Schlüssel sind die Element-IDs DIESES
|
||||
// Szenarios, und das Kalenderjahr ist in ein Planjahr (1-basiert) umgerechnet.
|
||||
export interface ResolvedActuals {
|
||||
planYear: number; // 1 = erstes Planjahr
|
||||
year: number; // Kalenderjahr (für Anzeige)
|
||||
cash?: number;
|
||||
byElementId: Record<string, ActualElementValue>;
|
||||
}
|
||||
|
||||
// Kalenderjahr -> Planjahr. `startYear` ist das Kalenderjahr des ersten Planjahres.
|
||||
export function toPlanYear(year: number, startYear: number | null | undefined): number | null {
|
||||
if (!startYear) return null;
|
||||
return year - startYear + 1;
|
||||
}
|
||||
|
||||
// Herkunft eines Elements: die lose Referenz auf sein Gegenstück im Eltern-Szenario.
|
||||
export interface ElementOrigin {
|
||||
id: string;
|
||||
sourceElementId?: string | null;
|
||||
}
|
||||
|
||||
// Bildet die gespeicherten Ist-Sätze auf ein Szenario ab.
|
||||
//
|
||||
// `origins` enthält die Elemente ALLER Szenarien des Plans -- nur so löst sich die
|
||||
// Herkunfts-Kette auch über ein übersprungenes Zwischen-Szenario hinweg auf.
|
||||
export function resolveActuals(
|
||||
sets: ActualsSetInput[],
|
||||
scenario: PlanInput,
|
||||
origins: ElementOrigin[]
|
||||
): ResolvedActuals[] {
|
||||
const sourceById = new Map<string, string | null>();
|
||||
for (const o of origins) sourceById.set(o.id, o.sourceElementId ?? null);
|
||||
// Sicherheitsnetz: Elemente des betrachteten Szenarios sind immer dabei.
|
||||
for (const e of scenario.elements) if (!sourceById.has(e.id)) sourceById.set(e.id, e.sourceElementId ?? null);
|
||||
|
||||
const totalYears = scenario.phases.reduce((s, p) => s + p.durationYears, 0);
|
||||
|
||||
return sets
|
||||
.flatMap<ResolvedActuals>((set) => {
|
||||
const planYear = toPlanYear(set.year, scenario.startYear);
|
||||
if (planYear === null) return [];
|
||||
const byElementId: Record<string, ActualElementValue> = {};
|
||||
for (const e of scenario.elements) {
|
||||
const root = resolveRootElementId(e.id, sourceById);
|
||||
const v = set.values[root];
|
||||
// Lücken fallen bewusst auf die Plandaten zurück: Ein Element ohne Ist-Wert läuft
|
||||
// unverändert auf seiner Planlinie weiter.
|
||||
if (v && (typeof v.value === "number" || typeof v.mortgage === "number")) {
|
||||
byElementId[e.id] = v;
|
||||
}
|
||||
}
|
||||
const out: ResolvedActuals = { planYear, year: set.year, byElementId };
|
||||
if (typeof set.cash === "number") out.cash = set.cash;
|
||||
return [out];
|
||||
})
|
||||
// Ausserhalb des Plans liegende Sätze werden ignoriert -- sie hätten keinen Angriffspunkt.
|
||||
.filter((r) => r.planYear >= 1 && r.planYear <= totalYears)
|
||||
// Bei zwei Sätzen im selben Planjahr gewinnt der zuletzt erfasste.
|
||||
.sort((a, b) => a.planYear - b.planYear);
|
||||
}
|
||||
|
||||
// Nachschlagen im Rechenkern: Gibt es für dieses Planjahr einen Ist-Satz?
|
||||
export function actualsForYear(list: ResolvedActuals[], planYear: number): ResolvedActuals | null {
|
||||
// Rückwärts, damit bei mehreren Sätzen im selben Jahr der letzte gewinnt.
|
||||
for (let i = list.length - 1; i >= 0; i--) if (list[i].planYear === planYear) return list[i];
|
||||
return null;
|
||||
}
|
||||
|
||||
// Das jüngste erfasste Planjahr -- Startpunkt der Monte-Carlo-Simulation im Ist-Modus und
|
||||
// Grundlage der Hervorhebung auf der Zeitachse.
|
||||
export function latestPlanYear(list: ResolvedActuals[]): number | null {
|
||||
return list.length === 0 ? null : Math.max(...list.map((r) => r.planYear));
|
||||
}
|
||||
|
||||
// Rechnet einen Ist-Fluss auf die Basis zurück, mit der der Rechenkern arbeitet.
|
||||
//
|
||||
// Einkommen laufen als `basis * (1 + idx/100)^(t-1)`. Ist für Jahr t ein Ist-Wert erfasst,
|
||||
// muss die Basis so gesetzt werden, dass die Formel in genau diesem Jahr den Ist-Wert trifft
|
||||
// -- danach wächst sie planmässig weiter. Das ist gleichbedeutend damit, den Bezugspunkt der
|
||||
// Reihe auf das Ist-Jahr zu legen, kommt aber ohne Eingriff in die Struktur aus.
|
||||
export function rebaseFlow(actualValue: number, idxPercent: number, tInPhase: number, deflator = 1): number {
|
||||
const growth = Math.pow(1 + idxPercent / 100, tInPhase - 1);
|
||||
const divisor = growth * (deflator || 1);
|
||||
if (!Number.isFinite(divisor) || divisor === 0) return actualValue;
|
||||
return actualValue / divisor;
|
||||
}
|
||||
+73
-2
@@ -11,6 +11,7 @@ import {
|
||||
DEFAULT_PROPERTY_GAINS_TAX_RATE,
|
||||
} from "@/lib/constants";
|
||||
import { num } from "@/lib/elements";
|
||||
import { actualsForYear, rebaseFlow, type ResolvedActuals } from "@/lib/actuals";
|
||||
import type { ElementCategory } from "@/lib/elements";
|
||||
import type { PersonRole, PlanInput } from "@/lib/types";
|
||||
|
||||
@@ -50,6 +51,9 @@ export interface ComputeOptions {
|
||||
// Standardmässig aus: die Monte-Carlo-Simulation ruft computePlan zehntausendfach auf
|
||||
// und darf von der Protokollierung nichts merken.
|
||||
explain?: boolean;
|
||||
// Effektive (Ist-)Werte, auf DIESES Szenario aufgelöst (siehe `actuals.ts`). Ohne sie
|
||||
// rechnet die Funktion exakt wie bisher -- die Plan-Sicht bleibt unangetastet.
|
||||
actuals?: ResolvedActuals[];
|
||||
}
|
||||
|
||||
// Ein Datenpunkt pro Jahr JE ELEMENT -- Grundlage der Detailansicht (Roadmap Nr. 43).
|
||||
@@ -111,6 +115,10 @@ export interface WealthBridge {
|
||||
investmentReturn: number; // Rendite auf PK/3a/Sonstigem Vermögen
|
||||
propertyAppreciation: number; // Wertsteigerung der Liegenschaft
|
||||
pensionFundContribution: number; // PK-Beiträge: erhöhen das Vermögen, ohne Cash zu kosten
|
||||
// Sprung auf die erfassten Ist-Werte (nur in der Ist-Sicht, sonst 0). Bewusst als eigene
|
||||
// Position: Die Differenz zwischen Plan und Wirklichkeit ist KEINE Rendite und darf nicht
|
||||
// als solche erscheinen -- ohne diese Zeile ginge die Brücke im Ist-Jahr nicht auf.
|
||||
actualsCorrection: number;
|
||||
endWealth: number; // = endWealthNominal
|
||||
residual: number; // Rundungsdifferenz (Kontrollgrösse, sollte nahe 0 sein)
|
||||
}
|
||||
@@ -127,6 +135,7 @@ export interface CashBridge {
|
||||
savingRates: number; // 3a + Sparbeiträge (Abgang)
|
||||
debtRates: number; // Amortisationen + Tilgungen (Abgang)
|
||||
withdrawals: number; // Bezugsraten aus Sonstigem Vermögen (Zugang)
|
||||
actualsCorrection: number; // Sprung auf den erfassten Ist-Cashbestand (sonst 0)
|
||||
cashEnd: number;
|
||||
residual: number;
|
||||
}
|
||||
@@ -332,6 +341,8 @@ function pct(v: number): string {
|
||||
|
||||
export function computePlan(plan: PlanInput, sample?: PlanSample, options?: ComputeOptions): PlanComputed {
|
||||
const explain = options?.explain === true;
|
||||
// Ohne Ist-Werte verhält sich die Funktion exakt wie bisher (die Golden Tests belegen es).
|
||||
const actuals = options?.actuals;
|
||||
const phases = [...plan.phases].sort((a, b) => a.sequenceNumber - b.sequenceNumber);
|
||||
const persons = plan.persons;
|
||||
const personA = persons.find((p) => p.role === "PERSON_A") ?? persons[0];
|
||||
@@ -703,6 +714,10 @@ export function computePlan(plan: PlanInput, sample?: PlanSample, options?: Comp
|
||||
let savingRatesTotal = 0;
|
||||
let debtRatesTotal = 0;
|
||||
let withdrawalsTotal = 0;
|
||||
// Sprung auf die Ist-Werte. Ohne Ist-Daten bleiben beide 0 und die Brücken rechnen
|
||||
// exakt wie bisher.
|
||||
let actualsCorrectionTotal = 0;
|
||||
let actualsCashCorrectionTotal = 0;
|
||||
|
||||
for (let t = 1; t <= duration; t++) {
|
||||
// Einkommen: nominal (Basis x (1+Lohnerhöhung)^(t-1)) + Renten (nominal fix).
|
||||
@@ -787,6 +802,58 @@ export function computePlan(plan: PlanInput, sample?: PlanSample, options?: Comp
|
||||
if (t === 1) plannedSaveRate = fixedRatesTotal + debtRates;
|
||||
|
||||
cash += quote - fixedRatesTotal - debtRates + cashFromWithdraw;
|
||||
|
||||
// --- Effektive Werte einspielen (Roadmap Nr. 5) --------------------------------------
|
||||
// Bewusst NACH Verzinsung, Tilgung und Cash-Fortschreibung: Der erfasste Wert ist der
|
||||
// Stand AM ENDE des Ist-Jahres. Rechnet man 2022 mit 120 und wieder 2024 mit 140, so
|
||||
// liegen dazwischen genau zwei Wachstumsjahre -- das entspricht der Erwartung.
|
||||
//
|
||||
// Die Differenz wird als eigene Grösse geführt und NICHT den Renditen zugeschlagen:
|
||||
// Ein Rückstand gegenüber dem Plan ist keine negative Rendite, sondern eine Korrektur.
|
||||
const act = actuals ? actualsForYear(actuals, yearsBefore + t) : null;
|
||||
if (act) {
|
||||
for (const a of assets) {
|
||||
const v = act.byElementId[a.ec.elementId]?.value;
|
||||
if (typeof v !== "number") continue; // Lücke -> Planlinie läuft weiter
|
||||
actualsCorrectionTotal += v - a.value;
|
||||
a.value = v;
|
||||
}
|
||||
for (const re of realEstates) {
|
||||
const av = act.byElementId[re.ec.elementId];
|
||||
if (typeof av?.value === "number") {
|
||||
actualsCorrectionTotal += av.value - re.value;
|
||||
re.value = av.value;
|
||||
}
|
||||
if (typeof av?.mortgage === "number") {
|
||||
// Eine höhere Restschuld mindert das Vermögen -- Vorzeichen umgekehrt.
|
||||
actualsCorrectionTotal -= av.mortgage - re.mortgage;
|
||||
re.mortgage = av.mortgage;
|
||||
}
|
||||
}
|
||||
for (const d of debts) {
|
||||
const v = act.byElementId[d.ec.elementId]?.value;
|
||||
if (typeof v !== "number") continue;
|
||||
actualsCorrectionTotal -= v - d.owed;
|
||||
d.owed = v;
|
||||
}
|
||||
// Flüsse: Der erfasste Betrag gilt für DIESES Jahr; die Basis wird so zurückgerechnet,
|
||||
// dass die Reihe hier den Ist-Wert trifft und danach planmässig weiterwächst.
|
||||
for (const inc of incomes) {
|
||||
const v = act.byElementId[inc.ec.elementId]?.value;
|
||||
if (typeof v === "number") inc.basis = rebaseFlow(v, inc.idx, t);
|
||||
}
|
||||
for (const exp of expenses) {
|
||||
const v = act.byElementId[exp.ec.elementId]?.value;
|
||||
// Ausgaben werden real geführt, erfasst wird der nominale Ist-Betrag.
|
||||
if (typeof v === "number") exp.basis = rebaseFlow(v, exp.idx, t, inflFactor);
|
||||
}
|
||||
if (typeof act.cash === "number") {
|
||||
actualsCashCorrectionTotal += act.cash - cash;
|
||||
actualsCorrectionTotal += act.cash - cash;
|
||||
cash = act.cash;
|
||||
}
|
||||
}
|
||||
|
||||
if (cash < 0) cashNegative = true;
|
||||
|
||||
quotaTotal += quote;
|
||||
@@ -1203,6 +1270,7 @@ export function computePlan(plan: PlanInput, sample?: PlanSample, options?: Comp
|
||||
investmentReturn: Math.round(investmentReturnTotal),
|
||||
propertyAppreciation: Math.round(propertyAppreciationTotal),
|
||||
pensionFundContribution: Math.round(pensionFundContributionTotal),
|
||||
actualsCorrection: Math.round(actualsCorrectionTotal),
|
||||
endWealth: endWealthNominal,
|
||||
residual:
|
||||
endWealthNominal -
|
||||
@@ -1215,7 +1283,8 @@ export function computePlan(plan: PlanInput, sample?: PlanSample, options?: Comp
|
||||
Math.round(quotaTotal) +
|
||||
Math.round(investmentReturnTotal) +
|
||||
Math.round(propertyAppreciationTotal) +
|
||||
Math.round(pensionFundContributionTotal)),
|
||||
Math.round(pensionFundContributionTotal) +
|
||||
Math.round(actualsCorrectionTotal)),
|
||||
},
|
||||
cashBridge: {
|
||||
openingCash: isFirstPhase ? Math.round(plan.initialCash || 0) : previousCashEnd,
|
||||
@@ -1229,6 +1298,7 @@ export function computePlan(plan: PlanInput, sample?: PlanSample, options?: Comp
|
||||
savingRates: Math.round(savingRatesTotal),
|
||||
debtRates: Math.round(debtRatesTotal),
|
||||
withdrawals: Math.round(withdrawalsTotal),
|
||||
actualsCorrection: Math.round(actualsCashCorrectionTotal),
|
||||
cashEnd,
|
||||
residual:
|
||||
cashEnd -
|
||||
@@ -1236,7 +1306,8 @@ export function computePlan(plan: PlanInput, sample?: PlanSample, options?: Comp
|
||||
Math.round(quotaTotal) -
|
||||
Math.round(savingRatesTotal) -
|
||||
Math.round(debtRatesTotal) +
|
||||
Math.round(withdrawalsTotal)),
|
||||
Math.round(withdrawalsTotal) +
|
||||
Math.round(actualsCashCorrectionTotal)),
|
||||
},
|
||||
traces: explain ? phaseTraces : undefined,
|
||||
});
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { buildViews, deviation, viewFor } from "@/lib/dataview";
|
||||
import type { ActualsSetInput } from "@/lib/actuals";
|
||||
import type { PlanInput } from "@/lib/types";
|
||||
|
||||
function plan(): PlanInput {
|
||||
return {
|
||||
id: "s",
|
||||
name: "T",
|
||||
householdType: "SINGLE",
|
||||
inflationRateDefault: 0,
|
||||
initialCash: 0,
|
||||
startYear: 2020,
|
||||
persons: [{ id: "A", role: "PERSON_A", name: null, age: 40, retirementAge: 65 }],
|
||||
phases: [{ id: "p1", sequenceNumber: 1, name: "Erwerb", durationYears: 10, cashTransition: {} }],
|
||||
elements: [
|
||||
{
|
||||
id: "fonds",
|
||||
category: "OTHER_ASSET",
|
||||
name: "Fonds",
|
||||
ownerRole: "HOUSEHOLD",
|
||||
orderIndex: 1,
|
||||
phaseValues: { p1: { startValue: 100000, expectedReturn: 5 } },
|
||||
transitionValues: {},
|
||||
sourceElementId: null,
|
||||
},
|
||||
],
|
||||
} as unknown as PlanInput;
|
||||
}
|
||||
|
||||
const set: ActualsSetInput = {
|
||||
id: "a1",
|
||||
recordedOn: "2022-08-18",
|
||||
year: 2022,
|
||||
cash: null,
|
||||
values: { fonds: { value: 120000 } },
|
||||
};
|
||||
|
||||
const endOf = (c: { phases: { endWealthNominal: number }[] }) => c.phases[c.phases.length - 1].endWealthNominal;
|
||||
|
||||
describe("buildViews", () => {
|
||||
it("liefert ohne Ist-Werte gar keine Ist-Sicht", () => {
|
||||
// Wichtig für die Oberfläche: Der Umschalter erscheint dann gar nicht erst.
|
||||
const v = buildViews(plan(), [], plan().elements);
|
||||
expect(v.actual).toBeNull();
|
||||
expect(v.actualYears).toEqual([]);
|
||||
expect(v.latestActualPlanYear).toBeNull();
|
||||
});
|
||||
|
||||
it("rechnet beide Sichten und lässt die Plan-Sicht unberührt", () => {
|
||||
const p = plan();
|
||||
const v = buildViews(p, [set], p.elements);
|
||||
const planOnly = buildViews(p, [], p.elements);
|
||||
|
||||
expect(v.actual).not.toBeNull();
|
||||
expect(endOf(v.plan)).toBe(endOf(planOnly.plan));
|
||||
expect(endOf(v.actual!)).toBeGreaterThan(endOf(v.plan));
|
||||
});
|
||||
|
||||
it("merkt sich die erfassten Jahre für die Zeitachse", () => {
|
||||
const p = plan();
|
||||
const v = buildViews(p, [set, { ...set, id: "a2", recordedOn: "2024-01-05", year: 2024 }], p.elements);
|
||||
expect(v.actualYears).toEqual([2022, 2024]);
|
||||
expect(v.latestActualPlanYear).toBe(5);
|
||||
});
|
||||
});
|
||||
|
||||
describe("viewFor", () => {
|
||||
it("fällt ohne Ist-Sicht auf den Plan zurück, statt leer zu bleiben", () => {
|
||||
const v = buildViews(plan(), [], plan().elements);
|
||||
expect(viewFor(v, "ACTUAL")).toBe(v.plan);
|
||||
});
|
||||
|
||||
it("liefert mit Ist-Werten die Ist-Sicht", () => {
|
||||
const p = plan();
|
||||
const v = buildViews(p, [set], p.elements);
|
||||
expect(viewFor(v, "ACTUAL")).toBe(v.actual);
|
||||
expect(viewFor(v, "PLAN")).toBe(v.plan);
|
||||
});
|
||||
});
|
||||
|
||||
describe("deviation", () => {
|
||||
it("behauptet ohne Ist-Sicht keine Abweichung von 0", () => {
|
||||
const v = buildViews(plan(), [], plan().elements);
|
||||
expect(deviation(v, endOf)).toBeNull();
|
||||
});
|
||||
|
||||
it("misst die Abweichung Ist gegenüber Plan", () => {
|
||||
const p = plan();
|
||||
const v = buildViews(p, [set], p.elements);
|
||||
const d = deviation(v, endOf)!;
|
||||
expect(d).toBeGreaterThan(0);
|
||||
expect(d).toBe(endOf(v.actual!) - endOf(v.plan));
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,67 @@
|
||||
// Datensicht: Plandaten oder effektive (Ist-)Daten.
|
||||
//
|
||||
// Der Plan-Lauf bleibt unangetastet -- die Ist-Sicht ist ein ZWEITER Lauf derselben Mechanik
|
||||
// mit korrigierter Ausgangsbasis (siehe `actuals.ts`). Dieses Modul bündelt, was beide
|
||||
// Sichten gemeinsam brauchen, damit sich Matrix, Grafiken und die drei Analysewerkzeuge
|
||||
// identisch verhalten.
|
||||
|
||||
import { computePlan } from "@/lib/calculations";
|
||||
import { resolveActuals, latestPlanYear, type ActualsSetInput, type ElementOrigin } from "@/lib/actuals";
|
||||
import type { PlanComputed } from "@/lib/calculations";
|
||||
import type { PlanInput } from "@/lib/types";
|
||||
|
||||
// In der Matrix stehen genau zwei Möglichkeiten zur Wahl: die reinen Planzahlen oder die
|
||||
// Ist-Zahlen MIT Abweichung. Ein Nebeneinander beider Rohwerte wäre bei zusätzlich
|
||||
// nominal/real eine Zelle mit acht Zahlen (siehe SPEZIFIKATION 9.29).
|
||||
export type DataSource = "PLAN" | "ACTUAL";
|
||||
|
||||
export const DATA_SOURCE_OPTIONS: { value: DataSource; label: string; short: string }[] = [
|
||||
{ value: "PLAN", label: "Planzahlen", short: "Plan" },
|
||||
{ value: "ACTUAL", label: "Effektive Zahlen inkl. Abweichung", short: "Effektiv" },
|
||||
];
|
||||
|
||||
export interface DataViews {
|
||||
plan: PlanComputed;
|
||||
// Null, solange für diesen Plan keine verwertbaren Ist-Werte erfasst sind.
|
||||
actual: PlanComputed | null;
|
||||
// Jüngstes erfasstes Planjahr -- Startpunkt der Monte-Carlo-Simulation im Ist-Modus und
|
||||
// Grundlage der Hervorhebung auf der Zeitachse.
|
||||
latestActualPlanYear: number | null;
|
||||
// Kalenderjahre mit Ist-Satz, aufsteigend (für die Marker auf der Zeitachse).
|
||||
actualYears: number[];
|
||||
}
|
||||
|
||||
// Beide Sichten in einem Zug. `computePlan` ist rein und kostet rund 0.2 ms -- zwei Läufe
|
||||
// sind billiger als jede Zwischenspeicherung.
|
||||
export function buildViews(
|
||||
plan: PlanInput,
|
||||
sets: ActualsSetInput[],
|
||||
origins: ElementOrigin[]
|
||||
): DataViews {
|
||||
const planComputed = computePlan(plan);
|
||||
const resolved = resolveActuals(sets, plan, origins);
|
||||
|
||||
if (resolved.length === 0) {
|
||||
return { plan: planComputed, actual: null, latestActualPlanYear: null, actualYears: [] };
|
||||
}
|
||||
|
||||
return {
|
||||
plan: planComputed,
|
||||
actual: computePlan(plan, undefined, { actuals: resolved }),
|
||||
latestActualPlanYear: latestPlanYear(resolved),
|
||||
actualYears: [...new Set(resolved.map((r) => r.year))].sort((a, b) => a - b),
|
||||
};
|
||||
}
|
||||
|
||||
// Die für die gewählte Quelle massgebende Berechnung. Fehlen Ist-Werte, bleibt es beim Plan --
|
||||
// eine leere Ansicht wäre die schlechtere Antwort als eine ehrliche Rückfallebene.
|
||||
export function viewFor(views: DataViews, source: DataSource): PlanComputed {
|
||||
return source === "ACTUAL" ? views.actual ?? views.plan : views.plan;
|
||||
}
|
||||
|
||||
// Abweichung Ist gegenüber Plan. `null`, wenn es keine Ist-Sicht gibt -- dann zeigt die
|
||||
// Oberfläche gar keine Abweichung an, statt eine von 0 zu behaupten.
|
||||
export function deviation(views: DataViews, pick: (c: PlanComputed) => number): number | null {
|
||||
if (!views.actual) return null;
|
||||
return pick(views.actual) - pick(views.plan);
|
||||
}
|
||||
+8
-2
@@ -17,6 +17,7 @@ import { applyDriver, applyElementDriver, driverById, DRIVERS, tunableElements }
|
||||
import type { DriverId, DriverUnit } from "@/lib/sensitivity";
|
||||
import type { PlanComputed } from "@/lib/calculations";
|
||||
import type { PlanInput } from "@/lib/types";
|
||||
import type { ResolvedActuals } from "@/lib/actuals";
|
||||
|
||||
// Ein Regler: entweder ein plan-weiter Treiber oder die Rendite eines einzelnen Elements.
|
||||
export type SliderRef = { kind: "driver"; id: DriverId } | { kind: "element"; id: string };
|
||||
@@ -147,9 +148,14 @@ export interface LiveResult {
|
||||
kpis: LiveKpis;
|
||||
}
|
||||
|
||||
export function runLive(plan: PlanInput, sliders: SliderDef[], values: SliderValues): LiveResult {
|
||||
export function runLive(
|
||||
plan: PlanInput,
|
||||
sliders: SliderDef[],
|
||||
values: SliderValues,
|
||||
actuals?: ResolvedActuals[]
|
||||
): LiveResult {
|
||||
const tuned = applySliders(plan, sliders, values);
|
||||
const computed = computePlan(tuned);
|
||||
const computed = computePlan(tuned, undefined, actuals ? { actuals } : undefined);
|
||||
return { plan: tuned, computed, kpis: kpisOf(computed) };
|
||||
}
|
||||
|
||||
|
||||
@@ -86,5 +86,24 @@ describe("Datenbank-Migrationen", () => {
|
||||
)
|
||||
).rows.map((r) => r.indexname);
|
||||
expect(idx).toContain("ScenarioVersion_scenarioId_major_minor_key");
|
||||
|
||||
// --- Effektive Werte ---
|
||||
expect(tables, "Tabelle ActualsSet fehlt").toContain("ActualsSet");
|
||||
const actCols = await cols("ActualsSet");
|
||||
for (const c of ["planId", "recordedOn", "year", "comment", "cash", "values", "createdById"]) {
|
||||
expect(actCols, `ActualsSet.${c} fehlt`).toContain(c);
|
||||
}
|
||||
|
||||
// Der Ist-Satz hängt am PLAN, nicht am Szenario -- sonst wäre die Realität pro Szenario
|
||||
// verschieden erfasst.
|
||||
expect(actCols).not.toContain("scenarioId");
|
||||
|
||||
// `cash` muss leer bleiben dürfen: Wer den Kontostand nicht kennt, soll den Satz trotzdem
|
||||
// erfassen können (Lücken fallen auf die Plandaten zurück).
|
||||
const cashCol = await db.query<{ is_nullable: string }>(
|
||||
`SELECT is_nullable FROM information_schema.columns
|
||||
WHERE table_name='ActualsSet' AND column_name='cash'`
|
||||
);
|
||||
expect(cashCol.rows[0].is_nullable).toBe("YES");
|
||||
}, 60000);
|
||||
});
|
||||
|
||||
+10
-6
@@ -18,6 +18,7 @@ import { computePlan } from "@/lib/calculations";
|
||||
import { num } from "@/lib/elements";
|
||||
import type { ElementCategory, PhaseData } from "@/lib/elements";
|
||||
import type { ElementInput, PlanInput } from "@/lib/types";
|
||||
import type { ResolvedActuals } from "@/lib/actuals";
|
||||
|
||||
export type DriverId =
|
||||
| "inflation"
|
||||
@@ -292,8 +293,8 @@ export function ineffectiveReason(plan: PlanInput, id: DriverId): string {
|
||||
}
|
||||
|
||||
// Zielgrösse: Endvermögen der letzten Phase, real (kaufkraftbereinigt) oder nominal.
|
||||
export function planMetric(plan: PlanInput, metric: TornadoMetric): number {
|
||||
const computed = computePlan(plan);
|
||||
export function planMetric(plan: PlanInput, metric: TornadoMetric, actuals?: ResolvedActuals[]): number {
|
||||
const computed = computePlan(plan, undefined, actuals ? { actuals } : undefined);
|
||||
const last = computed.phases[computed.phases.length - 1];
|
||||
if (!last) return 0;
|
||||
return Math.round(metric === "real" ? last.endWealthReal : last.endWealthNominal);
|
||||
@@ -302,14 +303,17 @@ export function planMetric(plan: PlanInput, metric: TornadoMetric): number {
|
||||
export function computeTornado(
|
||||
plan: PlanInput,
|
||||
metric: TornadoMetric,
|
||||
inputs: TornadoInput[]
|
||||
inputs: TornadoInput[],
|
||||
// Effektive Werte: Die Treiber wirken dann nur noch auf die NICHT belegten Jahre -- was
|
||||
// erfasst ist, steht fest. Die Balken fallen dadurch zu Recht kuerzer aus.
|
||||
actuals?: ResolvedActuals[]
|
||||
): TornadoResult {
|
||||
const base = planMetric(plan, metric);
|
||||
const base = planMetric(plan, metric, actuals);
|
||||
|
||||
const bars: TornadoBar[] = inputs.map((input) => {
|
||||
const def = driverById(input.id);
|
||||
const lowResult = planMetric(applyDriver(plan, input.id, input.low), metric);
|
||||
const highResult = planMetric(applyDriver(plan, input.id, input.high), metric);
|
||||
const lowResult = planMetric(applyDriver(plan, input.id, input.low), metric, actuals);
|
||||
const highResult = planMetric(applyDriver(plan, input.id, input.high), metric, actuals);
|
||||
// Die Richtung kann sich umkehren (tiefe Ausgaben -> hohes Vermögen). Der Balken spannt
|
||||
// deshalb über min..max; welche Eingabe zu welchem Ende gehört, zeigt die Tabelle.
|
||||
const swing = Math.abs(highResult - lowResult);
|
||||
|
||||
@@ -29,6 +29,9 @@ const EXEMPT: Record<string, string> = {
|
||||
"plans/[planId]": "ändert nur den Plan-Namen bzw. löscht den ganzen Plan – kein Szenario-Inhalt",
|
||||
"scenarios/[scenarioId]/versions": "erzeugt Versionen selbst (Hauptversion / Wiederherstellen)",
|
||||
"scenarios/[scenarioId]/versions/[versionId]": "erzeugt Versionen selbst",
|
||||
"plans/[planId]/actuals":
|
||||
"Ist-Werte sind eine Beobachtung, keine Planänderung – sie verändern kein Szenario",
|
||||
"plans/[planId]/actuals/[setId]": "dito (Löschen eines Ist-Satzes)",
|
||||
};
|
||||
|
||||
describe("Versionierung: Abdeckung der Schreibpfade", () => {
|
||||
|
||||
Reference in New Issue
Block a user