Kartenansicht je Lebensphase, Icon-Schiene links, Steuerung rechts
Deploy App / deploy (push) Successful in 1m10s

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-17 21:29:42 +02:00
parent 113616cc1f
commit f41c3614b5
6 changed files with 1294 additions and 46 deletions
+242 -3
View File
@@ -14,6 +14,8 @@ import {
Layers,
LayoutDashboard,
Menu,
Pin,
PinOff,
PiggyBank,
Plus,
Search,
@@ -39,8 +41,7 @@ import { SystemParametersView } from "@/components/SystemParametersView";
import { PlanTraceDialog } from "@/components/DetailView";
import { computePlan } from "@/lib/calculations";
import { ProfileMenu } from "@/components/ProfileMenu";
import { Tour, TOUR_DONE_KEY } from "@/components/Tour";
import { CommandPalette, type PaletteAction } from "@/components/CommandPalette";
import { Tour, TOUR_DONE_KEY } from "@/components/Tour";import { CommandPalette, type PaletteAction } from "@/components/CommandPalette";
import { PlanProfileFields, emptyProfileDraft, type ProfileDraft } from "@/components/PlanProfileFields";
import {
Button,
@@ -52,6 +53,9 @@ import {
useConfirm,
useToast,
} from "@/components/ui";
// Angepinnte Menueleiste: eine Arbeitsgewohnheit des Benutzers, keine Eigenschaft des Plans.
const RAIL_PINNED_KEY = "fpt.rail.pinned";
import { api } from "@/lib/api-client";
import { computeScenarioDiff } from "@/lib/diff";
import type { PlanInput, PlanListItem, ScenarioMeta } from "@/lib/types";
@@ -111,6 +115,33 @@ function AppShellInner({ username }: { username: string }) {
// Erhoehen erzwingt ein Neuladen der Szenario-Liste (bleibt bei einer Loeschung montiert).
const [scenarioListKey, setScenarioListKey] = useState(0);
// Menueleiste links: eingeklappt auf eine Icon-Schiene (56 px) statt 256 px. Angepinnt
// bleibt sie breit und schiebt den Inhalt; nicht angepinnt faehrt sie beim Ueberfahren als
// Overlay aus und legt sich UEBER den Inhalt -- so kostet sie im Ruhezustand nichts.
// Die Wahl gehoert zum Benutzer, nicht zum Plan, und ueberlebt deshalb die Sitzung.
const [railPinned, setRailPinned] = useState(false);
const [railHover, setRailHover] = useState(false);
const [planMenuOpen, setPlanMenuOpen] = useState(false);
useEffect(() => {
try {
// eslint-disable-next-line react-hooks/set-state-in-effect -- einmalige Initialisierung
if (localStorage.getItem(RAIL_PINNED_KEY) === "1") setRailPinned(true);
} catch {
/* localStorage nicht verfuegbar */
}
}, []);
function toggleRailPin() {
setRailPinned((v) => {
try {
localStorage.setItem(RAIL_PINNED_KEY, v ? "0" : "1");
} catch {
/* ignorieren */
}
return !v;
});
}
const railWide = railPinned || railHover;
// Tour (seit dem Layout-Umbau hier statt in PlanView -- sie liest die data-tour-Ziele im
// DOM der Szenario-Ansicht). Zwei Auslöser:
// * pendingTourRef: nach JEDER Plan-Erstellung genau einmal erzwingen (Roadmap-Feedback F14).
@@ -493,7 +524,81 @@ function AppShellInner({ username }: { username: string }) {
return (
<div className="flex min-h-screen w-full">
<aside className="hidden w-64 shrink-0 border-r border-border bg-surface lg:block">{sidebar}</aside>
{/* Linke Leiste. Eingeklappt eine Icon-Schiene; beim Ueberfahren faehrt sie als Overlay
aus (kostet keinen Platz), angepinnt bleibt sie stehen und schiebt den Inhalt.
Icon-only braucht zwingend Tooltip UND aria-label -- sonst ist die Schiene fuer
Screenreader und fuer Neulinge gleichermassen stumm. */}
<div
className="relative hidden shrink-0 lg:block"
style={{ width: railPinned ? "16rem" : "3.5rem" }}
onMouseEnter={() => setRailHover(true)}
onMouseLeave={() => {
setRailHover(false);
setPlanMenuOpen(false);
}}
>
<aside
className={`absolute inset-y-0 left-0 z-30 flex flex-col border-r border-border bg-surface transition-[width] duration-200 ${
railWide ? "w-64 shadow-xl" : "w-14"
} ${railPinned ? "shadow-none" : ""}`}
>
{railWide ? (
<>
<div className="flex items-center gap-1 px-3 pt-3">
<button
type="button"
onClick={toggleRailPin}
aria-label={railPinned ? "Menü loslösen" : "Menü anpinnen"}
title={railPinned ? "Menü loslösen" : "Menü anpinnen"}
className={`ml-auto rounded-lg p-1.5 transition-colors ${
railPinned ? "bg-accent-soft text-accent-soft-fg" : "text-faint hover:bg-surface-2 hover:text-fg"
}`}
>
{railPinned ? <PinOff className="h-4 w-4" /> : <Pin className="h-4 w-4" />}
</button>
</div>
<div className="min-h-0 flex-1">{sidebar}</div>
</>
) : (
<IconRail
plans={plans}
activeScenarioId={selectedScenarioId}
planNav={planNav}
atHome={selectedScenarioId === null && !planNav && !showSpec && !showSystemParams}
showSpec={showSpec}
showSystemParams={showSystemParams}
planMenuOpen={planMenuOpen}
onTogglePlanMenu={() => setPlanMenuOpen((o) => !o)}
onHome={() => {
setSelectedScenarioId(null);
setPlanNav(null);
setShowSpec(false);
setShowSystemParams(false);
}}
onOpenTab={(planId, tab) => {
setPlanMenuOpen(false);
if (tab === "actuals") void openActualsTab(planId);
else openPlanTab(planId, tab);
}}
onSpec={() => {
setShowSpec(true);
setSpecAnchor(null);
setShowSystemParams(false);
setSelectedScenarioId(null);
setPlanNav(null);
}}
onParams={() => {
setShowSystemParams(true);
setShowSpec(false);
setSelectedScenarioId(null);
setPlanNav(null);
}}
onSearch={() => setShowPalette(true)}
onPin={toggleRailPin}
/>
)}
</aside>
</div>
{sidebarOpen && (
<div className="fixed inset-0 z-40 lg:hidden">
@@ -1091,3 +1196,137 @@ function CopyScenarioDialog({
</Modal>
);
}
// --- Die eingeklappte Icon-Schiene -----------------------------------------------------------
//
// Sie zeigt genau das, was man im Alltag braucht: nach Hause, den aktiven Plan, seine vier
// Bereiche. Der Szenario-BAUM steht bewusst nicht hier -- er braucht Text, und Text braucht
// Breite. Wer ihn will, faehrt die Leiste aus oder pinnt sie an.
function IconRail({
plans,
activeScenarioId,
planNav,
atHome,
showSpec,
showSystemParams,
planMenuOpen,
onTogglePlanMenu,
onHome,
onOpenTab,
onSpec,
onParams,
onSearch,
onPin,
}: {
plans: PlanListItem[];
activeScenarioId: string | null;
planNav: { planId: string; tab: string } | null;
atHome: boolean;
showSpec: boolean;
showSystemParams: boolean;
planMenuOpen: boolean;
onTogglePlanMenu: () => void;
onHome: () => void;
onOpenTab: (planId: string, tab: "dashboard" | "scenarios" | "actuals" | "analyses" | "reports") => void;
onSpec: () => void;
onParams: () => void;
onSearch: () => void;
onPin: () => void;
}) {
// Welcher Plan ist gerade im Zugriff? Erst der offene, sonst der zuletzt gewaehlte Bereich,
// sonst der einzige. Ohne diesen Bezug waeren die vier Icons darunter gegenstandslos.
const active =
plans.find((p) => p.scenarios.some((sc) => sc.id === activeScenarioId)) ??
plans.find((p) => p.id === planNav?.planId) ??
(plans.length === 1 ? plans[0] : undefined);
const initials = active
? active.name
.split(/\s+/)
.slice(0, 2)
.map((w) => w[0])
.join("")
.toUpperCase()
: "";
const item = (
label: string,
Icon: typeof LayoutDashboard,
on: boolean,
onClick: () => void,
disabled?: boolean
) => (
<button
key={label}
type="button"
disabled={disabled}
onClick={onClick}
aria-label={label}
title={label}
className={`flex h-9 w-9 items-center justify-center rounded-lg transition-colors disabled:opacity-30 ${
on ? "bg-accent-soft text-accent-soft-fg" : "text-muted hover:bg-surface-2 hover:text-fg"
}`}
>
<Icon className="h-[18px] w-[18px]" />
</button>
);
return (
<div className="flex h-full flex-col items-center gap-1 py-3">
<div className="flex h-8 w-8 items-center justify-center rounded-xl bg-accent" title="FPT">
<PiggyBank className="h-5 w-5 text-accent-fg" />
</div>
<div className="my-1 h-px w-6 bg-border" />
{item("Übersicht", LayoutDashboard, atHome, onHome)}
{/* Der Plan-Wähler. Eingeklappt zeigt er die Initialen -- sonst wüsste man nach dem
Umschalten nicht mehr, worin man gerade ist. */}
<div className="relative">
<button
type="button"
onClick={onTogglePlanMenu}
aria-label={active ? `Plan: ${active.name}` : "Plan wählen"}
title={active ? `Plan: ${active.name}` : "Plan wählen"}
className={`flex h-9 w-9 items-center justify-center rounded-lg border text-[11px] font-semibold transition-colors ${
active ? "border-border text-fg hover:bg-surface-2" : "border-dashed border-border text-faint"
}`}
>
{initials}
</button>
{planMenuOpen && (
<div className="ui-fade absolute left-11 top-0 z-50 w-56 rounded-xl border border-border bg-surface p-1 shadow-xl">
{plans.length === 0 && <p className="px-2 py-1.5 text-xs text-faint">Noch keine Pläne.</p>}
{plans.map((p) => (
<button
key={p.id}
type="button"
onClick={() => onOpenTab(p.id, "dashboard")}
className={`flex w-full items-center gap-2 rounded-lg px-2 py-1.5 text-left text-xs font-medium transition-colors ${
p.id === active?.id ? "bg-accent-soft text-accent-soft-fg" : "text-muted hover:bg-surface-2"
}`}
>
<FolderKanban className="h-3.5 w-3.5 shrink-0" />
<span className="min-w-0 truncate">{p.name}</span>
</button>
))}
</div>
)}
</div>
{item("Szenarien", Layers, planNav?.tab === "scenarios" || !!activeScenarioId, () => active && onOpenTab(active.id, "scenarios"), !active)}
{item("Effektive Werte", CalendarClock, planNav?.tab === "actuals", () => active && onOpenTab(active.id, "actuals"), !active)}
{item("Analysen", BarChart3, planNav?.tab === "analyses", () => active && onOpenTab(active.id, "analyses"), !active)}
{item("Berichte", FileSpreadsheet, planNav?.tab === "reports", () => active && onOpenTab(active.id, "reports"), !active)}
<div className="my-1 h-px w-6 bg-border" />
{item("So rechnet FPT", FileText, showSpec, onSpec)}
{item("Systemparameter", SlidersHorizontal, showSystemParams, onParams)}
<div className="mt-auto flex flex-col items-center gap-1">
{item("Suchen und springen (Ctrl+K)", Search, false, onSearch)}
{item("Menü anpinnen", Pin, false, onPin)}
</div>
</div>
);
}
+6
View File
@@ -104,6 +104,9 @@ interface Props {
phaseDataByPhase?: Record<string, PhaseData>;
onSaved: () => void;
onDeleteElement: () => void;
// In der Kartenansicht steht Name und Kategorie schon im Zeilenkopf -- dann waere die
// eigene Ueberschrift eine Wiederholung, und der Loeschen-Knopf sitzt dort im Menue.
compact?: boolean;
}
// Warnhinweis beim Bearbeiten einer Phase, der noch Phasen folgen. Seit V3 werden Werte live
@@ -1182,6 +1185,7 @@ export function ElementDetail({
phaseDataByPhase = {},
onSaved,
onDeleteElement,
compact,
}: Props) {
const [pd, setPd] = useState<PhaseData>({ ...phaseData });
const [td, setTd] = useState<TransitionData>({ ...transitionData });
@@ -1252,6 +1256,7 @@ export function ElementDetail({
return (
<div className="flex flex-col gap-4">
{!compact && (
<div className="flex items-start justify-between gap-2">
<div>
<div className="text-xs font-semibold uppercase tracking-wide text-accent-soft-fg">
@@ -1268,6 +1273,7 @@ export function ElementDetail({
<Trash2 className="h-3.5 w-3.5" /> Element löschen
</button>
</div>
)}
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
{/* Punkt 4: Warnung, dass Änderungen sich in die Folgephasen fortschreiben. */}
+642
View File
@@ -0,0 +1,642 @@
"use client";
// Die Kartenansicht: eine Karte je Lebensphase (SPEZIFIKATION 3.15).
//
// Sie loest das Grundproblem der Matrix, nicht dessen Symptome. Die Matrix stellt Phasen
// NEBENEINANDER -- gut zum Vergleichen, schlecht zum Arbeiten: Ab der vierten Phase scrollt
// man horizontal, und das rechte Bearbeitungs-Panel legte sich ausgerechnet ueber die Spalte,
// zu der man gerade gescrollt hatte. Eine Karte zeigt genau eine Phase, ueber die ganze
// Breite, und getippt wird in der Karte selbst. Horizontales Scrollen gibt es nicht mehr.
//
// Die Karte erzaehlt die Phase als ZEITABLAUF in drei Abschnitten:
// 1. Eintritt -- was beim Wechsel in diese Phase geschah (Uebergang aus der Vorphase).
// Bei der ersten Phase stattdessen der Bestand aus der Bestandsaufnahme.
// 2. Laufend -- was in diesen Jahren gilt: die Elemente mit ihren Annahmen.
// 3. Ergebnis -- was am Ende dasteht.
//
// Der Uebergang gehoert bewusst zur FOLGEphase und nicht ans Ende der Vorphase: Das dabei
// frei werdende Kapital wird in der Folgephase verteilt (seit 0.41 auch in der Anzeige, Kap.
// 3.12.5). Der Preis ist, dass man den Hausverkauf gedanklich am Ende von Phase N trifft, ihn
// aber auf Karte N+1 findet -- bewusst in Kauf genommen, weil dort die Folgen stehen.
//
// Die Matrix bleibt als VERGLEICHSANSICHT bestehen (Diff, Plan/Ist, Verlauf ueber alle
// Phasen) -- dasselbe Datenmaterial, zwei Sichten.
import { useEffect, useState } from "react";
import {
AlertTriangle,
ChevronDown,
ChevronLeft,
ChevronRight,
Coins,
Maximize2,
Pencil,
PiggyBank,
Plus,
Trash2,
Wallet,
} from "lucide-react";
import { Button } from "@/components/ui";
import {
ElementDetail,
cashTransitionSummary,
isCashTransitionAnswered,
type CellContext,
} from "@/components/ElementDetail";
import { CATEGORY_LABELS, ownerLabel, type CashTransitionData, type PhaseData } from "@/lib/elements";
import { capitalPot } from "@/lib/distribution";
import { formatChf } from "@/lib/format";
import { unconfirmedCells } from "@/lib/review";
import type { PhaseComputed, PlanComputed, ElementPhaseComputed } from "@/lib/calculations";
import type { ElementInput, PlanInput } from "@/lib/types";
import type { PhaseRef } from "@/lib/ratefields";
export type ValueMode = "nominal" | "both" | "real";
// Alles, was die Karte an ihre Umgebung zurueckmeldet. Bewusst ein flaches Buendel statt
// vieler Einzel-Props: Die Karte ist eine Ansicht, keine Datenhaltung.
export interface CardActions {
onEditPhase: (phaseId: string) => void;
onDeletePhase: (phaseId: string, name: string) => void;
onAddPhase: () => void;
onAddElement: () => void;
onDeleteElement: (elementId: string, name: string) => void;
onDistributeRates: (phaseId: string) => void;
onDistributeCapital: (phaseId: string) => void;
onCashTransition: (fromPhaseId: string) => void;
onOpenPhaseDetail: (phaseId: string) => void;
onChanged: () => void;
}
interface Props {
plan: PlanInput;
computed: PlanComputed;
mode: ValueMode;
activePhaseId: string | null;
onSelectPhase: (phaseId: string) => void;
phaseContext: (phase: PhaseComputed, el: ElementInput) => CellContext;
transitionContext: (fromPhase: PhaseComputed, toPhase: PhaseComputed | undefined, el: ElementInput) => CellContext;
// Elemente, die am Uebergang NACH einer Phase ueberhaupt etwas zu entscheiden haben.
transitionElements: (fromPhase: PhaseComputed, toPhase: PhaseComputed | undefined) => ElementInput[];
cashTransitionFor: (phaseId: string) => CashTransitionData;
actions: CardActions;
}
const real = (v: number, d: number) => Math.round(v / (d || 1));
function val(v: number, deflator: number, mode: ValueMode): string {
if (mode === "real") return formatChf(real(v, deflator));
if (mode === "both") return `${formatChf(v)} (${formatChf(real(v, deflator))})`;
return formatChf(v);
}
export function PhaseCards({
plan,
computed,
mode,
activePhaseId,
onSelectPhase,
phaseContext,
transitionContext,
transitionElements,
cashTransitionFor,
actions,
}: Props) {
const phases = computed.phases;
const index = Math.max(0, phases.findIndex((p) => p.id === activePhaseId));
const phase = phases[index];
// Pfeiltasten. Der dritte Weg neben Zeitachse und den Knoepfen unten -- wer eine Phase nach
// der anderen durchgeht, will die Hand nicht an der Maus haben. Eingabefelder duerfen davon
// nicht betroffen sein, sonst springt die Karte beim Bearbeiten eines Betrags weg.
useEffect(() => {
function onKey(e: KeyboardEvent) {
if (e.key !== "ArrowLeft" && e.key !== "ArrowRight") return;
const t = e.target as HTMLElement | null;
if (t && (t.tagName === "INPUT" || t.tagName === "SELECT" || t.tagName === "TEXTAREA" || t.isContentEditable)) return;
if (e.metaKey || e.ctrlKey || e.altKey) return;
const next = e.key === "ArrowLeft" ? index - 1 : index + 1;
if (next < 0 || next >= phases.length) return;
e.preventDefault();
onSelectPhase(phases[next].id);
}
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [index, phases, onSelectPhase]);
if (!phase) return null;
return (
<div className="flex flex-col gap-3">
<PhaseCard
key={phase.id}
plan={plan}
computed={computed}
phase={phase}
previous={phases[index - 1]}
mode={mode}
phaseContext={phaseContext}
transitionContext={transitionContext}
transitionElements={transitionElements}
cashTransitionFor={cashTransitionFor}
actions={actions}
/>
<div className="flex items-center justify-center gap-2 text-xs text-muted">
<button
type="button"
disabled={index === 0}
onClick={() => onSelectPhase(phases[index - 1].id)}
className="flex items-center gap-1 rounded-lg border border-border px-2.5 py-1.5 transition-colors hover:bg-surface-2 disabled:opacity-40"
>
<ChevronLeft className="h-4 w-4" />
{phases[index - 1]?.name ?? "Anfang"}
</button>
<span className="px-2 tabular-nums">
Phase {index + 1} von {phases.length}
</span>
{index === phases.length - 1 ? (
<button
type="button"
onClick={actions.onAddPhase}
className="flex items-center gap-1 rounded-lg border border-dashed border-accent px-2.5 py-1.5 font-medium text-accent transition-colors hover:bg-accent hover:text-accent-fg"
>
<Plus className="h-4 w-4" /> Lebensphase
</button>
) : (
<button
type="button"
onClick={() => onSelectPhase(phases[index + 1].id)}
className="flex items-center gap-1 rounded-lg border border-border px-2.5 py-1.5 transition-colors hover:bg-surface-2"
>
{phases[index + 1].name}
<ChevronRight className="h-4 w-4" />
</button>
)}
</div>
</div>
);
}
// --- Die Karte -----------------------------------------------------------------------------
function PhaseCard({
plan,
computed,
phase,
previous,
mode,
phaseContext,
transitionContext,
transitionElements,
cashTransitionFor,
actions,
}: {
plan: PlanInput;
computed: PlanComputed;
phase: PhaseComputed;
previous: PhaseComputed | undefined;
mode: ValueMode;
phaseContext: (phase: PhaseComputed, el: ElementInput) => CellContext;
transitionContext: (fromPhase: PhaseComputed, toPhase: PhaseComputed | undefined, el: ElementInput) => CellContext;
transitionElements: (fromPhase: PhaseComputed, toPhase: PhaseComputed | undefined) => ElementInput[];
cashTransitionFor: (phaseId: string) => CashTransitionData;
actions: CardActions;
}) {
// Welche Zeilen sind aufgeklappt? Beim Wechsel der Karte faengt es wieder bei zu an --
// die Karte wird ueber `key` neu montiert.
const [open, setOpen] = useState<Record<string, boolean>>({});
const rows = phase.elements.filter((e) => e.status !== "SOLD" || e.startValue !== 0);
const openCount = unconfirmedCells(plan, computed, phase.id).length;
const ratesOpen = plan.phases.find((p) => p.id === phase.id)?.ratesConfirmed !== true;
const pot = capitalPot(phase);
const capitalOpen = pot.total > 0 && pot.allocatedInvestments + pot.allocatedRepayments === 0;
const transEls = previous ? transitionElements(previous, phase) : [];
const ct = previous ? cashTransitionFor(previous.id) : null;
const cashOpen = ct ? !isCashTransitionAnswered(ct) : false;
// Ein Knopf fuer alle Zeilen der Karte. Bei acht Elementen ist das der Unterschied zwischen
// «einmal durchsehen» und acht Klicks.
const allKeys = [...transEls.map((e) => `t:${e.id}`), ...rows.map((e) => `p:${e.elementId}`)];
const anyOpen = allKeys.some((k) => open[k]);
const toggleAll = () =>
setOpen(anyOpen ? {} : Object.fromEntries(allKeys.map((k) => [k, true])));
const dS = phase.cumulativeInflationStart;
const dE = phase.cumulativeInflationEnd;
const years =
plan.startYear != null
? (() => {
const before = computed.phases
.slice(0, computed.phases.findIndex((p) => p.id === phase.id))
.reduce((s, p) => s + p.durationYears, 0);
return `${plan.startYear + before}${plan.startYear + before + phase.durationYears - 1}`;
})()
: null;
return (
<article className="ui-fade overflow-hidden rounded-2xl border border-border bg-surface shadow-sm">
{/* Kopf: ruhig und gross. Er beantwortet «wo bin ich» in einem Blick. */}
<header className="flex flex-wrap items-start gap-3 px-5 py-4">
<div className="min-w-0">
<h2 className="truncate text-lg font-semibold text-fg">{phase.name}</h2>
<p className="mt-0.5 text-xs text-muted">
{years ? `${years} · ` : ""}
{phase.durationYears} {phase.durationYears === 1 ? "Jahr" : "Jahre"}
{phase.persons.length > 0 ? " · " : ""}
{phase.persons
.map((p) => `${ownerLabel(plan.persons, p.role)} ${p.startAge}${p.endAge}`)
.join(", ")}
</p>
</div>
<div className="ml-auto flex flex-wrap items-center gap-1.5">
{openCount + (ratesOpen ? 1 : 0) + (cashOpen ? 1 : 0) > 0 && (
<span className="flex items-center gap-1 rounded-full bg-attention-soft px-2 py-0.5 text-[11px] font-semibold text-attention-soft-fg">
<AlertTriangle className="h-3 w-3" />
{openCount + (ratesOpen ? 1 : 0) + (cashOpen ? 1 : 0)} offen
</span>
)}
{allKeys.length > 0 && (
<button
type="button"
onClick={toggleAll}
className="rounded-lg border border-border px-2 py-1 text-[11px] font-medium text-muted transition-colors hover:bg-surface-2 hover:text-fg"
>
{anyOpen ? "Alle zuklappen" : "Alle aufklappen"}
</button>
)}
<IconBtn label="Bezeichnung und Dauer ändern" onClick={() => actions.onEditPhase(phase.id)}>
<Pencil className="h-4 w-4" />
</IconBtn>
<IconBtn label="Detailansicht" onClick={() => actions.onOpenPhaseDetail(phase.id)}>
<Maximize2 className="h-4 w-4" />
</IconBtn>
{phase.sequenceNumber === computed.phases.length && (
<IconBtn label="Lebensphase löschen" danger onClick={() => actions.onDeletePhase(phase.id, phase.name)}>
<Trash2 className="h-4 w-4" />
</IconBtn>
)}
</div>
</header>
{/* 1 · EINTRITT */}
<Section
title={previous ? "Eintritt" : "Start"}
hint={
previous
? `Was beim Wechsel aus «${previous.name}» geschah`
: "Der Bestand bei Planbeginn erfasst in der Bestandsaufnahme"
}
>
{!previous ? (
<p className="px-1 py-2 text-xs text-muted">
Diese Phase beginnt mit dem heutigen Stand. Startwerte änderst du in der
Bestandsaufnahme oder über die Zeile selbst.
</p>
) : (
<>
{ct && (
<RowShell
icon={<Wallet className="h-4 w-4" />}
name="Cash"
meta="einmalige Sonderein-/ausgaben"
open={cashOpen}
right={<span className="tabular-nums">{cashTransitionSummary(ct)}</span>}
onClick={() => actions.onCashTransition(previous.id)}
/>
)}
{transEls.length === 0 && !cashOpen && (
<p className="px-1 py-2 text-xs text-muted">An diesem Übergang gibt es nichts zu entscheiden.</p>
)}
{transEls.map((el) => {
const key = `t:${el.id}`;
const ctx = transitionContext(previous, phase, el);
return (
<Row
key={key}
open={!!open[key]}
onToggle={() => setOpen((p) => ({ ...p, [key]: !p[key] }))}
icon={<Coins className="h-4 w-4" />}
name={el.name}
meta={`${CATEGORY_LABELS[el.category]} · ${ownerLabel(plan.persons, el.ownerRole)}`}
right={null}
>
<ElementDetail
compact
element={el}
context={ctx}
phaseData={el.phaseValues[phase.id] ?? {}}
transitionData={el.transitionValues[previous.id] ?? {}}
onSaved={actions.onChanged}
onDeleteElement={() => actions.onDeleteElement(el.id, el.name)}
/>
</Row>
);
})}
<div className="pt-1">
<QuotaButton
label="Kapital verteilen"
open={capitalOpen}
hidden={!pot.total && !pot.allocatedInvestments && !pot.allocatedRepayments}
sub={`${formatChf(pot.total)} verfügbar · ${formatChf(pot.rest)} bleibt Cash`}
onClick={() => actions.onDistributeCapital(phase.id)}
/>
</div>
</>
)}
</Section>
{/* 2 · LAUFEND */}
<Section
title="Laufend"
hint={`Was in diesen ${phase.durationYears} Jahren gilt`}
action={
<button
type="button"
onClick={actions.onAddElement}
className="flex items-center gap-1 rounded-lg border border-border px-2 py-1 text-[11px] font-medium text-muted transition-colors hover:bg-surface-2 hover:text-fg"
>
<Plus className="h-3.5 w-3.5" /> Element
</button>
}
>
{rows.length === 0 && (
<p className="px-1 py-2 text-xs text-muted">Noch keine Elemente erfasst.</p>
)}
{rows.map((ce) => {
const el = plan.elements.find((x) => x.id === ce.elementId);
if (!el) return null;
const key = `p:${el.id}`;
const isOpen = unconfirmedCells(plan, computed, phase.id).includes(el.id);
return (
<Row
key={key}
open={!!open[key]}
onToggle={() => setOpen((p) => ({ ...p, [key]: !p[key] }))}
icon={<PiggyBank className="h-4 w-4" />}
name={el.name}
meta={`${CATEGORY_LABELS[el.category]} · ${ownerLabel(plan.persons, el.ownerRole)}`}
attention={isOpen}
right={<CellValue ce={ce} dS={dS} dE={dE} mode={mode} />}
>
<ElementDetail
compact
element={el}
context={phaseContext(phase, el)}
phaseData={el.phaseValues[phase.id] ?? {}}
transitionData={{}}
allPhases={plan.phases.map<PhaseRef>((p) => ({ id: p.id, name: p.name, sequenceNumber: p.sequenceNumber }))}
phaseDataByPhase={Object.fromEntries(
plan.phases.map((p) => [p.id, (el.phaseValues[p.id] ?? {}) as PhaseData])
)}
onSaved={actions.onChanged}
onDeleteElement={() => actions.onDeleteElement(el.id, el.name)}
/>
</Row>
);
})}
<div className="pt-1">
<QuotaButton
label={phase.isConsumption ? "Bezug verteilen" : "Sparquote verteilen"}
open={ratesOpen}
sub={`${phase.isConsumption ? "Verzehrquote" : "Sparquote"} ${val(phase.quotaStart, dS, mode)}${val(
phase.quotaEnd,
phase.flowDeflatorEnd,
mode
)}`}
onClick={() => actions.onDistributeRates(phase.id)}
/>
</div>
</Section>
{/* 3 · ERGEBNIS */}
<Section title="Ergebnis" hint="Stand am Ende dieser Phase" tinted>
<div className="grid gap-x-6 gap-y-1.5 sm:grid-cols-2">
<Figure label="Vermögen" from={val(phase.startWealthNominal, dS, mode)} to={val(phase.endWealthNominal, dE, mode)} strong />
<Figure
label="Cash"
from={val(phase.cashStart, dS, mode)}
to={val(phase.cashEnd, dE, mode)}
danger={phase.cashNegative}
/>
</div>
{phase.cashNegative && (
<p className="mt-2 flex items-start gap-2 text-xs text-danger">
<AlertTriangle className="mt-0.5 h-3.5 w-3.5 shrink-0" />
Das Cash-Konto fällt in dieser Phase unter 0 eine Liquiditätslücke.
</p>
)}
</Section>
</article>
);
}
// --- Bausteine -----------------------------------------------------------------------------
function IconBtn({
label,
onClick,
danger,
children,
}: {
label: string;
onClick: () => void;
danger?: boolean;
children: React.ReactNode;
}) {
return (
<button
type="button"
aria-label={label}
title={label}
onClick={onClick}
className={`rounded-lg p-1.5 text-faint transition-colors ${
danger ? "hover:bg-danger-soft hover:text-danger" : "hover:bg-surface-2 hover:text-fg"
}`}
>
{children}
</button>
);
}
function Section({
title,
hint,
action,
tinted,
children,
}: {
title: string;
hint: string;
action?: React.ReactNode;
tinted?: boolean;
children: React.ReactNode;
}) {
return (
<section className={`border-t border-border px-5 py-3 ${tinted ? "bg-surface-2/50" : ""}`}>
<div className="mb-1.5 flex items-center gap-2">
<span className="text-xs font-semibold uppercase tracking-wide text-fg">{title}</span>
<span className="min-w-0 truncate text-[11px] text-faint">{hint}</span>
{action && <span className="ml-auto">{action}</span>}
</div>
{children}
</section>
);
}
// Eine aufklappbare Zeile. Zugeklappt ist sie eine Zusammenfassung, aufgeklappt der ganze
// Editor -- und zwar IN der Karte. Kein Panel legt sich mehr ueber das, was man bearbeitet.
function Row({
open,
onToggle,
icon,
name,
meta,
right,
attention,
children,
}: {
open: boolean;
onToggle: () => void;
icon: React.ReactNode;
name: string;
meta: string;
right: React.ReactNode;
attention?: boolean;
children: React.ReactNode;
}) {
return (
<div className={`border-b border-border last:border-b-0 ${open ? "bg-surface-2/40" : ""}`}>
<button
type="button"
onClick={onToggle}
aria-expanded={open}
className="flex w-full items-center gap-2.5 py-2 text-left transition-colors hover:bg-surface-2/60"
>
<span className={`h-1.5 w-1.5 shrink-0 rounded-full ${attention ? "bg-attention" : "bg-success"}`} />
<span className={attention ? "text-attention" : "text-faint"}>{icon}</span>
<span className="min-w-0">
<span className="block truncate text-sm text-fg">{name}</span>
<span className="block truncate text-[11px] text-faint">{meta}</span>
</span>
<span className="ml-auto shrink-0 text-xs text-muted">{right}</span>
<ChevronDown className={`h-4 w-4 shrink-0 text-faint transition-transform ${open ? "rotate-180" : ""}`} />
</button>
{open && <div className="pb-4 pl-6 pr-1">{children}</div>}
</div>
);
}
// Zeile ohne Editor -- sie fuehrt in einen eigenen Dialog (Cash-Uebergang).
function RowShell({
icon,
name,
meta,
right,
open,
onClick,
}: {
icon: React.ReactNode;
name: string;
meta: string;
right: React.ReactNode;
open?: boolean;
onClick: () => void;
}) {
return (
<button
type="button"
onClick={onClick}
className="flex w-full items-center gap-2.5 border-b border-border py-2 text-left transition-colors last:border-b-0 hover:bg-surface-2/60"
>
<span className={`h-1.5 w-1.5 shrink-0 rounded-full ${open ? "bg-attention" : "bg-success"}`} />
<span className={open ? "text-attention" : "text-faint"}>{icon}</span>
<span className="min-w-0">
<span className="block truncate text-sm text-fg">{name}</span>
<span className="block truncate text-[11px] text-faint">{meta}</span>
</span>
<span className="ml-auto shrink-0 text-xs text-muted">{right}</span>
<ChevronRight className="h-4 w-4 shrink-0 text-faint" />
</button>
);
}
function CellValue({
ce,
dS,
dE,
mode,
}: {
ce: ElementPhaseComputed;
dS: number;
dE: number;
mode: ValueMode;
}) {
if (ce.summary) return <span className="tabular-nums">{ce.summary}</span>;
if (ce.note) return <span>{ce.note}</span>;
return (
<span className="whitespace-nowrap tabular-nums">
{val(ce.startValue, dS, mode)} <span className="text-faint"></span> {val(ce.endValue, dE, mode)}
</span>
);
}
function Figure({
label,
from,
to,
strong,
danger,
}: {
label: string;
from: string;
to: string;
strong?: boolean;
danger?: boolean;
}) {
return (
<div className="flex items-baseline justify-between gap-3 border-b border-border/60 py-1 last:border-b-0">
<span className="text-xs text-muted">{label}</span>
<span
className={`whitespace-nowrap tabular-nums ${strong ? "text-sm font-semibold" : "text-xs"} ${
danger ? "text-danger" : "text-fg"
}`}
>
{from} <span className="text-faint"></span> {to}
</span>
</div>
);
}
// Der Weg in einen Verteil-Dialog. Gruen mit Haken, sobald erledigt -- dieselbe Sprache wie
// im Phasenkopf der Matrix (SPEZIFIKATION 3.6.9).
function QuotaButton({
label,
sub,
open,
hidden,
onClick,
}: {
label: string;
sub: string;
open: boolean;
hidden?: boolean;
onClick: () => void;
}) {
if (hidden) return null;
return (
<Button variant={open ? "primary" : "ghost"} onClick={onClick} className="w-full justify-between">
<span className="flex flex-col items-start">
<span>
{label}
{open ? "" : " ✓"}
</span>
<span className="text-[11px] font-normal opacity-80">{sub}</span>
</span>
<ChevronRight className="h-4 w-4" />
</Button>
);
}
+263 -37
View File
@@ -24,6 +24,8 @@ import {
Plus,
ShoppingCart,
Tornado,
PanelRight,
PanelRightClose,
Trash2,
TrendingUp,
Wallet,
@@ -47,6 +49,7 @@ import {
} from "@/lib/retirement-decision";
import { RetirementFields } from "@/components/RetirementFields";
import { ReviewTile } from "@/components/ReviewTile";
import { PhaseCards, type CardActions } from "@/components/PhaseCards";
import { BaseFields, InventoryDialog } from "@/components/InventoryDialog";
import { reviewPlan, unconfirmedCells } from "@/lib/review";
import { Button, EmptyState, InspectorShell, Modal, useConfirm, useToast } from "@/components/ui";
@@ -201,6 +204,30 @@ export function PlanView({
const [distribute, setDistribute] = useState<{ kind: "capital" | "rates"; phaseId: string } | null>(null);
const [valueMode, setValueMode] = useState<ValueMode>("nominal");
const [showInventory, setShowInventory] = useState(false);
// Karten oder Matrix. Die Wahl gilt fuer den Benutzer, nicht fuer das Szenario -- sie ist
// eine Arbeitsgewohnheit, keine Eigenschaft des Plans.
const [view, setView] = useState<"cards" | "matrix">("cards");
const [railOpen, setRailOpen] = useState(true);
const [activePhaseId, setActivePhaseId] = useState<string | null>(null);
useEffect(() => {
try {
const v = localStorage.getItem(VIEW_KEY);
// eslint-disable-next-line react-hooks/set-state-in-effect -- einmalige Initialisierung
if (v === "cards" || v === "matrix") setView(v);
if (localStorage.getItem(RAIL_KEY) === "0") setRailOpen(false);
} catch {
/* localStorage nicht verfuegbar */
}
}, []);
useEffect(() => {
try {
localStorage.setItem(VIEW_KEY, view);
localStorage.setItem(RAIL_KEY, railOpen ? "1" : "0");
} catch {
/* ignorieren */
}
}, [view, railOpen]);
// Eine Zelle, deren Annahmen noch nie jemand angeschaut hat. Sie traegt dieselbe
// Attention-Farbe wie ein offener Uebergang -- "hier fehlt was" sieht ueberall gleich aus.
const cellOpen = (elementId: string, phaseId: string) =>
@@ -415,51 +442,95 @@ export function PlanView({
}
const hasPhases = computed.phases.length > 0;
// Ein Vergleich (Szenario-Diff oder Plan/Ist) ist eine Frage ueber ALLE Phasen -- das kann
// eine einzelne Karte nicht zeigen. In diesen Faellen erzwingt FPT die Matrix, statt still
// die falsche Darstellung anzubieten.
const forceMatrix = !!diff || !!actualComputed;
const effectiveView: "cards" | "matrix" = forceMatrix || !hasPhases ? "matrix" : view;
const currentPhaseId =
computed.phases.find((p) => p.id === activePhaseId)?.id ?? computed.phases[0]?.id ?? null;
const cardActions: CardActions = {
onEditPhase: (phaseId) => setPanel({ kind: "phase", phaseId }),
onDeletePhase: (phaseId, name) => void deletePhase(phaseId, name),
onAddPhase: () => setShowAddPhase(true),
onAddElement: () => setShowAdd(true),
onDeleteElement: (elementId, name) => void deleteElement(elementId, name),
onDistributeRates: (phaseId) => setDistribute({ kind: "rates", phaseId }),
onDistributeCapital: (phaseId) => setDistribute({ kind: "capital", phaseId }),
onCashTransition: (fromPhaseId) => setPanel({ kind: "cashTransition", fromPhaseId }),
onOpenPhaseDetail: (phaseId) => setDetailFor({ kind: "phase", id: phaseId }),
onChanged,
};
return (
<div className="flex flex-col gap-5">
{/* OBERER BEREICH -- die Steuerung. Farblich abgesetzt vom unteren, damit sichtbar
ist: hier stellst du ein, unten siehst du das Ergebnis. Vier Kacheln nebeneinander,
darunter die Zeitachse über die volle Breite. */}
<div className="rounded-2xl border border-border bg-surface-2/60 p-4 shadow-sm">
<div className="grid grid-cols-1 gap-3 lg:grid-cols-4">
<SettingsTile
plan={plan}
computed={computed}
diffChanged={!!diff?.profileChanged}
onEdit={() => setPanel({ kind: "profile" })}
/>
<FiguresTile plan={plan} computed={computed} />
<QuickActionsTile
onInventory={() => setShowInventory(true)}
onHistory={onOpenHistory}
onTour={onStartTour}
onNewScenario={onCopyScenario}
onTraces={onOpenTraces}
exportHref={exportHref}
/>
<ReviewTile
review={review}
onOpenInventory={() => setShowInventory(true)}
onAddPhase={() => setShowAddPhase(true)}
onJump={(g) =>
g.kind === "phase"
? setPanel({ kind: "phase", phaseId: g.id })
: setReviewFromPhaseId(g.id)
}
/>
<div className="flex min-w-0 gap-4">
{/* MITTE -- die Arbeitsfläche. Die Steuerung liegt seit 0.42 rechts statt oben: Sie
kostete dort VERTIKALEN Platz, und Höhe ist auf einem Breitbildschirm die knappe
Grösse. Rechts kostet sie horizontalen, den man eher hat -- und eingeklappt gar
keinen (SPEZIFIKATION 3.15). */}
<div className="flex min-w-0 flex-1 flex-col gap-3">
<div className="flex flex-wrap items-center gap-2">
<h2 className="min-w-0 truncate text-lg font-semibold text-fg">{plan.name}</h2>
<div className="ml-auto flex flex-wrap items-center gap-2">
{/* Zwei Sichten auf dasselbe Material (Airtable-Prinzip): Karten zum ARBEITEN,
Matrix zum VERGLEICHEN. Ein Diff oder eine Ist-Ansicht erzwingt die Matrix --
über Phasen hinweg zu vergleichen kann eine Karte prinzipiell nicht. */}
<div className="inline-flex rounded-lg border border-border bg-surface p-0.5 text-xs">
{(["cards", "matrix"] as const).map((v) => (
<button
key={v}
type="button"
disabled={forceMatrix || (v === "cards" && !hasPhases)}
onClick={() => setView(v)}
title={
forceMatrix
? "Vergleiche werden in der Matrix gezeigt"
: v === "cards" && !hasPhases
? "Erst mit einer Lebensphase"
: undefined
}
className={`rounded-md px-2.5 py-1 font-medium transition-colors disabled:opacity-40 ${
effectiveView === v ? "bg-accent text-accent-fg" : "text-muted hover:text-fg"
}`}
>
{v === "cards" ? "Karten" : "Matrix"}
</button>
))}
</div>
<ValueModeSwitch mode={valueMode} onChange={setValueMode} />
{!railOpen && (
<button
type="button"
onClick={() => setRailOpen(true)}
title="Steuerung einblenden"
className="relative rounded-lg border border-border p-1.5 text-muted transition-colors hover:bg-surface-2 hover:text-fg"
>
<PanelRight className="h-4 w-4" />
{review.total > 0 && (
<span className="absolute -right-1 -top-1 flex h-4 min-w-4 items-center justify-center rounded-full bg-attention px-1 text-[10px] font-semibold text-attention-fg">
{review.total}
</span>
)}
</button>
)}
</div>
</div>
<div data-tour="timeline" className="mt-3">
{/* Die Zeitachse ist seit 0.42 der NAVIGATOR: Das Segment der offenen Karte ist
hervorgehoben, ein Klick springt hin. Zeit läuft von links nach rechts -- das
versteht man ohne Erklärung. */}
<div data-tour="timeline">
<Timeline
phases={computed.phases}
persons={personAxes}
ruinAge={computed.ruinAge}
actualYears={actualYears}
startYear={plan.startYear}
activePhaseId={effectiveView === "cards" ? currentPhaseId : null}
onSelectPhase={effectiveView === "cards" ? setActivePhaseId : undefined}
/>
</div>
</div>
{plan.elements.length === 0 && (
<EmptyState
@@ -497,7 +568,22 @@ export function PlanView({
Sie erscheint, sobald es ELEMENTE gibt -- Lebensphasen sind dafür nicht nötig. Bis
0.36 hing sie an den Phasen, wodurch die Bestandsaufnahme aus Schritt 1 im
Nichts verschwand: erfasst, gespeichert, unsichtbar. */}
{(hasPhases || plan.elements.length > 0) && (
{effectiveView === "cards" && hasPhases && (
<PhaseCards
plan={plan}
computed={computed}
mode={valueMode}
activePhaseId={currentPhaseId}
onSelectPhase={setActivePhaseId}
phaseContext={buildPhaseContext}
transitionContext={buildTransitionContext}
transitionElements={transitionElements}
cashTransitionFor={cashTransitionFor}
actions={cardActions}
/>
)}
{effectiveView === "matrix" && (hasPhases || plan.elements.length > 0) && (
<div data-tour="matrix" className="max-h-[75vh] overflow-auto rounded-xl border border-border bg-surface shadow-sm">
{/* Feste Spaltenbreiten: Alle Phasenspalten sind gleich breit -- bei einer einzigen
Phase bleibt die Tabelle dadurch schmal, bei vielen wird horizontal gescrollt.
@@ -954,6 +1040,69 @@ Erste Lebensphase anlegen
</table>
</div>
)}
</div>
{/* RECHTE LEISTE -- die Steuerung, nach Dringlichkeit sortiert. «Offene Punkte» steht
oben, ist als einzige eingefärbt und standardmässig offen; der Rest ist zugeklappt.
Eingeklappt bleibt die Zahl am Knopf sichtbar -- sonst klappt man die Leiste zu und
vergisst den halben Plan. */}
{railOpen && (
<aside className="hidden w-[19rem] shrink-0 flex-col gap-2 xl:flex">
<div className="flex items-center gap-2 px-1">
<span className="text-[11px] font-semibold uppercase tracking-wide text-faint">Steuerung</span>
<button
type="button"
onClick={() => setRailOpen(false)}
aria-label="Steuerung ausblenden"
title="Steuerung ausblenden"
className="ml-auto rounded p-1 text-faint transition-colors hover:bg-surface-2 hover:text-fg"
>
<PanelRightClose className="h-4 w-4" />
</button>
</div>
<ReviewTile
review={review}
onOpenInventory={() => setShowInventory(true)}
onAddPhase={() => setShowAddPhase(true)}
onJump={(g) => {
if (g.kind === "transition") {
// Ein Übergang gehört zur FOLGEphase -- dorthin springt die Karte.
const i = computed.phases.findIndex((p) => p.id === g.id);
const next = computed.phases[i + 1];
if (effectiveView === "cards" && next) return setActivePhaseId(next.id);
return setReviewFromPhaseId(g.id);
}
if (effectiveView === "cards") return setActivePhaseId(g.id);
setPanel({ kind: "phase", phaseId: g.id });
}}
/>
<RailSection title="Kennzahlen" defaultOpen>
<FiguresTile plan={plan} computed={computed} bare />
</RailSection>
<RailSection title="Grundeinstellungen">
<SettingsTile
plan={plan}
computed={computed}
diffChanged={!!diff?.profileChanged}
onEdit={() => setPanel({ kind: "profile" })}
bare
/>
</RailSection>
<RailSection title="Schnellaktionen">
<QuickActionsTile
onInventory={() => setShowInventory(true)}
onHistory={onOpenHistory}
onTour={onStartTour}
onNewScenario={onCopyScenario}
onTraces={onOpenTraces}
exportHref={exportHref}
bare
/>
</RailSection>
</aside>
)}
{/* Inspector-Panel: alle Einzel-Bearbeitungen. Der key erzwingt beim Wechsel der
Auswahl einen Neuaufbau, damit kein Formular-Zustand der vorherigen Zelle
@@ -1336,6 +1485,10 @@ Erste Lebensphase anlegen
const START_END_CATEGORIES: ElementCategory[] = [...VALUE_CATEGORIES, "INCOME", "EXPENSE"];
type ValueMode = "nominal" | "both" | "real";
// Arbeitsgewohnheiten des Benutzers, nicht Eigenschaften des Plans -- deshalb lokal.
const VIEW_KEY = "fpt.view";
const RAIL_KEY = "fpt.rail";
const VALUE_MODE_KEY = "fpt-value-mode";
// Realwert = nominal / kumulierte Inflation (Kaufkraft zum Planbeginn).
@@ -2671,12 +2824,24 @@ function Tile({
children,
action,
tour,
bare,
}: {
title: string;
children: React.ReactNode;
action?: React.ReactNode;
tour?: string;
// In der rechten Leiste liefert die aufklappbare Sektion Rahmen und Titel -- eine zweite
// Kachel darin waere ein Kasten im Kasten.
bare?: boolean;
}) {
if (bare) {
return (
<div data-tour={tour} className="flex flex-col gap-1">
{action && <div className="mb-1 flex justify-end">{action}</div>}
{children}
</div>
);
}
return (
<div data-tour={tour} className="flex h-full flex-col rounded-xl border border-border bg-surface px-3 py-2.5 shadow-sm">
<div className="mb-2 flex items-start justify-between gap-2">
@@ -2688,16 +2853,74 @@ function Tile({
);
}
// Ein aufklappbarer Abschnitt der rechten Leiste. Zugeklappt kostet er eine Zeile.
function RailSection({
title,
defaultOpen,
children,
}: {
title: string;
defaultOpen?: boolean;
children: React.ReactNode;
}) {
const [open, setOpen] = useState(defaultOpen ?? false);
return (
<div className="rounded-xl border border-border bg-surface shadow-sm">
<button
type="button"
onClick={() => setOpen((o) => !o)}
aria-expanded={open}
className="flex w-full items-center gap-2 px-3 py-2 text-left"
>
<span className="text-xs font-semibold uppercase tracking-wide text-faint">{title}</span>
<ChevronDown className={`ml-auto h-4 w-4 shrink-0 text-faint transition-transform ${open ? "rotate-180" : ""}`} />
</button>
{open && <div className="border-t border-border px-3 py-2.5">{children}</div>}
</div>
);
}
// Nominal / Beide / Real. Seit 0.42 an ZWEI Orten: in der Werkzeugleiste (sie gilt fuer beide
// Ansichten) und weiterhin in der Ecke der Matrix. Eine Implementierung, damit sie nicht
// auseinanderlaufen.
function ValueModeSwitch({ mode, onChange }: { mode: ValueMode; onChange: (m: ValueMode) => void }) {
return (
<div className="inline-flex rounded-lg border border-border bg-surface p-0.5 text-xs">
{(["nominal", "both", "real"] as ValueMode[]).map((m) => (
<button
key={m}
type="button"
onClick={() => onChange(m)}
title={
m === "real"
? "Kaufkraftbereinigt auf den Planbeginn"
: m === "both"
? "Nominal und real"
: "Nominale Beträge"
}
className={`rounded-md px-2.5 py-1 font-medium transition-colors ${
mode === m ? "bg-accent text-accent-fg" : "text-muted hover:text-fg"
}`}
>
{m === "nominal" ? "Nominal" : m === "both" ? "Beide" : "Real"}
</button>
))}
</div>
);
}
function SettingsTile({
plan,
computed,
diffChanged,
onEdit,
bare,
}: {
plan: PlanInput;
computed: PlanComputed;
diffChanged: boolean;
onEdit: () => void;
bare?: boolean;
}) {
// Der Planungshorizont wird ABGELEITET -- er ist die Summe der Lebensphasen. Bis 0.37 war
// er eine eigene Zahl, die man setzte und der die letzte Phase folgte. Das war eine zweite
@@ -2708,6 +2931,7 @@ function SettingsTile({
return (
<Tile
bare={bare}
tour="profil"
title={`Grundeinstellungen${diffChanged ? " · abweichend" : ""}`}
action={
@@ -2762,7 +2986,7 @@ function SettingsTile({
);
}
function FiguresTile({ plan, computed }: { plan: PlanInput; computed: PlanComputed }) {
function FiguresTile({ plan, computed, bare }: { plan: PlanInput; computed: PlanComputed; bare?: boolean }) {
const last = computed.phases[computed.phases.length - 1] ?? null;
// «Vermögen heute» ist die Summe der erfassten Bestände. Sie steht hier, weil sie die
// einzige Kennzahl ist, die schon VOR jeder Zeitplanung etwas aussagt -- und damit die
@@ -2777,7 +3001,7 @@ function FiguresTile({ plan, computed }: { plan: PlanInput; computed: PlanComput
}, plan.initialCash || 0);
return (
<Tile tour="endwealth" title="Kennzahlen">
<Tile tour="endwealth" title="Kennzahlen" bare={bare}>
<Figure label="Vermögen heute" value={today} />
<Figure label="Endvermögen nominal" value={last ? Math.round(last.endWealthNominal) : null} />
<Figure label="Endvermögen real" value={last ? Math.round(last.endWealthReal) : null} />
@@ -2816,6 +3040,7 @@ function QuickActionsTile({
onNewScenario,
onTraces,
exportHref,
bare,
}: {
onInventory?: () => void;
onHistory?: () => void;
@@ -2823,11 +3048,12 @@ function QuickActionsTile({
onNewScenario?: () => void;
onTraces?: () => void;
exportHref?: string;
bare?: boolean;
}) {
const cls =
"flex w-full items-center gap-1.5 rounded-lg border border-border px-2 py-1 text-[11px] font-medium text-muted transition-colors hover:bg-surface-2 hover:text-fg";
return (
<Tile tour="toolbar" title="Schnellaktionen">
<Tile tour="toolbar" title="Schnellaktionen" bare={bare}>
{onInventory && (
<button type="button" onClick={onInventory} className={cls}>
<ClipboardList className="h-3.5 w-3.5" /> Bestandsaufnahme
+19 -4
View File
@@ -20,6 +20,8 @@ export function Timeline({
ruinAge,
startYear,
actualYears = [],
activePhaseId,
onSelectPhase,
}: {
phases: PhaseComputed[];
persons: PersonAxis[];
@@ -28,6 +30,11 @@ export function Timeline({
// 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[];
// Seit 0.42 ist die Zeitachse nicht mehr nur Anzeige, sondern der NAVIGATOR der
// Kartenansicht: Das Segment der offenen Karte ist hervorgehoben, ein Klick springt hin.
// Zeit läuft von links nach rechts -- das versteht man ohne Erklärung.
activePhaseId?: string | null;
onSelectPhase?: (phaseId: string) => void;
}) {
if (phases.length === 0 || persons.length === 0) return null;
@@ -135,14 +142,22 @@ export function Timeline({
<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";
const active = activePhaseId === s.phase.id;
const clickable = !!onSelectPhase;
return (
<div
<button
key={s.phase.id}
type="button"
disabled={!clickable}
onClick={() => onSelectPhase?.(s.phase.id)}
aria-current={active ? "true" : undefined}
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 ${
className={`flex min-w-0 flex-col justify-center px-2 py-1.5 text-left transition-all ${
i > 0 ? "border-l border-border" : ""
} ${s.phase.type === "PENSION" ? "bg-accent-soft" : s.phase.type === "MIXED" ? "bg-accent/25" : "bg-accent/70"}`}
} ${clickable ? "cursor-pointer hover:brightness-105" : ""} ${
s.phase.type === "PENSION" ? "bg-accent-soft" : s.phase.type === "MIXED" ? "bg-accent/25" : "bg-accent/70"
} ${active ? "ring-2 ring-inset ring-fg/70" : clickable ? "opacity-70" : ""}`}
>
<span className={`truncate text-[10px] font-semibold ${soft ? "text-accent-soft-fg" : "text-accent-fg"}`}>
{s.phase.name}
@@ -151,7 +166,7 @@ export function Timeline({
{s.phase.durationYears} J.
{yearOf(s.from) ? ` · ${yearOf(s.from)}${yearOf(s.to)}` : ""}
</span>
</div>
</button>
);
})}
</div>