UI-Umbau: Analyse-Bereich, Planstart, Zeitachse mit Phasen
Deploy App / deploy (push) Successful in 1m44s

Sieben Anpassungen aus dem Feedback:

- Grafiken liegen neu im eigenen Bereich "Grafiken" (Dialog, Button oben) statt
  unter der Matrix. Die Matrix bleibt die ruhige Hauptansicht.
- Kennzahl "Geschaetzter Nachlass" entfernt -- sie war rechnerisch identisch mit
  dem nominalen Endvermoegen und suggerierte eine Zusatzinformation, die es nicht gab.
- Monte-Carlo-Button nach oben zu den Szenario-Aktionen verschoben.
- Neues Profilfeld "Planstart (Jahr)" (Scenario.startYear + Migration). Rein fuer
  die Darstellung -- die Berechnung rechnet unveraendert in relativen Jahren.
  Bestehende Szenarien werden auf das laufende Jahr gesetzt.
- Zeitachse zeigt die Lebensphasen als Segmente (Breite = Dauer, Einfaerbung nach
  Phasentyp) inkl. Jahresspanne, plus Jahres-Beschriftung an den Enden.
- Lebensphase bearbeiten neu als Popup statt Panel unter der Tabelle -- konsistent
  zu allen anderen Eingaben; Speichern schliesst.
- Vermoegensverlauf ueber ALLE Jahre statt nur ueber die Phasengrenzen. Dafuer
  fuehrt computePlan das Vermoegen neu pro Jahr mit (YearPoint.wealthNominal/
  wealthReal) -- dieselbe Groesse, die schon die Ruin-Erkennung berechnet. Damit
  werden Verlaeufe INNERHALB einer Phase sichtbar (z. B. Kapitalverzehr).

