Kartenansicht je Lebensphase, Icon-Schiene links, Steuerung rechts
Deploy App / deploy (push) Successful in 1m10s
Deploy App / deploy (push) Successful in 1m10s
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
+242
-3
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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. */}
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user