Files
FPT/src/components/PlanView.tsx
T

2435 lines
99 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client";
import { useEffect, useMemo, useState } from "react";
import {
AlertCircle,
ArrowRight,
Building2,
CheckCircle2,
ChevronDown,
ChevronRight,
CreditCard,
Home,
Landmark,
ListTodo,
Pencil,
Maximize2,
PiggyBank,
Table2,
Plus,
Settings2,
ShoppingCart,
Tornado,
Trash2,
TrendingUp,
Wallet,
} from "lucide-react";
import { Timeline } from "@/components/Timeline";
import { Sparkline } from "@/components/Sparkline";
import { CapitalDistributionDialog, RateDistributionDialog } from "@/components/DistributionDialogs";
import { capitalPot } from "@/lib/distribution";
import {
isRetirementTransition,
openTransitionCount,
decisionsText,
type DecisionCounts,
transitionInactive as inactiveAtTransition,
TRANSITION_CATEGORIES,
retirementConfirmed,
} from "@/lib/decisions";
import { AHV_REFERENCE_AGE } from "@/lib/constants";
import {
RETIREMENT_CATEGORIES,
withRetirementDefaults,
type RetirementDecision,
} from "@/lib/retirement-decision";
import { RetirementFields } from "@/components/RetirementFields";
import { RetirementPanel } from "@/components/RetirementPanel";
import { Button, EmptyState, InspectorShell, Modal, useConfirm, useToast } from "@/components/ui";
import { ElementDetailDialog, PhaseDetailDialog } from "@/components/DetailView";
import {
CashTransitionFields,
ElementDetail,
ElementPhaseFields,
ElementTransitionFields,
cashTransitionSummary,
isCashTransitionAnswered,
isTransitionAnswered,
withCashTransitionDefaults,
withTransitionDefaults,
type CellContext,
} from "@/components/ElementDetail";
import { PhaseDetail } from "@/components/PhaseDetail";
import { PlanProfileFields, type ProfileDraft } from "@/components/PlanProfileFields";
import { RetirementAdjuster } from "@/components/RetirementAdjuster";
import { MoneyField, SelectField, TextField } from "@/components/FormField";
import { api } from "@/lib/api-client";
import { formatChf } from "@/lib/format";
import { DATA_SOURCE_OPTIONS, type DataSource } from "@/lib/dataview";
import {
CATEGORY_LABELS,
CATEGORY_ORDER,
PERSON_ONLY_CATEGORIES,
inheritedPhaseValues,
num,
ownerLabel,
type CashTransitionData,
type ElementCategory,
type PhaseData,
type TransitionData,
} from "@/lib/elements";
import { computePlan, type PhaseComputed, type PlanComputed } from "@/lib/calculations";
import type { ScenarioDiff } from "@/lib/diff";
import type { ElementInput, PlanInput } from "@/lib/types";
const CATEGORY_ICON: Record<ElementCategory, React.ReactNode> = {
INCOME: <Wallet className="h-4 w-4" />,
EXPENSE: <ShoppingCart className="h-4 w-4" />,
AHV: <Landmark className="h-4 w-4" />,
PENSION_FUND: <Building2 className="h-4 w-4" />,
PILLAR_3A: <PiggyBank className="h-4 w-4" />,
REAL_ESTATE: <Home className="h-4 w-4" />,
OTHER_ASSET: <TrendingUp className="h-4 w-4" />,
OTHER_DEBT: <CreditCard className="h-4 w-4" />,
};
// Kategorien mit einem Übergangs-Entscheid. AHV ist dabei ein Sonderfall: nur beim
// Pensions-Übergang ist die Beitragskarriere zu prüfen (siehe transitionInactive).
const VALUE_CATEGORIES: ElementCategory[] = [
"PENSION_FUND",
"PILLAR_3A",
"REAL_ESTATE",
"OTHER_ASSET",
"OTHER_DEBT",
];
type Column =
| { kind: "phase"; phase: PhaseComputed }
| { kind: "transition"; fromPhase: PhaseComputed; toPhase: PhaseComputed };
// Alle Einzel-Bearbeitungen laufen über EIN rechtes Inspector-Panel (statt Modals):
// die Matrix bleibt sichtbar, ein Klick auf eine andere Zelle wechselt den Inhalt.
// Genau ein Panel kann offen sein -- deshalb eine Union statt sechs einzelner Zustände.
type Panel =
| { kind: "phase"; phaseId: string }
| { kind: "elementMeta"; elementId: string }
| { kind: "cell"; elementId: string; phaseId: string }
| { kind: "transition"; elementId: string; fromPhaseId: string }
| { kind: "cashTransition"; fromPhaseId: string }
| { kind: "cashInitial" }
| { kind: "profile" };
export function PlanView({
plan,
computed: planComputed,
actualComputed = null,
actualYears = [],
diff,
onChanged,
onOpenSpec,
onOpenSensitivity,
}: {
plan: PlanInput;
computed: PlanComputed;
// Zweiter Rechenlauf mit den effektiven Werten; null, wenn keine erfasst sind.
actualComputed?: PlanComputed | null;
// Kalenderjahre mit Ist-Satz (Marker auf der Zeitachse).
actualYears?: number[];
// Abweichungen gegenüber dem Eltern-Szenario; null im Basisszenario (nichts zu markieren).
diff: ScenarioDiff | null;
onChanged: () => void;
onOpenSpec?: (anchor: string) => void;
onOpenSensitivity?: () => void;
}) {
// Planzahlen oder effektive Zahlen (inkl. Abweichung). Bewusst zwei Möglichkeiten statt
// dreier: Plan UND Ist als Rohwerte nebeneinander wären mit nominal/real acht Zahlen je
// Zelle (siehe SPEZIFIKATION 9.29).
const [dataSource, setDataSource] = useState<DataSource>("PLAN");
const hasActuals = actualComputed !== null;
const computed = dataSource === "ACTUAL" && actualComputed ? actualComputed : planComputed;
// Planwert derselben Zelle -- Grundlage der Abweichung. In der Plan-Sicht null, dann zeigt
// die Zelle gar keine Abweichung an (statt eine von 0 zu behaupten).
const showDeviation = dataSource === "ACTUAL" && actualComputed !== null;
const planEndOf = (elementId: string | undefined, phaseId: string): number | null => {
if (!showDeviation || !elementId) return null;
const ph = planComputed.phases.find((p) => p.id === phaseId);
const el = ph?.elements.find((e) => e.elementId === elementId);
return el ? el.endValue : null;
};
const confirmDialog = useConfirm();
const toast = useToast();
// Markierungs-Klassen: geändert = gelb, neu = grün, entfernt = grau.
const cellDiff = (elementId: string, phaseId: string) =>
diff?.phaseCell.has(`${elementId}:${phaseId}`) ? "bg-diff-soft ring-1 ring-inset ring-diff/40" : "";
const transDiff = (elementId: string, fromPhaseId: string) =>
diff?.transitionCell.has(`${elementId}:${fromPhaseId}`) ? "bg-diff-soft ring-1 ring-inset ring-diff/40" : "";
const rowDiff = (elementId: string) => {
const k = diff?.elementRow.get(elementId);
return k === "added"
? "bg-diff-added-soft"
: k === "changed"
? "bg-diff-soft"
: "";
};
const [panel, setPanel] = useState<Panel | null>(null);
const [collapsedCats, setCollapsedCats] = useState<Set<ElementCategory>>(new Set());
const [showAdd, setShowAdd] = useState(false);
const [addCategory, setAddCategory] = useState<ElementCategory | null>(null);
const [showAddPhase, setShowAddPhase] = useState(false);
const [reviewFromPhaseId, setReviewFromPhaseId] = useState<string | null>(null);
// Offener Verteil-Dialog (Kapital bzw. Spar-/Verzehrquote) -- bewusst ein eigenes Popup,
// weil beide mehrere Elemente auf einmal bearbeiten.
const [distribute, setDistribute] = useState<{ kind: "capital" | "rates"; phaseId: string } | null>(null);
const [valueMode, setValueMode] = useState<ValueMode>("nominal");
// Matrix oder Pensionierung. Kein URL-Zustand: Es ist eine Arbeitsansicht, kein Ort.
const [view, setView] = useState<"matrix" | "retirement">("matrix");
// Die Tour (Start-Knopf, Auto-Start bei Plan-Erstellung, Rendering) liegt seit dem
// Layout-Umbau in AppShell -- sie liest die data-tour-Ziele im DOM dieser Ansicht.
// Nur-Lese-Detailansicht (Roadmap Nr. 43). Der Rechenweg wird erst beim Öffnen erzeugt.
const [detailFor, setDetailFor] = useState<{ kind: "element"; id: string } | { kind: "phase"; id: string } | null>(null);
// Erklärte Berechnung: bewusst NUR wenn eine Detailansicht offen ist. computePlan ist rein
// und läuft im Browser -- es braucht dafür weder einen API-Aufruf noch eine grössere
// Server-Antwort, und das Ergebnis ist per Konstruktion identisch zum Serverergebnis.
const explained = useMemo(
() => (detailFor ? computePlan(plan, undefined, { explain: true }) : null),
[detailFor, plan]
);
useEffect(() => {
const stored = typeof window !== "undefined" ? window.localStorage.getItem(VALUE_MODE_KEY) : null;
// eslint-disable-next-line react-hooks/set-state-in-effect -- einmalige Übernahme der gespeicherten Wahl
if (stored === "nominal" || stored === "both" || stored === "real") setValueMode(stored);
}, []);
function changeValueMode(m: ValueMode) {
setValueMode(m);
if (typeof window !== "undefined") window.localStorage.setItem(VALUE_MODE_KEY, m);
}
const columns = useMemo<Column[]>(() => {
const cols: Column[] = [];
computed.phases.forEach((p, i) => {
cols.push({ kind: "phase", phase: p });
if (i < computed.phases.length - 1) {
cols.push({ kind: "transition", fromPhase: p, toPhase: computed.phases[i + 1] });
}
});
return cols;
}, [computed.phases]);
// Anzeigename einer Person: eigener Name, sonst Fallback "Person A"/"Person B".
function personLabel(role: string): string {
const p = plan.persons.find((x) => x.role === role);
if (p?.name && p.name.trim()) return p.name.trim();
return role === "PERSON_A" ? "Person A" : "Person B";
}
const personAxes = plan.persons.map((p) => ({
role: p.role,
label: personLabel(p.role),
currentAge: p.age,
retirementAge: p.retirementAge,
color: p.role === "PERSON_A" ? "var(--person-a)" : "var(--person-b)",
}));
const elementsByCategory = useMemo(() => {
const map = new Map<ElementCategory, ElementInput[]>();
for (const cat of CATEGORY_ORDER) map.set(cat, []);
for (const e of [...plan.elements].sort((a, b) => a.orderIndex - b.orderIndex)) {
map.get(e.category)!.push(e);
}
return map;
}, [plan.elements]);
function computedElement(phaseId: string, elementId: string) {
return computed.phases.find((p) => p.id === phaseId)?.elements.find((e) => e.elementId === elementId);
}
// isRetirementTransition/transitionInactive/openTransitionCount kommen aus lib/decisions --
// der Bericht zaehlt mit derselben Regel (sonst meldet er andere Zahlen als die Matrix).
// Beitragskarriere des Element-Besitzers (nur für AHV relevant).
function careerFor(element: ElementInput) {
if (element.category !== "AHV" || !element.ownerRole || element.ownerRole === "HOUSEHOLD") return null;
const person = plan.persons.find((p) => p.role === element.ownerRole);
return computed.ahvCareer.find((c) => c.personId === person?.id) ?? null;
}
// Anzahl Phasen NACH dieser (für den Fortschreibungs-Warnhinweis).
function laterPhaseCount(phase: PhaseComputed): number {
return computed.phases.length - phase.sequenceNumber;
}
// Phasen in ihrer Reihenfolge -- Grundlage der Vererbung (Roadmap Nr. 44, Punkt A).
const orderedPhaseIds = computed.phases.map((p) => p.id);
// Alter des Element-Besitzers im ersten Jahr einer Phase. Ohne Personenzuordnung gilt
// Person A, weil die Zeitachse ohnehin an ihr hängt.
function ownerAgeAtPhaseStart(phase: PhaseComputed, element: ElementInput): number {
const role = element.ownerRole && element.ownerRole !== "HOUSEHOLD" ? element.ownerRole : "PERSON_A";
return phase.persons.find((p) => p.role === role)?.startAge ?? 0;
}
// Baut den Kontext für eine Phasenzelle.
function buildPhaseContext(phase: PhaseComputed, element: ElementInput): CellContext {
const ce = computedElement(phase.id, element.id);
const ownerWorking =
element.ownerRole && element.ownerRole !== "HOUSEHOLD"
? phase.persons.find((p) => p.role === element.ownerRole)?.working ?? false
: phase.type !== "PENSION";
return {
kind: "phase",
phaseId: phase.id,
ownerWorking,
isConsumption: phase.isConsumption,
durationYears: phase.durationYears,
isRetirementTransition: false,
carriedEndValue: ce?.endValue ?? 0,
carried: ce?.carried ?? false,
derivedStart: ce?.baseValue ?? 0,
derivedMortgage: ce?.mortgageStart ?? 0,
mortgageEnd: ce?.mortgageEnd ?? 0,
// Verkehrswert = Eigenkapital + Resthypothek (beides am Phasenende).
propertyValueEnd: (ce?.endValue ?? 0) + (ce?.mortgageEnd ?? 0),
deflatorStart: phase.cumulativeInflationStart,
laterPhaseCount: laterPhaseCount(phase),
ahvCareer: careerFor(element),
inheritedValues: inheritedPhaseValues(element.phaseValues, orderedPhaseIds, phase.id),
ownerAgeStart: ownerAgeAtPhaseStart(phase, element),
investTargets: [],
capitalFromTransfer: ce?.capitalFromTransfer ?? 0,
capitalFromTransferSources: ce?.capitalFromTransferSources ?? [],
};
}
function buildTransitionContext(fromPhase: PhaseComputed, toPhase: PhaseComputed | undefined, element: ElementInput): CellContext {
const ce = computedElement(fromPhase.id, element.id);
return {
kind: "transition",
phaseId: fromPhase.id,
ownerWorking: true,
isConsumption: fromPhase.isConsumption,
durationYears: fromPhase.durationYears,
isRetirementTransition: toPhase ? isRetirementTransition(element, fromPhase, toPhase) : false,
carriedEndValue: ce?.endValue ?? 0,
carried: ce?.carried ?? false,
derivedStart: 0,
derivedMortgage: 0,
mortgageEnd: ce?.mortgageEnd ?? 0,
// Verkehrswert = Eigenkapital + Resthypothek (beides am Phasenende).
propertyValueEnd: (ce?.endValue ?? 0) + (ce?.mortgageEnd ?? 0),
deflatorStart: fromPhase.cumulativeInflationStart,
laterPhaseCount: laterPhaseCount(fromPhase),
ahvCareer: careerFor(element),
inheritedValues: {},
ownerAgeStart: ownerAgeAtPhaseStart(fromPhase, element),
// Ziele fuer die Anlage-Quote beim Kapitalbezug (Punkt C).
investTargets: investTargetsOf(plan, toPhase),
capitalFromTransfer: 0,
capitalFromTransferSources: [],
};
}
// Am Übergang nichts (mehr) zu tun: verkauft/getilgt ODER PK/3a nach der Pensionierung
// (Besitzer ist zu Beginn der Von-Phase bereits pensioniert -> bereits bezogen/verrentet)
// ODER AHV ausserhalb des Pensions-Übergangs.
const transitionInactive = (el: ElementInput, fromPhase: PhaseComputed, toPhase?: PhaseComputed) =>
inactiveAtTransition(computed, el, fromPhase, toPhase);
function cashTransitionFor(phaseId: string): CashTransitionData {
return plan.phases.find((p) => p.id === phaseId)?.cashTransition ?? {};
}
// Anzahl offener (noch nicht getroffener) Übergangs-Entscheide an einer Grenze.
// Der Cash-Entscheid (einmalige Sonderein-/ausgaben) zählt mit.
const transitionOpenCount = (fromPhase: PhaseComputed, toPhase: PhaseComputed) =>
openTransitionCount(plan, computed, fromPhase, toPhase);
// Ist der Übergangs-Entscheid dieses Elements noch offen?
function transitionUnanswered(el: ElementInput, fromPhase: PhaseComputed, toPhase: PhaseComputed): boolean {
if (transitionInactive(el, fromPhase, toPhase)) return false;
const retire = isRetirementTransition(el, fromPhase, toPhase);
return !isTransitionAnswered(el.category, retire, el.transitionValues[fromPhase.id] ?? {});
}
function transitionElements(fromPhase: PhaseComputed, toPhase?: PhaseComputed): ElementInput[] {
return plan.elements
.filter((el) => TRANSITION_CATEGORIES.includes(el.category))
.filter((el) => !transitionInactive(el, fromPhase, toPhase))
.sort((a, b) => a.orderIndex - b.orderIndex);
}
async function handleAddPhase(payload: { name?: string; durationYears?: number }) {
await api.post(`/api/scenarios/${plan.id}/phases`, payload);
setShowAddPhase(false);
onChanged();
}
// Kurzfassung der Pensionierung fuer die Matrix-Ansicht: die eine Zahl, die zaehlt, plus
// der Hinweis auf ungepruefte Vorgaben. Ein Klick fuehrt in den Bildschirm.
const retirementSummaryText = (() => {
const r = computed.retirement;
if (r.gapAnnual === null) return null;
const unconfirmed = plan.elements.filter(
(e) => RETIREMENT_CATEGORIES.includes(e.category) && !retirementConfirmed(e)
).length;
const luecke = r.gapAnnual < 0 ? `Rentenluecke ${formatChf(r.gapAnnual)}/Jahr` : `Ueberschuss ${formatChf(r.gapAnnual)}/Jahr`;
return unconfirmed > 0 ? `${luecke} · ${unconfirmed} Vorgaben ungeprueft` : luecke;
})();
const hasPhases = computed.phases.length > 0;
const firstPhase = computed.phases[0] ?? null;
const lastPhase = computed.phases[computed.phases.length - 1] ?? null;
return (
<div className="flex flex-col gap-5">
<NextSteps
plan={plan}
computed={computed}
columns={columns}
openCountFor={transitionOpenCount}
onReview={(fromPhaseId) => setReviewFromPhaseId(fromPhaseId)}
onAddElement={(cat) => {
setAddCategory(cat);
setShowAdd(true);
}}
onAddPhase={() => setShowAddPhase(true)}
onOpenSensitivity={onOpenSensitivity}
/>
{/* Kompakter Klotz: Grundprofil (25 %) · Zeitachse (50 %) · Kennzahlen (25 %). Auf
schmalen Screens stapeln die drei. */}
<div className="grid grid-cols-1 gap-4 lg:grid-cols-4">
{/* Grundprofil -- Daten untereinander, «Bearbeiten» oben rechts. */}
<div
data-tour="profil"
className={`flex flex-col gap-2 rounded-xl border px-4 py-3 shadow-sm lg:col-span-1 ${
diff?.profileChanged ? "border-diff bg-diff-soft" : "border-border bg-surface"
}`}
>
<div className="flex items-start justify-between gap-2">
<span className="text-xs font-semibold uppercase tracking-wide text-faint">
Grundprofil{diff?.profileChanged ? " · abweichend" : ""}
</span>
<button
type="button"
onClick={() => setPanel({ kind: "profile" })}
className="flex shrink-0 items-center gap-1 rounded-lg border border-border px-2 py-0.5 text-xs font-medium text-muted hover:bg-surface-2"
>
<Settings2 className="h-3.5 w-3.5" /> Bearbeiten
</button>
</div>
<div className="flex flex-col gap-1">
{plan.persons.map((p) => (
<span key={p.role} className="text-xs text-muted">
{personLabel(p.role)}: {p.age} J., Pension {p.retirementAge}
</span>
))}
<span className="text-xs text-muted">Inflation {plan.inflationRateDefault}%</span>
</div>
</div>
{/* Zeitachse */}
<div data-tour="timeline" className="lg:col-span-2">
<Timeline
phases={computed.phases}
persons={personAxes}
ruinAge={computed.ruinAge}
actualYears={actualYears}
startYear={plan.startYear}
/>
</div>
{/* Kennzahlen: Endvermögen nominal + real (+ Ruin, falls vorhanden). */}
<div data-tour="endwealth" className="flex flex-col justify-center gap-2 rounded-xl border border-border bg-surface px-4 py-3 shadow-sm lg:col-span-1">
<span className="text-xs font-semibold uppercase tracking-wide text-faint">Endvermögen</span>
<div>
<div className="text-[11px] text-muted">Nominal</div>
<div className="text-lg font-bold text-fg">
{lastPhase ? formatChf(Math.round(lastPhase.endWealthNominal)) : "—"}
</div>
</div>
<div>
<div className="text-[11px] text-muted">Real (heutige Kaufkraft)</div>
<div className="text-lg font-bold text-fg">
{lastPhase ? formatChf(Math.round(lastPhase.endWealthReal)) : "—"}
</div>
</div>
{computed.ruinAge !== null && (
<div className="mt-0.5 rounded-lg bg-danger-soft px-2 py-1 text-xs font-medium text-danger">
Kapital reicht nur bis Alter {computed.ruinAge}
</div>
)}
</div>
</div>
{/* Ansicht: Matrix oder Pensionierung. Die Pensionierung steht bewusst gleichrangig
NEBEN der Matrix und nicht in ihr: Sie ist keine Zelle, sondern eine eigene Frage --
und die einzige, die man zwischen Szenarien systematisch variiert. */}
{hasPhases && (
<div className="flex flex-wrap items-center gap-2">
<div className="inline-flex rounded-lg border border-border bg-surface p-0.5 text-sm">
{(["matrix", "retirement"] as const).map((v) => (
<button
key={v}
type="button"
onClick={() => setView(v)}
className={`flex items-center gap-1.5 rounded-md px-3 py-1 font-medium ${
view === v ? "bg-accent text-accent-fg" : "text-muted hover:bg-surface-2"
}`}
>
{v === "matrix" ? <Table2 className="h-4 w-4" /> : <PiggyBank className="h-4 w-4" />}
{v === "matrix" ? "Matrix" : "Pensionierung"}
</button>
))}
</div>
{view === "matrix" && retirementSummaryText && (
<button
type="button"
onClick={() => setView("retirement")}
className="truncate text-xs text-muted underline decoration-dotted underline-offset-2 hover:text-fg"
title="Zur Pensionierung"
>
{retirementSummaryText}
</button>
)}
</div>
)}
{view === "retirement" && (
<RetirementPanel plan={plan} computed={computed} onSaved={onChanged} />
)}
{/* Anzeige-Umschalter (nominal/real, Plan/Ist) -- direkt über der Matrix, weil er nur
deren Zahlen steuert. */}
{hasPhases && view === "matrix" && (
<div className="flex items-center gap-2">
<span className="text-xs text-muted">Anzeige</span>
<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={() => changeValueMode(m)}
className={`rounded-md px-2.5 py-1 font-medium ${
valueMode === m ? "bg-accent text-accent-fg" : "text-muted hover:bg-surface-2"
}`}
>
{m === "nominal" ? "Nominal" : m === "both" ? "Beide" : "Real"}
</button>
))}
</div>
<span className="text-[11px] text-faint">real = kaufkraftbereinigt (Planbeginn)</span>
{/* Zweite Achse: Datenquelle. Erscheint nur, wenn es überhaupt Ist-Werte gibt --
sonst wäre es ein Umschalter ohne Gegenstück. */}
{hasActuals && (
<>
<span className="ml-2 text-xs text-muted">Zahlen</span>
<div className="inline-flex rounded-lg border border-border bg-surface p-0.5 text-xs">
{DATA_SOURCE_OPTIONS.map((o) => (
<button
key={o.value}
type="button"
onClick={() => setDataSource(o.value)}
title={o.label}
className={`rounded-md px-2.5 py-1 font-medium ${
dataSource === o.value ? "bg-accent text-accent-fg" : "text-muted hover:bg-surface-2"
}`}
>
{o.short}
</button>
))}
</div>
{dataSource === "ACTUAL" && (
<span className="text-[11px] text-faint">Abweichung gegenüber Plan farbig</span>
)}
</>
)}
</div>
)}
{!hasPhases && (
<EmptyState
icon={<Plus className="h-6 w-6" />}
title="Noch keine Lebensphasen"
text="Deine Planung besteht aus Lebensphasen Abschnitten, in denen deine Finanzen ähnlich laufen (z. B. «bis zur Pension»). Leg die erste an, danach kommen die finanziellen Elemente."
>
<Button onClick={() => setShowAddPhase(true)}>
<Plus className="h-4 w-4" /> Erste Lebensphase
</Button>
</EmptyState>
)}
{hasPhases && (
<div className="flex flex-wrap items-center gap-2">
<Button size="sm" className="px-3 py-1.5 text-sm" onClick={() => setShowAdd(true)}>
<Plus className="h-4 w-4" /> Finanzielles Element
</Button>
<button
type="button"
onClick={() => setShowAddPhase(true)}
className="flex items-center gap-1.5 rounded-lg border border-dashed border-accent bg-accent-soft px-3 py-1.5 text-sm font-medium text-accent-soft-fg transition-colors hover:bg-accent-soft"
>
<Plus className="h-4 w-4" /> Lebensphase
</button>
{/* Alle Kategoriegruppen auf einen Schlag auf- oder zuklappen. */}
<button
type="button"
onClick={() =>
setCollapsedCats((prev) =>
prev.size === 0 ? new Set(CATEGORY_ORDER.filter((c) => (elementsByCategory.get(c)?.length ?? 0) > 0)) : new Set()
)
}
className="ml-auto flex items-center gap-1.5 rounded-lg border border-border px-2.5 py-1.5 text-xs font-medium text-muted transition-colors hover:bg-surface-2 hover:text-fg"
>
{collapsedCats.size === 0 ? (
<>
<ChevronRight className="h-3.5 w-3.5" /> Alle zuklappen
</>
) : (
<>
<ChevronDown className="h-3.5 w-3.5" /> Alle aufklappen
</>
)}
</button>
</div>
)}
{computed.ruinAge !== null && (
<div className="flex flex-wrap items-center gap-2 rounded-xl border border-danger bg-danger-soft px-4 py-2 text-sm font-medium text-danger">
<AlertCircle className="h-4 w-4 shrink-0" />
<span className="min-w-0 flex-1">
Kapital aufgebraucht mit Alter {computed.ruinAge} das Gesamtvermögen (inkl. Cash) fällt danach unter 0.
</span>
{onOpenSensitivity && (
<button
type="button"
onClick={onOpenSensitivity}
className="flex shrink-0 items-center gap-1.5 rounded-lg border border-danger px-2.5 py-1 text-xs font-semibold text-danger transition-colors hover:bg-danger hover:text-white"
>
<Tornado className="h-3.5 w-3.5" /> Wirksamsten Hebel finden
</button>
)}
</div>
)}
{/* Matrix: eigener Scrollbereich, damit Phasen-Köpfe (oben) UND Elementnamen (links)
beim Scrollen sichtbar bleiben. */}
{hasPhases && view === "matrix" && (
<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.
Ein w-full haette die Spalten je nach Anzahl unterschiedlich gedehnt. */}
<table className="border-collapse text-sm">
<thead>
<tr>
<th className="sticky left-0 top-0 z-40 w-56 min-w-56 border-b border-r border-border bg-surface-2 px-3 py-2 text-left text-xs font-semibold uppercase tracking-wide text-faint">
Finanzielle Elemente
</th>
{columns.map((col) =>
col.kind === "phase" ? (
<PhaseHeader
key={col.phase.id}
phase={col.phase}
personLabel={personLabel}
mode={valueMode}
diffKind={diff?.phaseHeader.get(col.phase.id) ?? null}
onClick={() => setPanel({ kind: "phase", phaseId: col.phase.id })}
onExpand={() => setDetailFor({ kind: "phase", id: col.phase.id })}
onDistributeCapital={
col.phase.sequenceNumber > 1 ? () => setDistribute({ kind: "capital", phaseId: col.phase.id }) : null
}
onDistributeRates={() => setDistribute({ kind: "rates", phaseId: col.phase.id })}
onDelete={
col.phase.sequenceNumber === computed.phases.length
? () => void deletePhase(col.phase.id, col.phase.name)
: null
}
active={panel?.kind === "phase" && panel.phaseId === col.phase.id}
/>
) : (
<TransitionHeader
key={`t-${col.fromPhase.id}`}
counts={transitionOpenCount(col.fromPhase, col.toPhase)}
onClick={() => setReviewFromPhaseId(col.fromPhase.id)}
/>
)
)}
</tr>
</thead>
<tbody>
{/* Cash-Zeile (systemseitig, immer sichtbar, read-only). */}
<tr className="bg-surface-2/50">
<td data-tour="cash" className="sticky left-0 z-10 border-b border-r border-border bg-surface px-3 py-1.5">
<span className="flex items-center gap-1.5 text-xs font-semibold text-fg">
<Wallet className="h-4 w-4 text-accent" /> Cash
</span>
<span className="text-[10px] text-faint">verfügbares Kapital</span>
</td>
{columns.map((col) => {
const isFirst = col.kind === "phase" && col.phase.sequenceNumber === 1;
return col.kind === "phase" ? (
<td
key={`cash-${col.phase.id}`}
onClick={isFirst ? () => setPanel({ kind: "cashInitial" }) : undefined}
title={isFirst ? "Cash-Anfangswert bearbeiten" : undefined}
className={`border-b border-r border-border px-2 py-1.5 text-center text-xs ${
isFirst ? "cursor-pointer hover:bg-accent-soft" : ""
} ${col.phase.cashNegative ? "font-semibold text-danger" : "text-fg"} ${
isFirst && diff?.cashInitialChanged ? "bg-diff-soft ring-1 ring-inset ring-diff/40" : ""
}`}
>
<span className="whitespace-nowrap">
{valStr(col.phase.cashStart, col.phase.cumulativeInflationStart, valueMode)}{" "}
<span className="text-faint"></span>{" "}
{valStr(col.phase.cashEnd, col.phase.cumulativeInflationEnd, valueMode)}
</span>
</td>
) : (
(() => {
// Übergangszelle der Cash-Zeile: einmalige Sonderein-/ausgaben.
const ct = cashTransitionFor(col.fromPhase.id);
const open = !isCashTransitionAnswered(ct);
return (
<td
key={`cash-t-${col.fromPhase.id}`}
onClick={() => setPanel({ kind: "cashTransition", fromPhaseId: col.fromPhase.id })}
title="Einmalige Sonderein-/ausgaben"
className={`cursor-pointer border-b border-r border-border px-2 py-1.5 text-center text-[11px] ${
open
? "bg-attention font-semibold text-attention-fg"
: diff?.cashTransitionCell.has(col.fromPhase.id)
? "bg-diff-soft text-diff ring-1 ring-inset ring-diff/40"
: "bg-accent-soft/40 text-accent"
}`}
>
{cashTransitionSummary(ct)}
</td>
);
})()
);
})}
</tr>
{CATEGORY_ORDER.map((cat) => {
const els = elementsByCategory.get(cat)!;
if (els.length === 0) return null;
const collapsed = collapsedCats.has(cat);
return (
<FragmentRows key={cat}>
<tr className="bg-surface-2">
<td
className="sticky left-0 z-10 cursor-pointer border-b border-r border-border bg-surface-2 px-3 py-1.5"
onClick={() =>
setCollapsedCats((prev) => {
const next = new Set(prev);
if (next.has(cat)) next.delete(cat);
else next.add(cat);
return next;
})
}
>
<span className="flex items-center gap-1.5 text-xs font-semibold text-muted">
{collapsed ? <ChevronRight className="h-3.5 w-3.5" /> : <ChevronDown className="h-3.5 w-3.5" />}
<span className="text-accent">{CATEGORY_ICON[cat]}</span>
{CATEGORY_LABELS[cat]}
</span>
</td>
{/* Zugeklappt: Summe der Elementwerte je Phase -- sonst verschwindet die
Information beim Zuklappen. Aufgeklappt bleibt die Zeile leer, weil
man die Einzelwerte darunter sieht. */}
{collapsed
? columns.map((col) =>
col.kind === "phase" ? (
<td
key={`sum-${cat}-${col.phase.id}`}
className="border-b border-r border-border bg-surface-2 px-2 py-1.5 text-center text-xs font-semibold text-muted"
>
{(() => {
const rows = els
.map((e) => computedElement(col.phase.id, e.id))
.filter((ce): ce is NonNullable<typeof ce> => !!ce && ce.status === "ACTIVE");
if (rows.length === 0) return "";
const start = rows.reduce((sum, ce) => sum + ce.startValue, 0);
const end = rows.reduce((sum, ce) => sum + ce.endValue, 0);
return (
<span className="whitespace-nowrap">
{valStr(start, col.phase.cumulativeInflationStart, valueMode)}{" "}
<span className="text-faint"></span>{" "}
{valStr(end, col.phase.cumulativeInflationEnd, valueMode)}
</span>
);
})()}
</td>
) : (
<td key={`sum-t-${cat}-${col.fromPhase.id}`} className="border-b border-r border-border bg-surface-2" />
)
)
: <td colSpan={columns.length} className="border-b border-border" />}
</tr>
{!collapsed &&
els.map((el) => (
<tr key={el.id} className="hover:bg-surface-2">
<td className={`group/row sticky left-0 z-10 border-b border-r border-border px-3 py-1.5 ${rowDiff(el.id) || "bg-surface"}`}>
<div className="flex items-center gap-1 text-xs font-medium text-fg">
<span className="min-w-0 flex-1 truncate">{el.name}</span>
{diff?.elementRow.get(el.id) === "added" && (
<span className="rounded bg-diff-added px-1 text-[9px] font-semibold uppercase text-white">neu</span>
)}
{/* Aktionen zum Element selbst (phasenunabhängig): umbenennen,
löschen, Detailansicht -- bewusst IMMER sichtbar, damit sie
auch ohne Maus (Touch) erreichbar sind. */}
<button
type="button"
aria-label={`${el.name} umbenennen`}
title="Umbenennen und Zuordnung ändern"
onClick={(e) => {
e.stopPropagation();
setPanel({ kind: "elementMeta", elementId: el.id });
}}
className="shrink-0 rounded p-0.5 text-faint hover:bg-accent-soft hover:text-accent"
>
<Pencil className="h-3 w-3" />
</button>
<button
type="button"
aria-label={`${el.name} löschen`}
title="Element löschen"
onClick={(e) => {
e.stopPropagation();
void deleteElement(el.id, el.name);
}}
className="shrink-0 rounded p-0.5 text-faint hover:bg-danger-soft hover:text-danger"
>
<Trash2 className="h-3 w-3" />
</button>
<button
type="button"
aria-label={`Detailansicht ${el.name}`}
title="Detailansicht (nur lesen)"
onClick={(e) => {
e.stopPropagation();
setDetailFor({ kind: "element", id: el.id });
}}
className="shrink-0 rounded p-0.5 text-faint hover:bg-accent-soft hover:text-accent"
>
<Maximize2 className="h-3 w-3" />
</button>
</div>
{el.ownerRole && el.ownerRole !== "HOUSEHOLD" && (
<div className="text-[10px] text-faint">{personLabel(el.ownerRole)}</div>
)}
{/* Trend über alle Planjahre -- die per-Jahr-Werte existieren seit 0.11. */}
<Sparkline
values={computed.phases.flatMap(
(ph) => ph.elements.find((e) => e.elementId === el.id)?.yearly?.map((y) => y.value) ?? []
)}
className="mt-0.5"
/>
</td>
{columns.map((col) => {
if (col.kind === "phase") {
const ce = computedElement(col.phase.id, el.id);
return (
<td
key={col.phase.id}
onClick={() => setPanel({ kind: "cell", elementId: el.id, phaseId: col.phase.id })}
title={cellDiff(el.id, col.phase.id) ? "Weicht von der Vorlage ab" : undefined}
className={`cursor-pointer border-b border-r border-border px-2 py-1.5 text-center text-xs ${
ce?.locked ? "text-faint" : "text-fg"
} ${cellDiff(el.id, col.phase.id)}`}
>
{phaseCellContent(ce, col.phase, valueMode, planEndOf(ce?.elementId, col.phase.id))}
</td>
);
}
const locked = TRANSITION_CATEGORIES.includes(el.category) && transitionInactive(el, col.fromPhase, col.toPhase);
const canTransition = TRANSITION_CATEGORIES.includes(el.category) && !locked;
const open = canTransition && transitionUnanswered(el, col.fromPhase, col.toPhase);
return (
<td
key={`t-${col.fromPhase.id}`}
onClick={() =>
canTransition &&
setPanel({ kind: "transition", elementId: el.id, fromPhaseId: col.fromPhase.id })
}
title={transDiff(el.id, col.fromPhase.id) ? "Weicht von der Vorlage ab" : undefined}
className={`border-b border-r border-border px-2 py-1.5 text-center text-[11px] ${
canTransition ? "cursor-pointer" : "text-faint"
} ${
// Offene Entscheide tragen die Attention-Farbe (getrennt vom
// Akzent: "hier fehlt was" ist nicht "hier kannst du klicken").
open
? "bg-attention font-semibold text-attention-fg"
: transDiff(el.id, col.fromPhase.id)
? "bg-diff-soft text-diff ring-1 ring-inset ring-diff/40"
: canTransition
? "bg-accent-soft/40 text-accent"
: ""
}`}
>
{canTransition ? transitionSummary(el, col.fromPhase, col.toPhase) : locked ? "" : "→"}
</td>
);
})}
</tr>
))}
</FragmentRows>
);
})}
{/* Geisterzeilen: in der Vorlage vorhanden, in diesem Szenario gelöscht. */}
{(diff?.removedElements ?? []).map((r) => (
<tr key={`removed-${r.id}`} className="bg-diff-removed-soft">
<td className="sticky left-0 z-10 border-b border-r border-border bg-diff-removed-soft px-3 py-1.5">
<div className="flex items-center gap-1 truncate text-xs font-medium text-diff-removed line-through">
{r.name}
</div>
<div className="text-[10px] text-diff-removed">{CATEGORY_LABELS[r.category]} · entfernt</div>
</td>
<td colSpan={columns.length} className="border-b border-border px-3 py-1.5 text-center text-[11px] text-diff-removed">
In diesem Szenario entfernt
</td>
</tr>
))}
{plan.elements.length === 0 && (diff?.removedElements.length ?? 0) === 0 && (
<tr>
<td className="sticky left-0 bg-surface px-3 py-4 text-xs text-faint" colSpan={columns.length + 1}>
Noch keine finanziellen Elemente. Füge oben dein erstes Element hinzu.
</td>
</tr>
)}
</tbody>
</table>
</div>
)}
{/* Inspector-Panel: alle Einzel-Bearbeitungen. Der key erzwingt beim Wechsel der
Auswahl einen Neuaufbau, damit kein Formular-Zustand der vorherigen Zelle
übrig bleibt (gleiches Muster wie zuvor bei den Dialogen). */}
{panel && renderPanel()}
{showAdd && firstPhase && (
<AddElementDialog
plan={plan}
firstPhase={firstPhase}
initialCategory={addCategory}
onClose={() => {
setShowAdd(false);
setAddCategory(null);
}}
onCreated={() => {
setShowAdd(false);
setAddCategory(null);
toast("success", "Element angelegt.");
onChanged();
}}
/>
)}
{showAddPhase && (
<AddPhaseDialog
maxDurationYears={nextPhaseCap()}
onClose={() => setShowAddPhase(false)}
onCreate={handleAddPhase}
/>
)}
{reviewFromPhaseId && (() => {
const fromPhase = computed.phases.find((p) => p.id === reviewFromPhaseId);
if (!fromPhase) return null;
const toIndex = computed.phases.findIndex((p) => p.id === fromPhase.id) + 1;
const toPhase = computed.phases[toIndex];
const els = transitionElements(fromPhase, toPhase);
return (
<TransitionReviewDialog
plan={plan}
computed={computed}
fromPhase={fromPhase}
toPhase={toPhase}
elements={els}
initialCash={cashTransitionFor(fromPhase.id)}
buildContext={(el) => buildTransitionContext(fromPhase, toPhase, el)}
isRetirement={(el) => (toPhase ? isRetirementTransition(el, fromPhase, toPhase) : false)}
onClose={() => setReviewFromPhaseId(null)}
onSaved={() => {
setReviewFromPhaseId(null);
onChanged();
}}
/>
);
})()}
{detailFor?.kind === "element" && explained && (() => {
const el = plan.elements.find((e) => e.id === detailFor.id);
if (!el) return null;
return (
<ElementDetailDialog
key={`det-${detailFor.id}`}
elementId={el.id}
name={el.name}
category={el.category}
computed={explained}
onClose={() => setDetailFor(null)}
onOpenSpec={onOpenSpec}
/>
);
})()}
{detailFor?.kind === "phase" && explained && (() => {
const ph = explained.phases.find((p) => p.id === detailFor.id);
if (!ph) return null;
return (
<PhaseDetailDialog
key={`detph-${detailFor.id}`}
phase={ph}
computed={computed}
isFirst={ph.sequenceNumber === 1}
onClose={() => setDetailFor(null)}
onOpenSpec={onOpenSpec}
/>
);
})()}
{distribute?.kind === "capital" && (
<CapitalDistributionDialog
key={`cap-${distribute.phaseId}`}
plan={plan}
computed={computed}
phaseId={distribute.phaseId}
onClose={() => setDistribute(null)}
onSaved={() => {
setDistribute(null);
toast("success", "Kapital verteilt.");
onChanged();
}}
/>
)}
{distribute?.kind === "rates" && (
<RateDistributionDialog
key={`rate-${distribute.phaseId}`}
plan={plan}
computed={computed}
phaseId={distribute.phaseId}
onClose={() => setDistribute(null)}
onSaved={() => {
setDistribute(null);
toast("success", "Raten gespeichert.");
onChanged();
}}
/>
)}
</div>
);
// Nächste Phasen-Kappung (für das Phase-Popup).
// Element loeschen -- neu auch direkt aus der Zeile heraus.
async function deleteElement(elementId: string, elementName: string) {
const ok = await confirmDialog({
title: "Element löschen?",
message: ${elementName}» wird aus ALLEN Lebensphasen entfernt, samt Werten und Übergangs-Entscheiden.`,
confirmLabel: "Endgültig löschen",
danger: true,
});
if (!ok) return;
try {
await api.delete(`/api/elements/${elementId}`);
setPanel(null);
onChanged();
toast("success", "Element gelöscht.");
} catch (e) {
toast("error", e instanceof Error ? e.message : "Löschen fehlgeschlagen.");
}
}
async function deletePhase(phaseId: string, phaseName: string) {
const ok = await confirmDialog({
title: "Lebensphase löschen?",
message: ${phaseName}» wird mit allen erfassten Werten und Übergangs-Entscheiden entfernt.`,
confirmLabel: "Endgültig löschen",
danger: true,
});
if (!ok) return;
try {
await api.delete(`/api/phases/${phaseId}`);
setPanel(null);
onChanged();
toast("success", "Lebensphase gelöscht.");
} catch (e) {
toast("error", e instanceof Error ? e.message : "Löschen fehlgeschlagen.");
}
}
function nextPhaseCap(): number | null {
// Simpel aus den Personen ableiten (Jahre nach Planbeginn = Summe der Dauern).
const yearsBefore = plan.phases.reduce((s, p) => s + p.durationYears, 0);
const caps = plan.persons
.map((p) => p.retirementAge - (p.age + yearsBefore))
.filter((d) => d > 0);
return caps.length > 0 ? Math.min(...caps) : null;
}
// Inhalt des Inspector-Panels je nach Auswahl.
function renderPanel() {
if (!panel) return null;
const close = () => setPanel(null);
const closeAndRefresh = () => {
setPanel(null);
onChanged();
};
switch (panel.kind) {
case "elementMeta": {
const el = plan.elements.find((e) => e.id === panel.elementId);
if (!el) return null;
return (
<InspectorShell key={`meta-${panel.elementId}`} title={`Element: ${el.name}`} onClose={close}>
<ElementMetaPanel
element={el}
plan={plan}
personLabel={personLabel}
onSaved={closeAndRefresh}
onDelete={() => deleteElement(el.id, el.name)}
/>
</InspectorShell>
);
}
case "phase": {
const phase = computed.phases.find((p) => p.id === panel.phaseId);
const phaseInput = plan.phases.find((p) => p.id === panel.phaseId);
if (!phase || !phaseInput) return null;
return (
<InspectorShell key={`ph-${panel.phaseId}`} title={`Lebensphase: ${phase.name}`} onClose={close}>
<PhaseDetail
phase={phaseInput}
allPhases={plan.phases}
maxDurationYears={phase.maxDurationYears}
isLast={phase.sequenceNumber === computed.phases.length}
laterPhaseCount={laterPhaseCount(phase)}
onSaved={closeAndRefresh}
onDeleted={closeAndRefresh}
/>
</InspectorShell>
);
}
case "cell": {
const element = plan.elements.find((e) => e.id === panel.elementId);
const phase = computed.phases.find((p) => p.id === panel.phaseId);
if (!element || !phase) return null;
return (
<InspectorShell
key={`${panel.elementId}-${panel.phaseId}`}
title={element.name}
subtitle={`Lebensphase: ${phase.name}`}
onClose={close}
>
<ElementDetail
element={element}
context={buildPhaseContext(phase, element)}
phaseData={element.phaseValues[phase.id] ?? {}}
// Für die Übernahme von Raten über Phasen hinweg: alle Phasen und die dort
// bereits erfassten Werte (sie müssen beim Übertragen erhalten bleiben).
allPhases={plan.phases.map((p) => ({ id: p.id, sequenceNumber: p.sequenceNumber }))}
phaseDataByPhase={element.phaseValues}
transitionData={{}}
onSaved={closeAndRefresh}
onDeleteElement={() => {
setPanel(null);
deleteElement(element.id, element.name);
}}
/>
</InspectorShell>
);
}
case "transition": {
const element = plan.elements.find((e) => e.id === panel.elementId);
const fromPhase = computed.phases.find((p) => p.id === panel.fromPhaseId);
if (!element || !fromPhase) return null;
const toPhase = computed.phases[computed.phases.findIndex((p) => p.id === fromPhase.id) + 1];
return (
<TransitionCellPanel
plan={plan}
computed={computed}
key={`${panel.elementId}-${panel.fromPhaseId}`}
element={element}
fromPhase={fromPhase}
toPhase={toPhase}
context={buildTransitionContext(fromPhase, toPhase, element)}
onClose={close}
onSaved={closeAndRefresh}
/>
);
}
case "cashTransition": {
const fromPhase = computed.phases.find((p) => p.id === panel.fromPhaseId);
if (!fromPhase) return null;
const toPhase = computed.phases[computed.phases.findIndex((p) => p.id === fromPhase.id) + 1];
return (
<CashTransitionPanel
key={`ct-${panel.fromPhaseId}`}
fromPhase={fromPhase}
toPhase={toPhase}
initial={cashTransitionFor(fromPhase.id)}
onClose={close}
onSaved={closeAndRefresh}
/>
);
}
case "cashInitial":
return <CashInitialPanel key="cash-init" plan={plan} onClose={close} onSaved={closeAndRefresh} />;
case "profile":
return <ProfilePanel key="profile" plan={plan} onClose={close} onSaved={closeAndRefresh} />;
}
}
function transitionSummary(el: ElementInput, fromPhase: PhaseComputed, toPhase: PhaseComputed): string {
const td = el.transitionValues[fromPhase.id] ?? {};
switch (el.category) {
case "AHV":
return td.reviewed === true ? "Geprüft" : "?";
case "OTHER_ASSET":
return td.decision === "SELL"
? "Verkauf"
: td.decision === "PARTIAL"
? `Teilverkauf ${formatChf(num(td.partialSaleAmount))}`
: td.decision === "HOLD"
? "Halten"
: "?";
case "REAL_ESTATE":
if (td.decision === "SELL") return "Verkauf";
if (td.decision === undefined) return "?";
return num(td.extraAmortization) > 0 ? `Sonderamort. ${formatChf(num(td.extraAmortization))}` : "Halten";
case "PENSION_FUND":
if (isRetirementTransition(el, fromPhase, toPhase)) {
return td.payoutMode === "CAPITAL" ? "Kapital" : td.payoutMode === "COMBI" ? "Kombi" : td.payoutMode === "PENSION" ? "Rente" : "?";
}
if (td.withdrawalMode === undefined) return "?";
return td.withdrawalMode === "AMOUNT" && num(td.withdrawal) > 0 ? `${formatChf(num(td.withdrawal))}` : "Kein Bezug";
case "PILLAR_3A":
if (isRetirementTransition(el, fromPhase, toPhase)) return "Bezug";
if (td.withdrawalMode === undefined) return "?";
return td.withdrawalMode === "AMOUNT" && num(td.withdrawal) > 0 ? `${formatChf(num(td.withdrawal))}` : "Kein Bezug";
case "OTHER_DEBT":
return num(td.immediateRepayment) > 0 ? "Tilgung" : "→";
default:
return "→";
}
}
}
// Zellinhalt: Start- UND Zielwert für wertbehaftete Elemente, sonst die Kennzahl.
const START_END_CATEGORIES: ElementCategory[] = [...VALUE_CATEGORIES, "INCOME", "EXPENSE"];
type ValueMode = "nominal" | "both" | "real";
const VALUE_MODE_KEY = "fpt-value-mode";
// Realwert = nominal / kumulierte Inflation (Kaufkraft zum Planbeginn).
function realOf(nominal: number, deflator: number): number {
return Math.round(nominal / (deflator || 1));
}
// Reiner Text (fuer Titel/Tooltips und einzeilige Faelle).
function valStr(nominal: number, deflator: number, mode: ValueMode): string {
if (mode === "real") return formatChf(realOf(nominal, deflator));
if (mode === "both") return `${formatChf(nominal)} (${formatChf(realOf(nominal, deflator))})`;
return formatChf(nominal);
}
// Im Modus "Beide" steht der Realwert in Klammern in einer EIGENEN Zeile unter dem nominalen
// Wert -- nebeneinander wird die Zeile zu lang und die Spalten unnoetig breit. Der Pfeil
// wiederholt sich auf der zweiten Zeile, damit der Zeitbezug Start -> Ende erhalten bleibt.
function ValuePair({
start,
end,
deflatorStart,
deflatorEnd,
mode,
devEnd,
}: {
start: number;
end: number;
deflatorStart: number;
deflatorEnd: number;
mode: ValueMode;
// Abweichung des ENDwerts gegenüber dem Plan (nur in der Ist-Sicht gesetzt). Bewusst nur
// ein Wert statt Start und Ende: Die Zelle soll nicht zur zweiten Tabelle werden.
devEnd?: number | null;
}) {
const arrow = <span className="text-faint"></span>;
return (
<span className="inline-flex flex-col">
<span className="whitespace-nowrap tabular-nums">
{mode === "real" ? formatChf(realOf(start, deflatorStart)) : formatChf(start)} {arrow}{" "}
{mode === "real" ? formatChf(realOf(end, deflatorEnd)) : formatChf(end)}
<Deviation value={devEnd} deflator={mode === "real" ? deflatorEnd : 1} />
</span>
{mode === "both" && (
<span className="whitespace-nowrap tabular-nums text-faint">
({formatChf(realOf(start, deflatorStart))}) {arrow} ({formatChf(realOf(end, deflatorEnd))})
<Deviation value={devEnd} deflator={deflatorEnd} />
</span>
)}
</span>
);
}
// Die Abweichung trägt als einziges Element Farbe -- würde man die Beträge selbst einfärben,
// entstünde ein Ampelteppich, in dem nichts mehr heraussticht.
function Deviation({ value, deflator = 1 }: { value?: number | null; deflator?: number }) {
if (value == null || Math.round(value / (deflator || 1)) === 0) return null;
const v = Math.round(value / (deflator || 1));
return (
<span className={`ml-1.5 font-semibold ${v > 0 ? "text-success" : "text-danger"}`}>
{v > 0 ? "▲ +" : "▼ "}
{formatChf(Math.abs(v))}
</span>
);
}
// Einzelwert, gleiche Konvention.
function ValueSingle({ value, deflator, mode }: { value: number; deflator: number; mode: ValueMode }) {
return (
<span className="inline-flex flex-col">
<span className="whitespace-nowrap tabular-nums">
{mode === "real" ? formatChf(realOf(value, deflator)) : formatChf(value)}
</span>
{mode === "both" && (
<span className="whitespace-nowrap tabular-nums text-faint">({formatChf(realOf(value, deflator))})</span>
)}
</span>
);
}
function phaseCellContent(
ce: ReturnType<PhaseComputed["elements"]["find"]> | undefined,
phase: PhaseComputed,
mode: ValueMode,
// Endwert desselben Elements in derselben Phase laut PLAN -- null in der Plan-Sicht.
planEnd?: number | null
): React.ReactNode {
if (!ce) return "";
if (ce.note) return ce.note;
const isFlow = ce.category === "INCOME" || ce.category === "EXPENSE";
const dS = phase.cumulativeInflationStart;
const dE = isFlow ? phase.flowDeflatorEnd : phase.cumulativeInflationEnd;
const devEnd = planEnd == null ? null : ce.endValue - planEnd;
if (START_END_CATEGORIES.includes(ce.category) && (ce.startValue !== 0 || ce.endValue !== 0)) {
return (
<ValuePair start={ce.startValue} end={ce.endValue} deflatorStart={dS} deflatorEnd={dE} mode={mode} devEnd={devEnd} />
);
}
if ((ce.category === "AHV" || ce.category === "PENSION_FUND") && ce.startValue !== 0) {
return (
<span className="inline-flex flex-col">
<span className="whitespace-nowrap">Rente {mode === "real" ? formatChf(realOf(ce.startValue, dS)) : formatChf(ce.startValue)}</span>
{mode === "both" && (
<span className="whitespace-nowrap tabular-nums text-faint">({formatChf(realOf(ce.startValue, dS))})</span>
)}
</span>
);
}
return ce.summary || "";
}
function PhaseHeader({
phase,
personLabel,
mode,
diffKind,
onClick,
onExpand,
onDistributeCapital,
onDistributeRates,
onDelete,
active,
}: {
phase: PhaseComputed;
personLabel: (role: string) => string;
mode: ValueMode;
diffKind: "changed" | "added" | "removed" | null;
onClick: () => void;
onExpand: () => void;
// null = Phase 1: dort gibt es kein verteilbares Übergangs-Kapital (additionalInvestment
// wird in der ersten Phase von der Berechnung ignoriert, dort zählt der Startwert).
onDistributeCapital: (() => void) | null;
onDistributeRates: () => void;
// null = nicht die letzte Phase (nur die letzte lässt sich löschen).
onDelete: (() => void) | null;
active: boolean;
}) {
const quotaLabel = phase.isConsumption ? "Verzehrquote" : "Sparquote";
const pot = capitalPot(phase);
const dS = phase.cumulativeInflationStart;
const dE = phase.cumulativeInflationEnd; // Bestandswerte (Cash, Vermögen)
const dF = phase.flowDeflatorEnd; // Flow-Werte (Einkommen, Ausgaben, Quote)
return (
<th
onClick={onClick}
className={`group/ph sticky top-0 z-30 w-52 min-w-52 max-w-52 cursor-pointer border-b border-r border-border px-2 py-2 text-left align-top ${
active
? "bg-accent-soft"
: diffKind === "added"
? "bg-diff-added-soft"
: diffKind === "changed"
? "bg-diff-soft"
: "bg-surface"
}`}
>
<div className="flex items-center gap-1">
<span className="min-w-0 flex-1 truncate text-xs font-semibold text-fg">{phase.name}</span>
{diffKind === "added" && (
<span className="rounded bg-diff-added px-1 text-[9px] font-semibold uppercase text-white">neu</span>
)}
{phase.cashNegative ? (
<AlertCircle className="h-3.5 w-3.5 shrink-0 text-danger" />
) : (
<CheckCircle2 className="h-3.5 w-3.5 shrink-0 text-success" />
)}
{/* Aktionen zur Phase. Der Kopf selbst öffnet ebenfalls das Bearbeiten-Panel; die
Knöpfe müssen das Event deshalb stoppen, sonst gingen zwei Dinge gleichzeitig auf.
Bewusst IMMER sichtbar (Touch hat kein Hover). */}
<button
type="button"
aria-label={`${phase.name} bearbeiten`}
title="Bezeichnung und Dauer ändern"
onClick={(e) => {
e.stopPropagation();
onClick();
}}
className="shrink-0 rounded p-0.5 text-faint hover:bg-accent-soft hover:text-accent"
>
<Pencil className="h-3 w-3" />
</button>
{onDelete && (
<button
type="button"
aria-label={`${phase.name} löschen`}
title="Lebensphase löschen"
onClick={(e) => {
e.stopPropagation();
onDelete();
}}
className="shrink-0 rounded p-0.5 text-faint hover:bg-danger-soft hover:text-danger"
>
<Trash2 className="h-3 w-3" />
</button>
)}
<button
type="button"
aria-label={`Detailansicht ${phase.name}`}
title="Detailansicht (nur lesen)"
onClick={(e) => {
e.stopPropagation();
onExpand();
}}
className="shrink-0 rounded p-0.5 text-faint hover:bg-accent-soft hover:text-accent"
>
<Maximize2 className="h-3 w-3" />
</button>
</div>
<div className="mt-0.5 flex flex-wrap gap-1">
<span className="rounded bg-surface-2 px-1 text-[10px] text-muted">
{phase.type === "ERWERB" ? "Erwerb" : phase.type === "PENSION" ? "Pension" : "Misch"}
</span>
<span className="text-[10px] text-faint">{phase.durationYears} J.</span>
</div>
<div className="mt-1 flex flex-col gap-1 text-[10px] leading-tight text-muted">
<div>
{phase.persons.map((p) => (
<div key={p.role} className="whitespace-nowrap text-faint">
{personLabel(p.role)} {p.startAge} {p.endAge}
</div>
))}
</div>
{/* Verfügbares Kapital -- die Grösse, die man für die Planung DIESER Phase braucht.
Nur zeigen, wenn überhaupt etwas da ist. */}
{onDistributeCapital && pot.total > 0 && (
<div className="rounded-md bg-surface-2 px-1.5 py-1">
<div className="flex items-baseline justify-between gap-2 whitespace-nowrap">
<span className="font-medium text-fg">Verfügbares Kapital</span>
<ValueSingle value={pot.total} deflator={dS} mode={mode} />
</div>
{pot.allocatedInvestments + pot.allocatedRepayments > 0 && (
<div className="flex items-baseline justify-between gap-2 whitespace-nowrap pl-2 text-faint">
<span>davon verteilt</span>
<ValueSingle value={pot.allocatedInvestments + pot.allocatedRepayments} deflator={dS} mode={mode} />
</div>
)}
<div className="flex items-baseline justify-between gap-2 whitespace-nowrap pl-2 text-faint">
<span>Rest auf Cash</span>
<ValueSingle value={pot.rest} deflator={dS} mode={mode} />
</div>
<button
type="button"
onClick={(e) => {
e.stopPropagation();
onDistributeCapital();
}}
className="mt-1 w-full rounded border border-accent px-1.5 py-0.5 text-[10px] font-semibold text-accent transition-colors hover:bg-accent hover:text-accent-fg"
>
Kapital verteilen
</button>
</div>
)}
{/* Spar- bzw. Verzehrquote */}
<div className="rounded-md bg-surface-2 px-1.5 py-1">
<div className="flex items-baseline justify-between gap-2 whitespace-nowrap">
<span className={`font-medium ${phase.isConsumption ? "text-danger" : "text-fg"}`}>{quotaLabel}</span>
<ValuePair start={phase.quotaStart} end={phase.quotaEnd} deflatorStart={dS} deflatorEnd={dF} mode={mode} />
</div>
<button
type="button"
onClick={(e) => {
e.stopPropagation();
onDistributeRates();
}}
className="mt-1 w-full rounded border border-accent px-1.5 py-0.5 text-[10px] font-semibold text-accent transition-colors hover:bg-accent hover:text-accent-fg"
>
{phase.isConsumption ? "Bezug verteilen" : "Sparquote verteilen"}
</button>
</div>
<div className="flex items-baseline justify-between gap-2 whitespace-nowrap font-medium text-fg">
<span>Vermögen</span>
<ValuePair
start={phase.startWealthNominal}
end={phase.endWealthNominal}
deflatorStart={dS}
deflatorEnd={dE}
mode={mode}
/>
</div>
{(phase.oneOffInflow > 0 || phase.oneOffOutflow > 0) && (
<div className={`whitespace-nowrap ${phase.oneOffOutflow > phase.oneOffInflow ? "text-danger" : "text-success"}`}>
{phase.oneOffInflow > 0 ? `+ ${phase.oneOffInflowLabel ?? "Zufluss"}` : ""}
{phase.oneOffInflow > 0 && phase.oneOffOutflow > 0 ? " · " : ""}
{phase.oneOffOutflow > 0 ? ` ${phase.oneOffOutflowLabel ?? "Kosten"}` : ""}
</div>
)}
{/* Alles Weitere (Einkommen, Ausgaben, Raten im Detail, Kapitalinvestitionen) steht
in der Detailansicht -- erreichbar über das Expand-Icon oben. */}
</div>
</th>
);
}
function TransitionHeader({ counts, onClick }: { counts: DecisionCounts; onClick: () => void }) {
// Eine ungeprüfte Vorgabe ist kein vergessenes Eingabefeld -- sie wird deshalb eigens
// benannt und in gedeckterem Ton gezeigt (SPEZIFIKATION 3.5.3).
const done = counts.open === 0 && counts.unconfirmed === 0;
const onlyDefaults = counts.open === 0 && counts.unconfirmed > 0;
return (
<th
onClick={onClick}
data-tour="transition"
title={done ? undefined : decisionsText(counts)}
className={`sticky top-0 z-30 w-24 min-w-24 cursor-pointer border-b border-r border-border px-2 py-2 text-center align-top text-[11px] font-medium transition-colors ${
done
? "bg-surface-2 text-success"
: onlyDefaults
? "bg-surface-2 text-muted"
: "bg-attention text-attention-fg"
}`}
>
<div>Übergang</div>
{done ? (
<div className="mt-1 flex items-center justify-center gap-1 text-[10px] font-semibold">
<CheckCircle2 className="h-3 w-3" /> geprüft
</div>
) : (
<div className="mt-1 flex flex-col gap-0.5">
{counts.open > 0 && (
<div className="rounded-full bg-attention-fg/20 px-1.5 py-0.5 text-[10px] font-semibold">
{counts.open} offen
</div>
)}
{counts.unconfirmed > 0 && (
<div className="rounded-full border border-border px-1.5 py-0.5 text-[10px] font-semibold">
{counts.unconfirmed} Vorgabe{counts.unconfirmed === 1 ? "" : "n"}
</div>
)}
</div>
)}
</th>
);
}
function FragmentRows({ children }: { children: React.ReactNode }) {
return <>{children}</>;
}
// --- "Nächste Schritte": abgeleitete Checkliste ------------------------------------------
// Führt neue (und wiederkehrende) Nutzer zum jeweils nächsten sinnvollen Schritt, statt
// sie vor der Matrix raten zu lassen. Alles wird aus den vorhandenen Daten abgeleitet --
// kein eigener Zustand, kein API-Aufruf, verschwindet von selbst, wenn nichts offen ist.
function NextSteps({
plan,
computed,
columns,
openCountFor,
onReview,
onAddElement,
onAddPhase,
onOpenSensitivity,
}: {
plan: PlanInput;
computed: PlanComputed;
columns: Column[];
openCountFor: (fromPhase: PhaseComputed, toPhase: PhaseComputed) => DecisionCounts;
onReview: (fromPhaseId: string) => void;
onAddElement: (category: ElementCategory) => void;
onAddPhase: () => void;
onOpenSensitivity?: () => void;
}) {
if (computed.phases.length === 0) return null;
const items: { key: string; text: string; action: string; run: () => void }[] = [];
// Offene Übergangs-Entscheide (inkl. Cash) -- der wichtigste Handlungsbedarf.
let openTotal = 0;
let firstOpenPhaseId: string | null = null;
for (const col of columns) {
if (col.kind !== "transition") continue;
const n = openCountFor(col.fromPhase, col.toPhase);
openTotal += n.open;
if (n.open > 0 && firstOpenPhaseId === null) firstOpenPhaseId = col.fromPhase.id;
}
if (openTotal > 0 && firstOpenPhaseId) {
const target = firstOpenPhaseId;
items.push({
key: "transitions",
text: `${openTotal} Übergangs-Entscheid${openTotal === 1 ? "" : "e"} offen z. B. ob eine Immobilie verkauft wird.`,
action: "Jetzt durchgehen",
run: () => onReview(target),
});
}
if (!plan.elements.some((e) => e.category === "INCOME")) {
items.push({
key: "income",
text: "Noch kein Einkommen erfasst ohne Einkommen gibt es keine Sparquote und keine AHV-Rente.",
action: "Einkommen erfassen",
run: () => onAddElement("INCOME"),
});
}
if (!plan.elements.some((e) => e.category === "EXPENSE")) {
items.push({
key: "expense",
text: "Noch keine Ausgaben erfasst sie sind erfahrungsgemäss der stärkste Hebel der ganzen Planung.",
action: "Ausgaben erfassen",
run: () => onAddElement("EXPENSE"),
});
}
// Plan endet vor der Pensionierung: die spannendste Phase fehlt noch.
const totalYears = computed.phases.reduce((s, p) => s + p.durationYears, 0);
if (plan.persons.some((p) => p.age + totalYears < p.retirementAge)) {
items.push({
key: "pension-phase",
text: "Dein Plan endet vor der Pensionierung die entscheidende Phase fehlt noch.",
action: "Phase anlegen",
run: onAddPhase,
});
}
if (computed.ruinAge !== null && onOpenSensitivity) {
items.push({
key: "ruin",
text: `Das Kapital reicht nicht bis ans Planende (Ruin mit ${computed.ruinAge}). Finde heraus, welche Annahme am meisten bewegt.`,
action: "Einflussfaktoren",
run: onOpenSensitivity,
});
}
if (items.length === 0) return null;
return (
<div className="rounded-xl border border-attention-soft-fg/30 bg-attention-soft px-4 py-3">
<div className="mb-2 flex items-center gap-2 text-xs font-semibold uppercase tracking-wide text-attention-soft-fg">
<ListTodo className="h-4 w-4" /> Nächste Schritte
</div>
<ul className="flex flex-col gap-1.5">
{items.map((it) => (
<li key={it.key} className="flex flex-wrap items-center gap-2 text-sm text-fg">
<span className="min-w-0 flex-1">{it.text}</span>
<button
type="button"
onClick={it.run}
className="flex shrink-0 items-center gap-1 rounded-lg border border-attention-soft-fg/40 px-2.5 py-1 text-xs font-semibold text-attention-soft-fg transition-colors hover:bg-attention hover:text-attention-fg"
>
{it.action} <ArrowRight className="h-3 w-3" />
</button>
</li>
))}
</ul>
</div>
);
}
// --- Dialog: neues finanzielles Element mit Direkteingabe der Phase-1-Werte ---
function AddElementDialog({
plan,
firstPhase,
initialCategory,
onClose,
onCreated,
}: {
plan: PlanInput;
firstPhase: PhaseComputed;
initialCategory?: ElementCategory | null;
onClose: () => void;
onCreated: () => void;
}) {
const [category, setCategory] = useState<ElementCategory>(initialCategory ?? "INCOME");
const [name, setName] = useState("");
const [ownerRole, setOwnerRole] = useState<string>("PERSON_A");
const [pd, setPd] = useState<PhaseData>({});
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
const needsPerson = PERSON_ONLY_CATEGORIES.includes(category);
const isCouple = plan.householdType === "COUPLE";
const pLabel = (role: string) => {
const p = plan.persons.find((x) => x.role === role);
return p?.name && p.name.trim() ? p.name.trim() : role === "PERSON_A" ? "Person A" : "Person B";
};
const ownerOptions = needsPerson
? isCouple
? [
{ value: "PERSON_A", label: pLabel("PERSON_A") },
{ value: "PERSON_B", label: pLabel("PERSON_B") },
]
: [{ value: "PERSON_A", label: pLabel("PERSON_A") }]
: isCouple
? [
{ value: "HOUSEHOLD", label: "Gemeinsam" },
{ value: "PERSON_A", label: pLabel("PERSON_A") },
{ value: "PERSON_B", label: pLabel("PERSON_B") },
]
: [
{ value: "HOUSEHOLD", label: "Gemeinsam" },
{ value: "PERSON_A", label: pLabel("PERSON_A") },
];
// Kontext für die Phase-1-Felder des neuen Elements.
const owner = needsPerson || ownerRole !== "HOUSEHOLD" ? ownerRole : null;
const ownerWorking =
owner && owner !== "HOUSEHOLD"
? firstPhase.persons.find((p) => p.role === owner)?.working ?? false
: firstPhase.type !== "PENSION";
const context: CellContext = {
kind: "phase",
phaseId: firstPhase.id,
ownerWorking,
isConsumption: firstPhase.isConsumption,
durationYears: firstPhase.durationYears,
isRetirementTransition: false,
carriedEndValue: 0,
carried: false,
derivedStart: 0,
derivedMortgage: 0,
mortgageEnd: 0,
propertyValueEnd: 0,
deflatorStart: firstPhase.cumulativeInflationStart,
laterPhaseCount: 0, // beim Anlegen bewusst kein Warnhinweis
ahvCareer: null,
// Beim Anlegen gibt es keine Vorphase, aus der etwas zu erben waere.
inheritedValues: {},
ownerAgeStart: firstPhase.persons.find((p) => p.role === (owner ?? "PERSON_A"))?.startAge ?? 0,
investTargets: [],
capitalFromTransfer: 0,
capitalFromTransferSources: [],
};
async function create() {
setSaving(true);
setError(null);
try {
const { element } = await api.post<{ element: { id: string } }>(`/api/scenarios/${plan.id}/elements`, {
category,
name: name.trim() || CATEGORY_LABELS[category],
ownerRole,
});
// Ist-Zustand direkt in Phase 1 speichern (sofern Felder ausgefuellt).
if (Object.keys(pd).length > 0) {
await api.put(`/api/elements/${element.id}/phase/${firstPhase.id}`, pd);
}
onCreated();
} catch (e) {
setError(e instanceof Error ? e.message : "Erstellen fehlgeschlagen.");
} finally {
setSaving(false);
}
}
return (
<DialogShell title="Finanzielles Element" onClose={onClose} wide>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
<div>
<label className="mb-1 block text-xs font-medium text-muted">Kategorie</label>
<select
value={category}
onChange={(e) => {
const c = e.target.value as ElementCategory;
setCategory(c);
setPd({});
if (PERSON_ONLY_CATEGORIES.includes(c) && ownerRole === "HOUSEHOLD") setOwnerRole("PERSON_A");
if (!name) setName(CATEGORY_LABELS[c]);
}}
className="w-full rounded-lg border border-border bg-input px-3 py-2 text-sm text-fg focus:border-accent focus:outline-none focus:ring-2 focus:ring-accent/25"
>
{CATEGORY_ORDER.map((c) => (
<option key={c} value={c}>
{CATEGORY_LABELS[c]}
</option>
))}
</select>
</div>
<div>
<label className="mb-1 block text-xs font-medium text-muted">Zuordnung</label>
<select
value={ownerRole}
onChange={(e) => setOwnerRole(e.target.value)}
className="w-full rounded-lg border border-border bg-input px-3 py-2 text-sm text-fg focus:border-accent focus:outline-none focus:ring-2 focus:ring-accent/25"
>
{ownerOptions.map((o) => (
<option key={o.value} value={o.value}>
{o.label}
</option>
))}
</select>
</div>
<div className="sm:col-span-2">
<label className="mb-1 block text-xs font-medium text-muted">Bezeichnung</label>
<input
value={name}
onChange={(e) => setName(e.target.value)}
placeholder={CATEGORY_LABELS[category]}
className="w-full rounded-lg border border-border bg-input px-3 py-2 text-sm text-fg focus:border-accent focus:outline-none focus:ring-2 focus:ring-accent/25"
/>
</div>
</div>
<div className="mt-1 border-t border-border pt-3">
<div className="mb-2 text-xs font-semibold uppercase tracking-wide text-faint">Werte (erste Lebensphase)</div>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
<ElementPhaseFields
element={{ category }}
context={context}
pd={pd}
setP={(patch) => setPd((prev) => ({ ...prev, ...patch }))}
/>
</div>
</div>
{error && <p className="text-sm text-danger">{error}</p>}
<DialogActions saving={saving} onConfirm={create} onClose={onClose} confirmLabel="Erstellen" />
</DialogShell>
);
}
// --- Dialog: neue Lebensphase ---
function AddPhaseDialog({
maxDurationYears,
onClose,
onCreate,
}: {
maxDurationYears: number | null;
onClose: () => void;
onCreate: (payload: { name?: string; durationYears?: number }) => void;
}) {
const cap = maxDurationYears;
const [name, setName] = useState("");
const [durationYears, setDurationYears] = useState(cap ?? 10);
const [saving, setSaving] = useState(false);
return (
<DialogShell title="Neue Lebensphase" onClose={onClose}>
<div className="grid grid-cols-1 gap-3">
<div>
<label className="mb-1 block text-xs font-medium text-muted">Bezeichnung (optional)</label>
<input
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="automatisch (Erwerb/Pension)"
className="w-full rounded-lg border border-border bg-input px-3 py-2 text-sm text-fg focus:border-accent focus:outline-none focus:ring-2 focus:ring-accent/25"
/>
</div>
<div>
<label className="mb-1 block text-xs font-medium text-muted">
Dauer (Jahre){cap != null ? ` · max. ${cap}` : ""}
</label>
<input
type="number"
value={durationYears}
min={1}
max={cap ?? undefined}
onFocus={(e) => e.currentTarget.select()}
onChange={(e) => {
const v = e.target.valueAsNumber || 1;
setDurationYears(cap != null ? Math.min(v, cap) : v);
}}
className="w-full rounded-lg border border-border bg-input px-3 py-2 text-sm text-fg focus:border-accent focus:outline-none focus:ring-2 focus:ring-accent/25"
/>
</div>
</div>
<DialogActions
saving={saving}
onConfirm={() => {
setSaving(true);
onCreate({ name: name.trim() || undefined, durationYears });
}}
onClose={onClose}
confirmLabel="Erstellen"
/>
</DialogShell>
);
}
// Ziele für die Anlage-Quote der Kapitalverwendung (Roadmap Nr. 44, Punkt C): alle
// Vermögens-Elemente, die in der Folgephase noch aktiv sind.
// Der Name allein genuegt hier nicht: Zwei Personen nennen ihr Wertschriftendepot beide "ETF",
// und im Dropdown waehlt man dann blind das falsche -- was erst Phasen spaeter auffaellt.
function investTargetsOf(plan: PlanInput, toPhase: PhaseComputed | undefined): { id: string; name: string }[] {
return (toPhase?.elements ?? [])
.filter((e) => e.category === "OTHER_ASSET" && e.status === "ACTIVE")
.map((e) => {
const owner = plan.elements.find((x) => x.id === e.elementId)?.ownerRole ?? null;
return { id: e.elementId, name: `${e.name} · ${ownerLabel(plan.persons, owner)}` };
});
}
// --- Panel: Szenario-Profil (Grundprofil bearbeiten) ---
function ProfilePanel({ plan, onClose, onSaved }: { plan: PlanInput; onClose: () => void; onSaved: () => void }) {
const [draft, setDraft] = useState<ProfileDraft>({
householdType: plan.householdType,
inflationRateDefault: plan.inflationRateDefault,
startYear: plan.startYear ?? new Date().getFullYear(),
persons: plan.persons.map((p) => ({ role: p.role, name: p.name ?? "", age: p.age, retirementAge: p.retirementAge })),
});
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
async function save() {
setSaving(true);
setError(null);
try {
await api.patch(`/api/scenarios/${plan.id}`, draft);
onSaved();
} catch (e) {
setError(e instanceof Error ? e.message : "Speichern fehlgeschlagen.");
} finally {
setSaving(false);
}
}
return (
<InspectorShell title="Szenario-Profil" subtitle="Haushalt, Personen, Planstart und Inflation dieses Szenarios" onClose={onClose}>
<div className="flex flex-col gap-3">
<PlanProfileFields draft={draft} onChange={setDraft} lockRetirement={plan.phases.length > 0} />
{error && <p className="text-sm text-danger">{error}</p>}
<div className="flex gap-2 pt-1">
<Button disabled={saving} onClick={save}>{saving ? "…" : "Speichern"}</Button>
<Button variant="secondary" onClick={onClose}>Abbrechen</Button>
</div>
{plan.phases.length > 0 && (
<div className="mt-3 border-t border-border pt-3">
<h3 className="mb-2 text-sm font-semibold text-fg">Pensionsalter anpassen</h3>
{/* Wirkt SOFORT (eigener Endpunkt), weil dabei Phasendauern verschoben werden --
das lässt sich nicht sinnvoll mit dem Profil-Formular zusammen abspeichern. */}
<RetirementAdjuster plan={plan} onSaved={onSaved} />
</div>
)}
</div>
</InspectorShell>
);
}
// --- Dialog: geführter Übergang ---
// Pensionsalter des Element-Besitzers -- Bezugspunkt fuer die Vorgaben des Entscheids.
function retirementAgeOf(plan: PlanInput, el: ElementInput): number {
return plan.persons.find((p) => p.role === el.ownerRole)?.retirementAge ?? AHV_REFERENCE_AGE;
}
function TransitionReviewDialog({
plan,
computed,
fromPhase,
toPhase,
elements,
initialCash,
buildContext,
isRetirement,
onClose,
onSaved,
}: {
plan: PlanInput;
computed: PlanComputed;
fromPhase: PhaseComputed;
toPhase: PhaseComputed | undefined;
elements: ElementInput[];
initialCash: CashTransitionData;
buildContext: (el: ElementInput) => CellContext;
isRetirement: (el: ElementInput) => boolean;
onClose: () => void;
onSaved: () => void;
}) {
const [tds, setTds] = useState<Record<string, TransitionData>>(() =>
Object.fromEntries(
elements.map((e) => [e.id, withTransitionDefaults(e.category, isRetirement(e), e.transitionValues[fromPhase.id] ?? {})])
)
);
const [ct, setCt] = useState<CashTransitionData>(() => withCashTransitionDefaults(initialCash));
// Pensionierungs-Entscheide: eigener Entwurf, weil sie an einem anderen Ort liegen als die
// Übergangswerte -- und beim Speichern über einen eigenen Endpunkt gehen.
const [rds, setRds] = useState<Record<string, RetirementDecision>>({});
const effectiveRd = (el: ElementInput) =>
withRetirementDefaults(el.category, retirementAgeOf(plan, el), {
...(el.retirementDecision ?? {}),
...(rds[el.id] ?? {}),
});
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
async function saveAll() {
setSaving(true);
setError(null);
try {
await api.put(`/api/phases/${fromPhase.id}/cash-transition`, ct);
for (const e of elements) {
await api.put(`/api/elements/${e.id}/transition/${fromPhase.id}`, tds[e.id] ?? {});
}
// Pensionierungs-Entscheide liegen am ELEMENT und haben deshalb einen eigenen Endpunkt.
for (const [elementId, changes] of Object.entries(rds)) {
const el = plan.elements.find((x) => x.id === elementId);
await api.put(`/api/elements/${elementId}/retirement`, { ...(el?.retirementDecision ?? {}), ...changes });
}
onSaved();
} catch (err) {
setError(err instanceof Error ? err.message : "Speichern fehlgeschlagen.");
} finally {
setSaving(false);
}
}
return (
<DialogShell title={`Übergang prüfen: ${fromPhase.name}${toPhase?.name ?? "Ende"}`} onClose={onClose} wide>
<p className="text-sm text-muted">
Geh die Positionen durch und triff je Element den Übergangs-Entscheid (Halten, Verkaufen,
Bezug). Danach werden gehaltene Werte automatisch in die nächste Phase fortgeschrieben.
</p>
<div className="flex flex-col gap-3">
{/* Cash zuerst: einmalige Sonderein-/ausgaben betreffen jeden Übergang. */}
<div className="rounded-xl border border-border bg-surface-2 p-3">
<div className="mb-2 flex items-center gap-2">
<span className="text-accent">
<Wallet className="h-4 w-4" />
</span>
<span className="text-sm font-semibold text-fg">Cash</span>
<span className="text-xs text-faint">Einmalige Sonderein-/ausgaben</span>
</div>
<p className="mb-2 text-xs text-accent-soft-fg">
Einmalige Ereignisse wie Erbschaft, Autokauf oder Poolbau werden hier direkt dem Cash-Konto
gutgeschrieben bzw. belastet.
</p>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
<CashTransitionFields
ct={ct}
setC={(patch) => setCt((prev) => ({ ...prev, ...patch }))}
deflatorEnd={fromPhase.cumulativeInflationEnd}
/>
</div>
</div>
{elements.length === 0 && (
<p className="rounded-lg border border-dashed border-border bg-surface-2 p-3 text-sm text-muted">
An diesem Übergang gibt es sonst keine zu entscheidenden Positionen.
</p>
)}
{elements.map((el) => {
const ctx = buildContext(el);
const retire = isRetirement(el);
const hint =
el.category === "AHV" && retire
? "Pensionierung: Beitragskarriere prüfen die Rente hängt vom Durchschnittseinkommen ab."
: (el.category === "PENSION_FUND" || el.category === "PILLAR_3A") && !retire
? "Hier könntest du optional Kapital beziehen."
: el.category === "PENSION_FUND" && retire
? "Pensionierung: Bezugsart wählen (Rente / Kapital / Kombination)."
: el.category === "PILLAR_3A" && retire
? "Wird bei Pensionierung vollständig bezogen."
: null;
return (
<div key={el.id} className="rounded-xl border border-border bg-surface-2 p-3">
<div className="mb-2 flex items-center gap-2">
<span className="text-accent">{CATEGORY_ICON[el.category]}</span>
<span className="text-sm font-semibold text-fg">{el.name}</span>
<span className="text-xs text-faint">{CATEGORY_LABELS[el.category]}</span>
</div>
{hint && <p className="mb-2 text-xs text-accent-soft-fg">{hint}</p>}
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
<ElementTransitionFields
element={el}
context={ctx}
td={tds[el.id] ?? {}}
setT={(patch) => setTds((prev) => ({ ...prev, [el.id]: { ...prev[el.id], ...patch } }))}
retirement={
<RetirementFields
plan={plan}
el={el}
defaultOpen
rd={effectiveRd(el)}
patch={(id, patch) => setRds((prev) => ({ ...prev, [id]: { ...prev[id], ...patch } }))}
summary={computed.retirement.perPerson.find((x) => x.role === el.ownerRole)}
/>
}
/>
</div>
</div>
);
})}
</div>
{error && <p className="text-sm text-danger">{error}</p>}
<DialogActions saving={saving} onConfirm={saveAll} onClose={onClose} confirmLabel="Alle speichern" />
</DialogShell>
);
}
// --- Panel: Cash-Übergang (einmalige Sonderein-/ausgaben) ---
function CashTransitionPanel({
fromPhase,
toPhase,
initial,
onClose,
onSaved,
}: {
fromPhase: PhaseComputed;
toPhase: PhaseComputed | undefined;
initial: CashTransitionData;
onClose: () => void;
onSaved: () => void;
}) {
// Vorbelegung, damit ein blosses "Speichern" den sichtbaren Default (1:1) persistiert.
const [ct, setCt] = useState<CashTransitionData>(() => withCashTransitionDefaults(initial));
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
async function save() {
setSaving(true);
setError(null);
try {
await api.put(`/api/phases/${fromPhase.id}/cash-transition`, ct);
onSaved();
} catch (e) {
setError(e instanceof Error ? e.message : "Speichern fehlgeschlagen.");
} finally {
setSaving(false);
}
}
return (
<InspectorShell
title="Übergang: Cash"
subtitle={`Einmalige Sonderein-/ausgaben · ${fromPhase.name}${toPhase?.name ?? "Ende"}`}
onClose={onClose}
>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
<CashTransitionFields
ct={ct}
setC={(patch) => setCt((prev) => ({ ...prev, ...patch }))}
deflatorEnd={fromPhase.cumulativeInflationEnd}
/>
</div>
{error && <p className="mt-3 text-sm text-danger">{error}</p>}
<PanelActions saving={saving} onConfirm={save} onClose={onClose} confirmLabel="Speichern" />
</InspectorShell>
);
}
// --- Panel: Cash-Anfangswert (erste Lebensphase) ---
function CashInitialPanel({ plan, onClose, onSaved }: { plan: PlanInput; onClose: () => void; onSaved: () => void }) {
const [value, setValue] = useState(plan.initialCash);
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
async function save() {
setSaving(true);
setError(null);
try {
await api.patch(`/api/scenarios/${plan.id}`, { initialCash: value });
onSaved();
} catch (e) {
setError(e instanceof Error ? e.message : "Speichern fehlgeschlagen.");
} finally {
setSaving(false);
}
}
return (
<InspectorShell
title="Cash-Anfangswert"
subtitle="Startbestand des Ausgleichskontos zu Beginn der ersten Lebensphase"
onClose={onClose}
>
<div className="flex flex-col gap-3">
<MoneyField label="Anfangswert (CHF)" value={value} onChange={setValue} />
{error && <p className="text-sm text-danger">{error}</p>}
<PanelActions saving={saving} onConfirm={save} onClose={onClose} confirmLabel="Speichern" />
</div>
</InspectorShell>
);
}
// --- Panel: einzelner Übergangs-Entscheid (per Klick auf eine Übergangszelle) ---
function TransitionCellPanel({
plan,
computed,
element,
fromPhase,
toPhase,
context,
onClose,
onSaved,
}: {
plan: PlanInput;
computed: PlanComputed;
element: ElementInput;
fromPhase: PhaseComputed;
toPhase: PhaseComputed | undefined;
context: CellContext;
onClose: () => void;
onSaved: () => void;
}) {
const [td, setTd] = useState<TransitionData>(() =>
withTransitionDefaults(element.category, context.isRetirementTransition, element.transitionValues[fromPhase.id] ?? {})
);
const [rd, setRd] = useState<RetirementDecision>({});
const effectiveRd = (el: ElementInput) =>
withRetirementDefaults(el.category, retirementAgeOf(plan, el), {
...(el.retirementDecision ?? {}),
...rd,
});
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
async function save() {
setSaving(true);
setError(null);
try {
await api.put(`/api/elements/${element.id}/transition/${fromPhase.id}`, td);
if (Object.keys(rd).length > 0) {
await api.put(`/api/elements/${element.id}/retirement`, {
...(element.retirementDecision ?? {}),
...rd,
});
}
onSaved();
} catch (e) {
setError(e instanceof Error ? e.message : "Speichern fehlgeschlagen.");
} finally {
setSaving(false);
}
}
return (
<InspectorShell
title={`Übergang: ${element.name}`}
subtitle={`${CATEGORY_LABELS[element.category]} · ${fromPhase.name}${toPhase?.name ?? "Ende"}`}
onClose={onClose}
>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
<ElementTransitionFields
element={element}
context={context}
td={td}
setT={(patch) => setTd((prev) => ({ ...prev, ...patch }))}
retirement={
<RetirementFields
plan={plan}
el={element}
defaultOpen
rd={effectiveRd(element)}
patch={(_id: string, patch: Partial<RetirementDecision>) =>
setRd((prev) => ({ ...prev, ...patch }))
}
summary={computed.retirement.perPerson.find((x) => x.role === element.ownerRole)}
/>
}
/>
</div>
{error && <p className="mt-3 text-sm text-danger">{error}</p>}
<PanelActions saving={saving} onConfirm={save} onClose={onClose} confirmLabel="Speichern" />
</InspectorShell>
);
}
// --- gemeinsame Bausteine ---
// Erstell-Flows und der geführte Übergang bleiben Modals (ui.Modal: ESC, Fokus-Falle,
// Animation); Einzel-Bearbeitungen laufen über das Inspector-Panel.
function DialogShell({
title,
onClose,
children,
wide,
}: {
title: string;
onClose: () => void;
children: React.ReactNode;
wide?: boolean;
}) {
return (
<Modal title={title} onClose={onClose} wide={wide}>
{children}
</Modal>
);
}
function DialogActions({
saving,
onConfirm,
onClose,
confirmLabel,
}: {
saving: boolean;
onConfirm: () => void;
onClose: () => void;
confirmLabel: string;
}) {
return (
<div className="flex gap-2 pt-1">
<Button disabled={saving} onClick={onConfirm}>
{saving ? "…" : confirmLabel}
</Button>
<Button variant="secondary" onClick={onClose}>
Abbrechen
</Button>
</div>
);
}
// Aktionsleiste im Inspector-Panel.
function PanelActions({
saving,
onConfirm,
onClose,
confirmLabel,
}: {
saving: boolean;
onConfirm: () => void;
onClose: () => void;
confirmLabel: string;
}) {
return (
<div className="mt-4 flex gap-2 border-t border-border pt-3">
<Button disabled={saving} onClick={onConfirm}>
{saving ? "…" : confirmLabel}
</Button>
<Button variant="secondary" onClick={onClose}>
Abbrechen
</Button>
</div>
);
}
// --- Panel: Element umbenennen und zuordnen ----------------------------------------------
// Beides sind Eigenschaften des ELEMENTS, nicht einer Phase -- deshalb ein eigenes Panel,
// erreichbar über das Stift-Symbol in der Element-Zeile.
function ElementMetaPanel({
element,
plan,
personLabel,
onSaved,
onDelete,
}: {
element: ElementInput;
plan: PlanInput;
personLabel: (role: string) => string;
onSaved: () => void;
onDelete: () => void;
}) {
const [name, setName] = useState(element.name);
const [ownerRole, setOwnerRole] = useState<string>(element.ownerRole ?? "HOUSEHOLD");
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
const needsPerson = PERSON_ONLY_CATEGORIES.includes(element.category);
const isCouple = plan.householdType === "COUPLE";
const options = needsPerson
? isCouple
? [
{ value: "PERSON_A", label: personLabel("PERSON_A") },
{ value: "PERSON_B", label: personLabel("PERSON_B") },
]
: [{ value: "PERSON_A", label: personLabel("PERSON_A") }]
: isCouple
? [
{ value: "HOUSEHOLD", label: "Gemeinsam" },
{ value: "PERSON_A", label: personLabel("PERSON_A") },
{ value: "PERSON_B", label: personLabel("PERSON_B") },
]
: [
{ value: "HOUSEHOLD", label: "Gemeinsam" },
{ value: "PERSON_A", label: personLabel("PERSON_A") },
];
async function save() {
setSaving(true);
setError(null);
try {
await api.patch(`/api/elements/${element.id}`, { name: name.trim() || element.name, ownerRole });
onSaved();
} catch (e) {
setError(e instanceof Error ? e.message : "Speichern fehlgeschlagen.");
} finally {
setSaving(false);
}
}
return (
<div className="flex flex-col gap-4">
<div className="flex items-center justify-between">
<div className="text-xs font-semibold uppercase tracking-wide text-accent-soft-fg">
{CATEGORY_LABELS[element.category]}
</div>
<button
type="button"
onClick={onDelete}
className="flex items-center gap-1 rounded-lg border border-border px-2 py-1 text-xs text-muted hover:border-danger hover:bg-danger-soft hover:text-danger"
>
<Trash2 className="h-3.5 w-3.5" /> Element löschen
</button>
</div>
<TextField label="Bezeichnung" value={name} onChange={setName} />
<SelectField
label="Zuordnung"
help={
needsPerson
? "AHV, Pensionskasse und Säule 3a hängen am individuellen Pensionierungszeitpunkt und gehören deshalb immer genau einer Person."
: "Gemeinsame Elemente zählen zum Haushalt. Bei Paaren wird ein gemeinsames Einkommen NICHT für die AHV-Rente angerechnet."
}
value={ownerRole}
onChange={setOwnerRole}
options={options}
/>
<p className="text-xs text-faint">
Die Werte je Lebensphase bearbeitest du direkt in den Zellen der Matrix.
</p>
{error && <p className="text-sm text-danger">{error}</p>}
<div className="flex gap-2">
<Button disabled={saving} onClick={save}>
{saving ? "Speichern…" : "Speichern"}
</Button>
</div>
</div>
);
}