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:
@@ -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 (
|
||||
|
||||
Reference in New Issue
Block a user