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

Neuer Knopf auf Plan-Ebene: Liste plus Wizard in zwei Schritten. Ein
Ist-Satz haengt am PLAN, nicht am Szenario -- die Zuordnung laeuft ueber
die Herkunfts-Kette sourceElementId.

computePlan nimmt neu { actuals }: Die Werte schnappen in jedem erfassten
Jahr auf die Realitaet und laufen von dort planmaessig weiter. Luecken
fallen auf die Plandaten zurueck. Ohne die Option unveraendert -- die 43
Golden Tests laufen durch.

Der Sprung ist keine Rendite: eigene Brueckenposition actualsCorrection
in Vermoegens- und Cash-Bruecke, sonst ginge die Zerlegung nicht auf.

Matrix: Umschalter Plan/Effektiv, im Ist-Modus mit farbiger Abweichung
statt acht Zahlen je Zelle. Zeitachse: Marker je Jahr, juengster farbig.

Vier Analysewerkzeuge mit einheitlicher Leiste (nominal/real als
Einfachauswahl, Plan/Effektiv). MC: Zielbetrag dreht mit, Startjahr
abgeleitet statt eingebbar.

Neue Tabelle ActualsSet (gegen echtes Postgres verifiziert), Module
actuals.ts und dataview.ts. Spezifikation 0.21, 27 Tests (181 -> 208).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-20 20:35:07 +02:00
parent 4343d50aa4
commit e117e5a4e5
25 changed files with 1991 additions and 74 deletions
+78 -4
View File
@@ -46,6 +46,7 @@ import { PlanProfileFields, type ProfileDraft } from "@/components/PlanProfileFi
import { MoneyField } from "@/components/FormField";
import { api } from "@/lib/api-client";
import { formatChf } from "@/lib/format";
import { DATA_SOURCE_OPTIONS, type DataSource } from "@/lib/dataview";
import {
CATEGORY_LABELS,
CATEGORY_ORDER,
@@ -107,7 +108,9 @@ type Panel =
export function PlanView({
plan,
computed,
computed: planComputed,
actualComputed = null,
actualYears = [],
diff,
onChanged,
onOpenSpec,
@@ -115,12 +118,32 @@ export function PlanView({
}: {
plan: PlanInput;
computed: PlanComputed;
// Zweiter Rechenlauf mit den effektiven Werten; null, wenn keine erfasst sind.
actualComputed?: PlanComputed | null;
// Kalenderjahre mit Ist-Satz (Marker auf der Zeitachse).
actualYears?: number[];
// Abweichungen gegenüber dem Eltern-Szenario; null im Basisszenario (nichts zu markieren).
diff: ScenarioDiff | null;
onChanged: () => void;
onOpenSpec?: (anchor: string) => void;
onOpenSensitivity?: () => void;
}) {
// Planzahlen oder effektive Zahlen (inkl. Abweichung). Bewusst zwei Möglichkeiten statt
// dreier: Plan UND Ist als Rohwerte nebeneinander wären mit nominal/real acht Zahlen je
// Zelle (siehe SPEZIFIKATION 9.29).
const [dataSource, setDataSource] = useState<DataSource>("PLAN");
const hasActuals = actualComputed !== null;
const computed = dataSource === "ACTUAL" && actualComputed ? actualComputed : planComputed;
// Planwert derselben Zelle -- Grundlage der Abweichung. In der Plan-Sicht null, dann zeigt
// die Zelle gar keine Abweichung an (statt eine von 0 zu behaupten).
const showDeviation = dataSource === "ACTUAL" && actualComputed !== null;
const planEndOf = (elementId: string | undefined, phaseId: string): number | null => {
if (!showDeviation || !elementId) return null;
const ph = planComputed.phases.find((p) => p.id === phaseId);
const el = ph?.elements.find((e) => e.elementId === elementId);
return el ? el.endValue : null;
};
const confirmDialog = useConfirm();
const toast = useToast();
// Markierungs-Klassen: geändert = gelb, neu = grün, entfernt = grau.
@@ -366,6 +389,32 @@ export function PlanView({
))}
</div>
<span className="text-[11px] text-faint">real = kaufkraftbereinigt (Planbeginn)</span>
{/* Zweite Achse: Datenquelle. Erscheint nur, wenn es überhaupt Ist-Werte gibt --
sonst wäre es ein Umschalter ohne Gegenstück. */}
{hasActuals && (
<>
<span className="ml-2 text-xs text-muted">Zahlen</span>
<div className="inline-flex rounded-lg border border-border bg-surface p-0.5 text-xs">
{DATA_SOURCE_OPTIONS.map((o) => (
<button
key={o.value}
type="button"
onClick={() => setDataSource(o.value)}
title={o.label}
className={`rounded-md px-2.5 py-1 font-medium ${
dataSource === o.value ? "bg-accent text-accent-fg" : "text-muted hover:bg-surface-2"
}`}
>
{o.short}
</button>
))}
</div>
{dataSource === "ACTUAL" && (
<span className="text-[11px] text-faint">Abweichung gegenüber Plan farbig</span>
)}
</>
)}
</div>
<div data-tour="timeline">
@@ -373,6 +422,7 @@ export function PlanView({
phases={computed.phases}
persons={personAxes}
ruinAge={computed.ruinAge}
actualYears={actualYears}
startYear={plan.startYear}
/>
</div>
@@ -629,7 +679,7 @@ export function PlanView({
ce?.locked ? "text-faint" : "text-fg"
} ${cellDiff(el.id, col.phase.id)}`}
>
{phaseCellContent(ce, col.phase, valueMode)}
{phaseCellContent(ce, col.phase, valueMode, planEndOf(ce?.elementId, col.phase.id))}
</td>
);
}
@@ -992,12 +1042,16 @@ function ValuePair({
deflatorStart,
deflatorEnd,
mode,
devEnd,
}: {
start: number;
end: number;
deflatorStart: number;
deflatorEnd: number;
mode: ValueMode;
// Abweichung des ENDwerts gegenüber dem Plan (nur in der Ist-Sicht gesetzt). Bewusst nur
// ein Wert statt Start und Ende: Die Zelle soll nicht zur zweiten Tabelle werden.
devEnd?: number | null;
}) {
const arrow = <span className="text-faint"></span>;
return (
@@ -1005,16 +1059,31 @@ function ValuePair({
<span className="whitespace-nowrap tabular-nums">
{mode === "real" ? formatChf(realOf(start, deflatorStart)) : formatChf(start)} {arrow}{" "}
{mode === "real" ? formatChf(realOf(end, deflatorEnd)) : formatChf(end)}
<Deviation value={devEnd} deflator={mode === "real" ? deflatorEnd : 1} />
</span>
{mode === "both" && (
<span className="whitespace-nowrap tabular-nums text-faint">
({formatChf(realOf(start, deflatorStart))}) {arrow} ({formatChf(realOf(end, deflatorEnd))})
<Deviation value={devEnd} deflator={deflatorEnd} />
</span>
)}
</span>
);
}
// Die Abweichung trägt als einziges Element Farbe -- würde man die Beträge selbst einfärben,
// entstünde ein Ampelteppich, in dem nichts mehr heraussticht.
function Deviation({ value, deflator = 1 }: { value?: number | null; deflator?: number }) {
if (value == null || Math.round(value / (deflator || 1)) === 0) return null;
const v = Math.round(value / (deflator || 1));
return (
<span className={`ml-1.5 font-semibold ${v > 0 ? "text-success" : "text-danger"}`}>
{v > 0 ? "▲ +" : "▼ "}
{formatChf(Math.abs(v))}
</span>
);
}
// Einzelwert, gleiche Konvention.
function ValueSingle({ value, deflator, mode }: { value: number; deflator: number; mode: ValueMode }) {
return (
@@ -1032,15 +1101,20 @@ function ValueSingle({ value, deflator, mode }: { value: number; deflator: numbe
function phaseCellContent(
ce: ReturnType<PhaseComputed["elements"]["find"]> | undefined,
phase: PhaseComputed,
mode: ValueMode
mode: ValueMode,
// Endwert desselben Elements in derselben Phase laut PLAN -- null in der Plan-Sicht.
planEnd?: number | null
): React.ReactNode {
if (!ce) return "";
if (ce.note) return ce.note;
const isFlow = ce.category === "INCOME" || ce.category === "EXPENSE";
const dS = phase.cumulativeInflationStart;
const dE = isFlow ? phase.flowDeflatorEnd : phase.cumulativeInflationEnd;
const devEnd = planEnd == null ? null : ce.endValue - planEnd;
if (START_END_CATEGORIES.includes(ce.category) && (ce.startValue !== 0 || ce.endValue !== 0)) {
return <ValuePair start={ce.startValue} end={ce.endValue} deflatorStart={dS} deflatorEnd={dE} mode={mode} />;
return (
<ValuePair start={ce.startValue} end={ce.endValue} deflatorStart={dS} deflatorEnd={dE} mode={mode} devEnd={devEnd} />
);
}
if ((ce.category === "AHV" || ce.category === "PENSION_FUND") && ce.startValue !== 0) {
return (