UI-Umbau: Analyse-Bereich, Planstart, Zeitachse mit Phasen
Deploy App / deploy (push) Successful in 1m44s
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:
@@ -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,
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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
@@ -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
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user