Zwei Tests fuer das Jahres-Vermoegen (58 -> 60). Spezifikation auf v0.9.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-18 13:40:31 +02:00
parent a5d4868c58
commit d203e504c0
16 changed files with 266 additions and 84 deletions
+73 -8
View File
@@ -2,7 +2,9 @@
import { useCallback, useEffect, useState } from "react";
import {
BarChart3,
Copy,
Dices,
FileText,
FolderKanban,
GitBranch,
@@ -15,6 +17,7 @@ import {
} from "lucide-react";
import { PlanView } from "@/components/PlanView";
import { Dashboard } from "@/components/Dashboard";
import { MonteCarloDialog } from "@/components/MonteCarloDialog";
import { SpecView } from "@/components/SpecView";
import { ProfileMenu } from "@/components/ProfileMenu";
import { PlanProfileFields, emptyProfileDraft, type ProfileDraft } from "@/components/PlanProfileFields";
@@ -39,6 +42,8 @@ export function AppShell({ username }: { username: string }) {
const [showNewPlan, setShowNewPlan] = useState(false);
const [copyFrom, setCopyFrom] = useState<ScenarioMeta | null>(null);
const [showSpec, setShowSpec] = useState(false);
const [showCharts, setShowCharts] = useState(false);
const [showMonteCarlo, setShowMonteCarlo] = useState(false);
const loadPlans = useCallback(async () => {
const data = await api.get<{ plans: PlanListItem[] }>("/api/plans");
@@ -267,6 +272,26 @@ export function AppShell({ username }: { username: string }) {
Szenario loeschen
</button>
)}
{detail.plan.phases.length > 0 && (
<>
<button
type="button"
onClick={() => setShowCharts(true)}
className="flex items-center gap-1.5 rounded-lg border border-border px-3 py-1.5 text-sm font-medium text-muted hover:bg-surface-2"
>
<BarChart3 className="h-4 w-4" />
Grafiken
</button>
<button
type="button"
onClick={() => setShowMonteCarlo(true)}
className="flex items-center gap-1.5 rounded-lg border border-border px-3 py-1.5 text-sm font-medium text-muted hover:bg-surface-2"
>
<Dices className="h-4 w-4" />
Monte-Carlo-Simulation
</button>
</>
)}
{diff && detail.base && (
<span className="ml-auto flex items-center gap-1.5 rounded-lg border border-diff bg-diff-soft px-3 py-1.5 text-xs font-medium text-diff">
<GitBranch className="h-3.5 w-3.5" />
@@ -278,14 +303,6 @@ export function AppShell({ username }: { username: string }) {
</div>
<PlanView plan={detail.plan} computed={detail.computed} diff={diff} onChanged={refreshCurrent} />
{detail.plan.phases.length > 0 && (
<Dashboard
plan={detail.plan}
computed={detail.computed}
siblings={(activePlan?.scenarios ?? []).filter((s) => s.id !== detail.meta.id)}
/>
)}
</div>
)}
</main>
@@ -306,6 +323,24 @@ export function AppShell({ username }: { username: string }) {
/>
)}
{showCharts && detail && (
<ChartsDialog onClose={() => setShowCharts(false)} title={`Grafiken · ${detail.meta.name}`}>
<Dashboard
plan={detail.plan}
computed={detail.computed}
siblings={(activePlan?.scenarios ?? []).filter((s) => s.id !== detail.meta.id)}
/>
</ChartsDialog>
)}
{showMonteCarlo && detail && (
<MonteCarloDialog
plan={detail.plan}
computed={detail.computed}
onClose={() => setShowMonteCarlo(false)}
/>
)}
{copyFrom && (
<CopyScenarioDialog
source={copyFrom}
@@ -325,6 +360,36 @@ export function AppShell({ username }: { username: string }) {
);
}
// Breiter Dialog fuer den Analyse-Bereich (Grafiken).
function ChartsDialog({
title,
children,
onClose,
}: {
title: string;
children: React.ReactNode;
onClose: () => void;
}) {
return (
<div className="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="flex w-full max-w-5xl flex-col gap-4 rounded-2xl border border-border bg-surface p-6 shadow-xl"
>
<div className="flex items-center justify-between">
<h2 className="flex items-center gap-2 text-base font-semibold text-fg">
<BarChart3 className="h-5 w-5 text-accent" /> {title}
</h2>
<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>
{children}
</div>
</div>
);
}
// Rekursiver Szenario-Baum: Kinder werden eingerueckt, damit Sub-Szenarien sichtbar sind.
function ScenarioTree({
scenarios,
+1 -2
View File
@@ -93,10 +93,9 @@ export function Dashboard({
return (
<div className="flex flex-col gap-6">
<div className="grid grid-cols-1 gap-4 sm:grid-cols-3">
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<StatCard label="Endvermoegen (nominal)" value={lastPhase ? lastPhase.endWealthNominal : 0} />
<StatCard label="Endvermoegen (real, kaufkraftbereinigt)" value={lastPhase ? lastPhase.endWealthReal : 0} />
<StatCard label="Geschaetzter Nachlass" value={computed.nachlass} help="Endvermoegen der letzten Phase - potenziell vererbbar." />
</div>
<section className="rounded-xl border border-border bg-surface p-4 shadow-sm">
+11
View File
@@ -6,6 +6,7 @@ import type { HouseholdType, PersonRole } from "@/lib/types";
export interface ProfileDraft {
householdType: HouseholdType;
inflationRateDefault: number;
startYear: number;
persons: { role: PersonRole; name: string; age: number; retirementAge: number }[];
}
@@ -13,6 +14,7 @@ export function emptyProfileDraft(): ProfileDraft {
return {
householdType: "SINGLE",
inflationRateDefault: 1.5,
startYear: new Date().getFullYear(),
persons: [{ role: "PERSON_A", name: "", age: 35, retirementAge: 65 }],
};
}
@@ -89,6 +91,15 @@ export function PlanProfileFields({
</div>
))}
<NumberField
label="Planstart (Jahr)"
help="Kalenderjahr, in dem die Planung beginnt (Jahr 1). Wird auf der Zeitachse und in den Grafiken angezeigt; die Berechnung selbst rechnet in Jahren ab Planbeginn."
value={draft.startYear}
min={1900}
max={2200}
onChange={(v) => onChange({ ...draft, startYear: Math.round(v) })}
/>
<NumberField
label="Erwartete Inflationsrate (%)"
help="Langfristige Annahme zur jaehrlichen Teuerung. Gilt plan-weit fuer alle Lebensphasen."
+24 -24
View File
@@ -8,7 +8,6 @@ import {
ChevronDown,
ChevronRight,
CreditCard,
Dices,
Home,
Landmark,
PiggyBank,
@@ -33,7 +32,6 @@ import {
type CellContext,
} from "@/components/ElementDetail";
import { PhaseDetail } from "@/components/PhaseDetail";
import { MonteCarloDialog } from "@/components/MonteCarloDialog";
import { PlanProfileFields, type ProfileDraft } from "@/components/PlanProfileFields";
import { MoneyField } from "@/components/FormField";
import { api } from "@/lib/api-client";
@@ -123,7 +121,6 @@ export function PlanView({
const [editTransition, setEditTransition] = useState<{ elementId: string; fromPhaseId: string } | null>(null);
const [editPhaseCell, setEditPhaseCell] = useState<{ elementId: string; phaseId: string } | null>(null);
const [showCashInit, setShowCashInit] = useState(false);
const [showMonteCarlo, setShowMonteCarlo] = useState(false);
// fromPhaseId des Cash-Uebergangs, der gerade bearbeitet wird.
const [editCashTransition, setEditCashTransition] = useState<string | null>(null);
const [valueMode, setValueMode] = useState<ValueMode>("nominal");
@@ -321,7 +318,12 @@ export function PlanView({
<span className="text-[11px] text-faint">real = kaufkraftbereinigt (Planbeginn)</span>
</div>
<Timeline phases={computed.phases} persons={personAxes} ruinAge={computed.ruinAge} />
<Timeline
phases={computed.phases}
persons={personAxes}
ruinAge={computed.ruinAge}
startYear={plan.startYear}
/>
{/* Plan-Profil */}
<div
@@ -376,13 +378,6 @@ export function PlanView({
>
<Plus className="h-4 w-4" /> Lebensphase
</button>
<button
type="button"
onClick={() => setShowMonteCarlo(true)}
className="ml-auto flex items-center gap-1.5 rounded-lg border border-border px-3 py-1.5 text-sm font-medium text-muted hover:bg-surface-2"
>
<Dices className="h-4 w-4" /> Monte-Carlo-Simulation durchführen
</button>
</div>
)}
@@ -590,13 +585,18 @@ export function PlanView({
</div>
)}
{/* Detail-Panel. Der key erzwingt beim Wechsel von Zelle/Phase einen Neuaufbau,
damit der lokale Formular-Zustand nicht vom vorher geoeffneten Element uebrig bleibt. */}
{selected && (
<div key={`ph-${selected.phaseId}`} className="rounded-xl border border-accent bg-surface p-4 shadow-sm">
{renderDetail()}
</div>
)}
{/* Lebensphase bearbeiten -- als Popup, konsistent zu allen anderen Eingaben.
Der key erzwingt beim Wechsel der Phase einen Neuaufbau, damit der lokale
Formular-Zustand nicht von der vorher geoeffneten Phase uebrig bleibt. */}
{selected && (() => {
const phase = computed.phases.find((p) => p.id === selected.phaseId);
if (!phase) return null;
return (
<DialogShell key={`ph-${selected.phaseId}`} title={`Lebensphase: ${phase.name}`} onClose={() => setSelected(null)}>
{renderDetail()}
</DialogShell>
);
})()}
{showAdd && firstPhase && (
<AddElementDialog
@@ -640,11 +640,7 @@ export function PlanView({
/>
)}
{showMonteCarlo && (
<MonteCarloDialog plan={plan} computed={computed} onClose={() => setShowMonteCarlo(false)} />
)}
{editCashTransition && (() => {
{editCashTransition && (() => {
const fromPhase = computed.phases.find((p) => p.id === editCashTransition);
if (!fromPhase) return null;
const toIndex = computed.phases.findIndex((p) => p.id === fromPhase.id) + 1;
@@ -759,7 +755,10 @@ export function PlanView({
maxDurationYears={phase.maxDurationYears}
isLast={isLast}
laterPhaseCount={laterPhaseCount(phase)}
onSaved={onChanged}
onSaved={() => {
setSelected(null);
onChanged();
}}
onDeleted={() => {
setSelected(null);
onChanged();
@@ -1190,6 +1189,7 @@ function PlanSettingsDialog({ plan, onClose, onSaved }: { plan: PlanInput; onClo
const [draft, setDraft] = useState<ProfileDraft>({
householdType: plan.householdType,
inflationRateDefault: plan.inflationRateDefault,
startYear: plan.startYear ?? new Date().getFullYear(),
persons: plan.persons.map((p) => ({ role: p.role, name: p.name ?? "", age: p.age, retirementAge: p.retirementAge })),
});
const [saving, setSaving] = useState(false);
+46 -23
View File
@@ -11,16 +11,19 @@ interface PersonAxis {
color: string;
}
// Horizontale Zeitachse: Alter von links nach rechts, mit deutlich markiertem
// Pensionsalter je Person und Trennlinien an den Phasengrenzen.
// Horizontale Zeitachse: die Lebensphasen als aneinandergereihte Segmente (Breite = Dauer),
// darunter Alter und -- sofern ein Planstart gesetzt ist -- die Kalenderjahre. Pensionsalter
// je Person und ein allfaelliges Ruinalter sind als Marker eingezeichnet.
export function Timeline({
phases,
persons,
ruinAge,
startYear,
}: {
phases: PhaseComputed[];
persons: PersonAxis[];
ruinAge?: number | null;
startYear?: number | null;
}) {
if (phases.length === 0 || persons.length === 0) return null;
@@ -30,18 +33,18 @@ export function Timeline({
const span = Math.max(1, maxAge - minAge);
const pct = (age: number) => `${(Math.max(0, Math.min(span, age - minAge)) / span) * 100}%`;
const yearOf = (yearsFromStart: number) => (startYear ? startYear + yearsFromStart : null);
// Phasengrenzen (kumulierte Jahre).
const boundaries: { year: number; label: string }[] = [];
let acc = 0;
for (const p of phases) {
boundaries.push({ year: acc, label: p.name });
acc += p.durationYears;
}
// Segmente: Start-/Endjahr (relativ zum Planbeginn) je Phase -- ohne Mutation waehrend
// des Renderns, deshalb die kumulierte Summe der vorangehenden Dauern.
const segments = phases.map((p, i) => {
const from = phases.slice(0, i).reduce((s, x) => s + x.durationYears, 0);
return { phase: p, from, to: from + p.durationYears };
});
return (
<div className="rounded-xl border border-border bg-surface p-4 shadow-sm">
<div className="mb-3 flex items-center justify-between">
<div className="mb-3 flex flex-wrap items-center justify-between gap-2">
<h3 className="text-sm font-semibold text-fg">Zeitachse</h3>
<div className="flex gap-3 text-xs text-muted">
{persons.map((p) => (
@@ -61,7 +64,9 @@ export function Timeline({
key={p.role}
className="absolute top-0 flex -translate-x-1/2 flex-col items-center"
style={{ left: pct(p.retirementAge) }}
title={`${p.label}: Pensionierung mit ${p.retirementAge}`}
title={`${p.label}: Pensionierung mit ${p.retirementAge}${
yearOf(p.retirementAge - minAge) ? ` (${yearOf(p.retirementAge - minAge)})` : ""
}`}
>
<Flag className="h-3.5 w-3.5" style={{ color: p.color }} fill={p.color} />
<span className="whitespace-nowrap text-[10px] font-medium" style={{ color: p.color }}>
@@ -86,21 +91,39 @@ export function Timeline({
</div>
)}
{/* Achse */}
<div className="relative h-2 w-full rounded-full bg-gradient-to-r from-accent-soft to-accent">
{boundaries.slice(1).map((b) => (
<div
key={b.year}
className="absolute top-0 h-2 w-px bg-surface/70"
style={{ left: pct(minAge + b.year) }}
/>
))}
{/* Phasen-Segmente: Breite proportional zur Dauer, Einfaerbung nach Phasentyp. */}
<div className="flex w-full overflow-hidden rounded-lg border border-border">
{segments.map((s, i) => {
const soft = s.phase.type === "PENSION" || s.phase.type === "MIXED";
return (
<div
key={s.phase.id}
style={{ width: `${(s.phase.durationYears / span) * 100}%` }}
title={`${s.phase.name} · ${s.phase.durationYears} Jahre`}
className={`flex min-w-0 flex-col justify-center px-2 py-1.5 ${
i > 0 ? "border-l border-border" : ""
} ${s.phase.type === "PENSION" ? "bg-accent-soft" : s.phase.type === "MIXED" ? "bg-accent/25" : "bg-accent/70"}`}
>
<span className={`truncate text-[10px] font-semibold ${soft ? "text-accent-soft-fg" : "text-accent-fg"}`}>
{s.phase.name}
</span>
<span className={`truncate text-[9px] ${soft ? "text-accent-soft-fg" : "text-accent-fg"} opacity-80`}>
{s.phase.durationYears} J.
{yearOf(s.from) ? ` · ${yearOf(s.from)}${yearOf(s.to)}` : ""}
</span>
</div>
);
})}
</div>
{/* Alters-Beschriftung */}
{/* Alters- und Jahres-Beschriftung */}
<div className="mt-1 flex justify-between text-[11px] text-muted">
<span>{minAge} J.</span>
<span>{maxAge} J.</span>
<span>
{minAge} J.{yearOf(0) ? ` · ${yearOf(0)}` : ""}
</span>
<span>
{maxAge} J.{yearOf(totalYears) ? ` · ${yearOf(totalYears)}` : ""}
</span>
</div>
</div>
</div>
+7 -9
View File
@@ -19,18 +19,16 @@ export interface TimelineSeries {
computed: PlanComputed;
}
// Datenpunkte je Serie: Start (Beginn Phase 1) + Endwert je Phase, verortet auf dem
// Alter der Referenzperson (Person A). So laeuft die x-Achse ueber das Alter statt ueber
// die Phasen.
// Datenpunkte je Serie: JEDES Planjahr (nicht nur die Phasengrenzen), verortet auf dem Alter
// der Referenzperson (Person A). Der erste Punkt ist das Startvermoegen vor Jahr 1.
function pointsFor(computed: PlanComputed) {
const phases = computed.phases;
if (phases.length === 0) return [] as { age: number; nominal: number; real: number }[];
const refAge = (ph: PlanComputed["phases"][number]) => ph.persons[0]?.startAge ?? 0;
const pts = [
{ age: refAge(phases[0]), nominal: Math.round(phases[0].startWealthNominal), real: Math.round(phases[0].startWealthNominal) },
];
for (const p of phases) {
pts.push({ age: refAge(p) + p.durationYears, nominal: Math.round(p.endWealthNominal), real: Math.round(p.endWealthReal) });
const startAge = phases[0].persons[0]?.startAge ?? 0;
const start = Math.round(phases[0].startWealthNominal);
const pts = [{ age: startAge, nominal: start, real: start }];
for (const y of computed.yearly) {
pts.push({ age: y.age, nominal: y.wealthNominal, real: y.wealthReal });
}
return pts;
}