Files
FPT/src/components/PlanView.tsx
T
admGitAICDS 4791dccf93
Deploy App / deploy (push) Successful in 57s
Detailansichten, Wasserfaelle und vollstaendige Rechenweg-Offenlegung
Roadmap Nr. 43 (Detailansichten) und Nr. 41 (Berechnungslogiken offenlegen).

Systemparameter-Ansicht:
- SYSTEM_PARAMETERS in constants.ts: Wert, Bedeutung, Herleitung, Quelle,
  Stand -- aus derselben Datei, aus der gerechnet wird

Detailansichten (nur lesen, per Expand-Icon):
- Element: Verlauf ueber ALLE Planjahre (neu ElementPhaseComputed.yearly),
  bei Immobilien Verkehrswert/Restschuld/Eigenkapital getrennt
- Phase: Vermoegensaufteilung + zwei Wasserfaelle

Zwei getrennte Wasserfaelle (WealthBridge / CashBridge):
- Sparraten, Amortisationen und Investitionen sind UMBUCHUNGEN und erscheinen
  nur im Cash-Wasserfall -- als Vermoegensabgang gezeichnet wuerden sie einen
  Verlust vortaeuschen, den es nicht gibt
- PK-Beitraege dagegen sind ein echter Vermoegenszugang (belasten kein Cash),
  Verrentung ein echter Abgang (Kapital verlaesst die Bilanz)
- residual als Kontrollgroesse fuer die Vollstaendigkeit der Zerlegung

Rechenweg-Protokoll, vollstaendige Abdeckung:
- computePlan(plan, sample?, { explain }) protokolliert die Schritte, die es
  ohnehin ausfuehrt -- die Erklaerung IST die Rechnung, statt einer zweiten
  Formel-Implementierung im UI, die still abdriften koennte
- standardmaessig aus (Monte Carlo bleibt unberuehrt)
- Arithmetik nicht umgestellt: Zwischengroessen werden als Differenz
  abgeleitet, damit die 43 Golden Tests bitgleich bleiben
- jeder Trace verlinkt in die SPEZIFIKATION; ein Test prueft gegen die echte
  Datei, dass alle Verweise eine existierende Ueberschrift treffen

SPEZIFIKATION auf 0.11: neue Kapitel 3.6.7, 3.6.8, 4.14, 9.20, 9.21.
12 Tests ergaenzt (80 -> 92). Keine DB-Aenderung.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-18 17:12:55 +02:00

1637 lines
64 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,
Building2,
CheckCircle2,
ChevronDown,
ChevronRight,
CreditCard,
Home,
Landmark,
Maximize2,
PiggyBank,
Plus,
Settings2,
ShoppingCart,
TrendingUp,
Wallet,
X,
} from "lucide-react";
import { Timeline } from "@/components/Timeline";
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 { MoneyField } from "@/components/FormField";
import { api } from "@/lib/api-client";
import { formatChf } from "@/lib/format";
import {
CATEGORY_LABELS,
CATEGORY_ORDER,
PERSON_ONLY_CATEGORIES,
num,
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 Uebergangs-Entscheid. AHV ist dabei ein Sonderfall: nur beim
// Pensions-Uebergang ist die Beitragskarriere zu pruefen (siehe transitionInactive).
const TRANSITION_CATEGORIES: ElementCategory[] = [
"AHV",
"PENSION_FUND",
"PILLAR_3A",
"REAL_ESTATE",
"OTHER_ASSET",
"OTHER_DEBT",
];
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 };
type Selection =
| { type: "phase"; phaseId: string };
export function PlanView({
plan,
computed,
diff,
onChanged,
onOpenSpec,
}: {
plan: PlanInput;
computed: PlanComputed;
// Abweichungen gegenueber dem Eltern-Szenario; null im Basisszenario (nichts zu markieren).
diff: ScenarioDiff | null;
onChanged: () => void;
onOpenSpec?: (anchor: string) => void;
}) {
// Markierungs-Klassen: geaendert = gelb, neu = gruen, 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 [selected, setSelected] = useState<Selection | null>(null);
const [collapsedCats, setCollapsedCats] = useState<Set<ElementCategory>>(new Set());
const [showAdd, setShowAdd] = useState(false);
const [showAddPhase, setShowAddPhase] = useState(false);
const [showSettings, setShowSettings] = useState(false);
const [reviewFromPhaseId, setReviewFromPhaseId] = useState<string | null>(null);
const [editTransition, setEditTransition] = useState<{ elementId: string; fromPhaseId: string } | null>(null);
const [editPhaseCell, setEditPhaseCell] = useState<{ elementId: string; phaseId: string } | null>(null);
const [showCashInit, setShowCashInit] = useState(false);
// fromPhaseId des Cash-Uebergangs, der gerade bearbeitet wird.
const [editCashTransition, setEditCashTransition] = useState<string | null>(null);
const [valueMode, setValueMode] = useState<ValueMode>("nominal");
// Nur-Lese-Detailansicht (Roadmap Nr. 43). Der Rechenweg wird erst beim Oeffnen erzeugt.
const [detailFor, setDetailFor] = useState<{ kind: "element"; id: string } | { kind: "phase"; id: string } | null>(null);
// Erklaerte Berechnung: bewusst NUR wenn eine Detailansicht offen ist. computePlan ist rein
// und laeuft im Browser -- es braucht dafuer weder einen API-Aufruf noch eine groessere
// 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 Uebernahme 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);
}
function isRetirementTransition(element: ElementInput, fromPhase: PhaseComputed, toPhase: PhaseComputed): boolean {
if (!element.ownerRole || element.ownerRole === "HOUSEHOLD") return false;
const before = fromPhase.persons.find((p) => p.role === element.ownerRole);
const after = toPhase.persons.find((p) => p.role === element.ownerRole);
return !!before?.working && !!after && !after.working;
}
// Beitragskarriere des Element-Besitzers (nur fuer 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 (fuer den Fortschreibungs-Warnhinweis).
function laterPhaseCount(phase: PhaseComputed): number {
return computed.phases.length - phase.sequenceNumber;
}
// Baut den Kontext fuer 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,
deflatorStart: phase.cumulativeInflationStart,
laterPhaseCount: laterPhaseCount(phase),
ahvCareer: careerFor(element),
};
}
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,
deflatorStart: fromPhase.cumulativeInflationStart,
laterPhaseCount: laterPhaseCount(fromPhase),
ahvCareer: careerFor(element),
};
}
// Am Uebergang 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-Uebergangs.
function transitionInactive(el: ElementInput, fromPhase: PhaseComputed, toPhase?: PhaseComputed): boolean {
const ce = computedElement(fromPhase.id, el.id);
if (ce && ce.status !== "ACTIVE") return true;
if (el.category === "AHV") {
return !(toPhase && isRetirementTransition(el, fromPhase, toPhase));
}
if (el.category === "PENSION_FUND" || el.category === "PILLAR_3A") {
if (el.ownerRole && el.ownerRole !== "HOUSEHOLD") {
const owner = fromPhase.persons.find((p) => p.role === el.ownerRole);
if (owner && !owner.working) return true;
}
}
return false;
}
function cashTransitionFor(phaseId: string): CashTransitionData {
return plan.phases.find((p) => p.id === phaseId)?.cashTransition ?? {};
}
// Anzahl offener (noch nicht getroffener) Uebergangs-Entscheide an einer Grenze.
// Der Cash-Entscheid (einmalige Sonderein-/ausgaben) zaehlt mit.
function transitionOpenCount(fromPhase: PhaseComputed, toPhase: PhaseComputed): number {
let n = isCashTransitionAnswered(cashTransitionFor(fromPhase.id)) ? 0 : 1;
for (const el of plan.elements) {
if (!TRANSITION_CATEGORIES.includes(el.category)) continue;
if (transitionInactive(el, fromPhase, toPhase)) continue;
const td = el.transitionValues[fromPhase.id] ?? {};
const retire = toPhase ? isRetirementTransition(el, fromPhase, toPhase) : false;
if (!isTransitionAnswered(el.category, retire, td)) n++;
}
return n;
}
// Ist der Uebergangs-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();
}
const hasPhases = computed.phases.length > 0;
const firstPhase = computed.phases[0] ?? null;
return (
<div className="flex flex-col gap-5">
<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>
</div>
<Timeline
phases={computed.phases}
persons={personAxes}
ruinAge={computed.ruinAge}
startYear={plan.startYear}
/>
{/* Plan-Profil */}
<div
className={`flex flex-wrap items-center gap-3 rounded-xl border px-4 py-3 text-sm shadow-sm ${
diff?.profileChanged ? "border-diff bg-diff-soft" : "border-border bg-surface"
}`}
>
<span className="text-xs font-semibold uppercase tracking-wide text-faint">
Grundprofil (Szenario){diff?.profileChanged ? " · abweichend" : ""}
</span>
{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>
<button
type="button"
onClick={() => setShowSettings(true)}
className="ml-auto flex items-center gap-1.5 rounded-lg border border-border px-2.5 py-1 text-xs font-medium text-muted hover:bg-surface-2"
>
<Settings2 className="h-3.5 w-3.5" /> Einstellungen
</button>
</div>
{!hasPhases && (
<div className="rounded-xl border border-dashed border-border bg-surface p-8 text-center">
<p className="text-sm text-muted">Dieser Plan hat noch keine Lebensphasen.</p>
<button
type="button"
onClick={() => setShowAddPhase(true)}
className="mt-3 inline-flex items-center gap-1.5 rounded-lg bg-accent px-4 py-2 text-sm font-medium text-accent-fg hover:bg-accent-hover"
>
<Plus className="h-4 w-4" /> Erste Lebensphase
</button>
</div>
)}
{hasPhases && (
<div className="flex flex-wrap items-center gap-2">
<button
type="button"
onClick={() => setShowAdd(true)}
className="flex items-center gap-1.5 rounded-lg bg-accent px-3 py-1.5 text-sm font-medium text-accent-fg shadow-sm hover:bg-accent-hover"
>
<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 hover:bg-accent-soft"
>
<Plus className="h-4 w-4" /> Lebensphase
</button>
</div>
)}
{computed.ruinAge !== null && (
<div className="flex 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" /> Kapital aufgebraucht mit Alter {computed.ruinAge} das Gesamtvermoegen (inkl. Cash) faellt danach unter 0.
</div>
)}
{/* Matrix */}
{hasPhases && (
<div className="overflow-x-auto rounded-xl border border-border bg-surface shadow-sm">
<table className="w-full border-collapse text-sm">
<thead>
<tr>
<th className="sticky left-0 z-20 min-w-44 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={() => setSelected({ type: "phase", phaseId: col.phase.id })}
onExpand={() => setDetailFor({ kind: "phase", id: col.phase.id })}
active={selected?.type === "phase" && selected.phaseId === col.phase.id}
/>
) : (
<TransitionHeader
key={`t-${col.fromPhase.id}`}
openCount={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 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">verfuegbares 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 ? () => setShowCashInit(true) : 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>
) : (
(() => {
// Uebergangszelle 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={() => setEditCashTransition(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-accent font-semibold text-accent-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>
<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>
)}
<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 opacity-0 hover:bg-accent-soft hover:text-accent group-hover/row:opacity-100"
>
<Maximize2 className="h-3 w-3" />
</button>
</div>
{el.ownerRole && el.ownerRole !== "HOUSEHOLD" && (
<div className="text-[10px] text-faint">{personLabel(el.ownerRole)}</div>
)}
</td>
{columns.map((col) => {
if (col.kind === "phase") {
const ce = computedElement(col.phase.id, el.id);
return (
<td
key={col.phase.id}
onClick={() => setEditPhaseCell({ 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)}
</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 &&
setEditTransition({ 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 bleiben in Akzentfarbe; sonst gewinnt die Abweichungs-Markierung.
open
? "bg-accent font-semibold text-accent-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 geloescht. */}
{(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. Fuegen Sie oben Ihr erstes Element hinzu.
</td>
</tr>
)}
</tbody>
</table>
</div>
)}
{/* Lebensphase bearbeiten -- als Popup, konsistent zu allen anderen Eingaben.
Der key erzwingt beim Wechsel der Phase einen Neuaufbau, damit der lokale
Formular-Zustand nicht von der vorher geoeffneten Phase uebrig bleibt. */}
{selected && (() => {
const phase = computed.phases.find((p) => p.id === selected.phaseId);
if (!phase) return null;
return (
<DialogShell key={`ph-${selected.phaseId}`} title={`Lebensphase: ${phase.name}`} onClose={() => setSelected(null)}>
{renderDetail()}
</DialogShell>
);
})()}
{showAdd && firstPhase && (
<AddElementDialog
plan={plan}
firstPhase={firstPhase}
onClose={() => setShowAdd(false)}
onCreated={() => {
setShowAdd(false);
onChanged();
}}
/>
)}
{showAddPhase && (
<AddPhaseDialog
maxDurationYears={nextPhaseCap()}
onClose={() => setShowAddPhase(false)}
onCreate={handleAddPhase}
/>
)}
{showSettings && (
<PlanSettingsDialog
plan={plan}
onClose={() => setShowSettings(false)}
onSaved={() => {
setShowSettings(false);
onChanged();
}}
/>
)}
{showCashInit && (
<CashInitialDialog
plan={plan}
onClose={() => setShowCashInit(false)}
onSaved={() => {
setShowCashInit(false);
onChanged();
}}
/>
)}
{editCashTransition && (() => {
const fromPhase = computed.phases.find((p) => p.id === editCashTransition);
if (!fromPhase) return null;
const toIndex = computed.phases.findIndex((p) => p.id === fromPhase.id) + 1;
const toPhase = computed.phases[toIndex];
return (
<CashTransitionDialog
key={`ct-${editCashTransition}`}
fromPhase={fromPhase}
toPhase={toPhase}
initial={cashTransitionFor(fromPhase.id)}
onClose={() => setEditCashTransition(null)}
onSaved={() => {
setEditCashTransition(null);
onChanged();
}}
/>
);
})()}
{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
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();
}}
/>
);
})()}
{editTransition && (() => {
const element = plan.elements.find((e) => e.id === editTransition.elementId);
const fromPhase = computed.phases.find((p) => p.id === editTransition.fromPhaseId);
if (!element || !fromPhase) return null;
const toIndex = computed.phases.findIndex((p) => p.id === fromPhase.id) + 1;
const toPhase = computed.phases[toIndex];
const context = buildTransitionContext(fromPhase, toPhase, element);
return (
<TransitionCellDialog
key={`${editTransition.elementId}-${editTransition.fromPhaseId}`}
element={element}
fromPhase={fromPhase}
toPhase={toPhase}
context={context}
onClose={() => setEditTransition(null)}
onSaved={() => {
setEditTransition(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}
isFirst={ph.sequenceNumber === 1}
onClose={() => setDetailFor(null)}
onOpenSpec={onOpenSpec}
/>
);
})()}
{editPhaseCell && (() => {
const element = plan.elements.find((e) => e.id === editPhaseCell.elementId);
const phase = computed.phases.find((p) => p.id === editPhaseCell.phaseId);
if (!element || !phase) return null;
const context = buildPhaseContext(phase, element);
return (
<PhaseCellDialog
key={`${editPhaseCell.elementId}-${editPhaseCell.phaseId}`}
element={element}
phase={phase}
context={context}
phaseData={element.phaseValues[phase.id] ?? {}}
onClose={() => setEditPhaseCell(null)}
onSaved={() => {
setEditPhaseCell(null);
onChanged();
}}
onDeleted={() => {
setEditPhaseCell(null);
deleteElement(element.id);
}}
/>
);
})()}
</div>
);
// Naechste Phasen-Kappung (fuer das Phase-Popup).
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;
}
function renderDetail() {
if (!selected) return null;
const phase = computed.phases.find((p) => p.id === selected.phaseId);
const phaseInput = plan.phases.find((p) => p.id === selected.phaseId);
if (!phase || !phaseInput) return null;
const isLast = phase.sequenceNumber === computed.phases.length;
return (
<PhaseDetail
phase={phaseInput}
maxDurationYears={phase.maxDurationYears}
isLast={isLast}
laterPhaseCount={laterPhaseCount(phase)}
onSaved={() => {
setSelected(null);
onChanged();
}}
onDeleted={() => {
setSelected(null);
onChanged();
}}
/>
);
}
async function deleteElement(id: string) {
if (!confirm("Dieses Element wirklich loeschen (aus allen Phasen)?")) return;
await api.delete(`/api/elements/${id}`);
setSelected(null);
onChanged();
}
function transitionSummary(el: ElementInput, fromPhase: PhaseComputed, toPhase: PhaseComputed): string {
const td = el.transitionValues[fromPhase.id] ?? {};
switch (el.category) {
case "AHV":
return td.reviewed === true ? "Geprueft" : "?";
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 fuer 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));
}
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);
}
function phaseCellContent(
ce: ReturnType<PhaseComputed["elements"]["find"]> | undefined,
phase: PhaseComputed,
mode: ValueMode
): 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;
if (START_END_CATEGORIES.includes(ce.category) && (ce.startValue !== 0 || ce.endValue !== 0)) {
return (
<span className="whitespace-nowrap">
{valStr(ce.startValue, dS, mode)} <span className="text-faint"></span> {valStr(ce.endValue, dE, mode)}
</span>
);
}
if ((ce.category === "AHV" || ce.category === "PENSION_FUND") && ce.startValue !== 0) {
return `Rente ${valStr(ce.startValue, dS, mode)}`;
}
return ce.summary || "";
}
function PhaseHeader({
phase,
personLabel,
mode,
diffKind,
onClick,
onExpand,
active,
}: {
phase: PhaseComputed;
personLabel: (role: string) => string;
mode: ValueMode;
diffKind: "changed" | "added" | "removed" | null;
onClick: () => void;
onExpand: () => void;
active: boolean;
}) {
const quotaLabel = phase.isConsumption ? "Verzehr" : "Quote";
const dS = phase.cumulativeInflationStart;
const dE = phase.cumulativeInflationEnd; // Bestandswerte (Cash, Vermoegen)
const dF = phase.flowDeflatorEnd; // Flow-Werte (Einkommen, Ausgaben, Quote)
return (
<th
onClick={onClick}
className={`group/ph min-w-44 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" />
)}
{/* Der Kopf oeffnet per Klick das Bearbeiten-Popup -- das Expand-Icon muss das Event
deshalb stoppen, sonst gingen beide Dialoge gleichzeitig auf. */}
<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 opacity-0 hover:bg-accent-soft hover:text-accent group-hover/ph:opacity-100"
>
<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 space-y-0.5 text-[10px] leading-tight text-muted">
{phase.persons.map((p) => (
<div key={p.role} className="text-faint">
{personLabel(p.role)} {p.startAge} {p.endAge}
</div>
))}
<div>Einkommen {valStr(phase.incomeStart, dS, mode)} {valStr(phase.incomeEnd, dF, mode)}</div>
<div>Ausgaben {valStr(phase.expenseStart, dS, mode)} {valStr(phase.expenseEnd, dF, mode)}</div>
<div>
{quotaLabel} {valStr(phase.quotaStart, dS, mode)} {valStr(phase.quotaEnd, dF, mode)}
</div>
<div className="my-1 border-t border-border" />
<div>Geplante Sparrate {valStr(phase.plannedSaveRate, dS, mode)}</div>
<div>Geplante Verzehrrate {valStr(phase.plannedWithdrawRate, dS, mode)}</div>
{(phase.capitalInflow > 0 || phase.capitalInvest > 0) && (
<>
<div className="my-1 border-t border-border" />
{phase.capitalInflow > 0 && <div>Kapitalzufluss {valStr(phase.capitalInflow, dS, mode)}</div>}
{phase.capitalInvest > 0 && <div>Kapitalinvestitionen {valStr(phase.capitalInvest, dS, mode)}</div>}
</>
)}
{(phase.oneOffInflow > 0 || phase.oneOffOutflow > 0) && (
<>
<div className="my-1 border-t border-border" />
{phase.oneOffInflow > 0 && (
<div className="text-success">
+ {phase.oneOffInflowLabel ?? "Einmaliger Zufluss"} {valStr(phase.oneOffInflow, dS, mode)}
</div>
)}
{phase.oneOffOutflow > 0 && (
<div className="text-danger">
{phase.oneOffOutflowLabel ?? "Einmalige Kosten"} {valStr(phase.oneOffOutflow, dS, mode)}
</div>
)}
</>
)}
<div className="my-1 border-t border-border" />
<div>
Vermoegen {valStr(phase.startWealthNominal, dS, mode)} {valStr(phase.endWealthNominal, dE, mode)}
</div>
</div>
</th>
);
}
function TransitionHeader({ openCount, onClick }: { openCount: number; onClick: () => void }) {
const done = openCount === 0;
return (
<th
onClick={onClick}
className={`cursor-pointer border-b border-r border-border px-2 py-2 text-center align-top text-[11px] font-medium ${
done ? "bg-success/10 text-success" : "bg-accent text-accent-fg"
}`}
>
<div>Uebergang</div>
{done ? (
<div className="mt-1 flex items-center justify-center gap-1 text-[10px] font-semibold">
<CheckCircle2 className="h-3 w-3" /> geprueft
</div>
) : (
<div className="mt-1 rounded-full bg-accent-fg/20 px-1.5 py-0.5 text-[10px] font-semibold">
{openCount} offen
</div>
)}
</th>
);
}
function FragmentRows({ children }: { children: React.ReactNode }) {
return <>{children}</>;
}
// --- Dialog: neues finanzielles Element mit Direkteingabe der Phase-1-Werte ---
function AddElementDialog({
plan,
firstPhase,
onClose,
onCreated,
}: {
plan: PlanInput;
firstPhase: PhaseComputed;
onClose: () => void;
onCreated: () => void;
}) {
const [category, setCategory] = useState<ElementCategory>("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 fuer 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,
deflatorStart: firstPhase.cumulativeInflationStart,
laterPhaseCount: 0, // beim Anlegen bewusst kein Warnhinweis
ahvCareer: null,
};
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>
);
}
// --- Dialog: Plan-Einstellungen (Grundprofil bearbeiten) ---
function PlanSettingsDialog({ 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 (
<DialogShell title="Plan-Einstellungen" onClose={onClose}>
<PlanProfileFields draft={draft} onChange={setDraft} />
{error && <p className="text-sm text-danger">{error}</p>}
<DialogActions saving={saving} onConfirm={save} onClose={onClose} confirmLabel="Speichern" />
</DialogShell>
);
}
// --- Dialog: geführter Übergang ---
function TransitionReviewDialog({
fromPhase,
toPhase,
elements,
initialCash,
buildContext,
isRetirement,
onClose,
onSaved,
}: {
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));
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] ?? {});
}
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">
Gehen Sie die Positionen durch und treffen Sie 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 Uebergang. */}
<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önnten Sie 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 } }))}
/>
</div>
</div>
);
})}
</div>
{error && <p className="text-sm text-danger">{error}</p>}
<DialogActions saving={saving} onConfirm={saveAll} onClose={onClose} confirmLabel="Alle speichern" />
</DialogShell>
);
}
// --- Dialog: Cash-Uebergang (einmalige Sonderein-/ausgaben) ---
function CashTransitionDialog({
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 (
<DialogShell title="Uebergang: Cash" onClose={onClose} wide>
<div className="text-xs text-muted">
Einmalige Sonderein-/ausgaben · {fromPhase.name} → {toPhase?.name ?? "Ende"}
</div>
<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="text-sm text-danger">{error}</p>}
<DialogActions saving={saving} onConfirm={save} onClose={onClose} confirmLabel="Speichern" />
</DialogShell>
);
}
// --- Dialog: Cash-Anfangswert (erste Lebensphase) ---
function CashInitialDialog({ 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 (
<DialogShell title="Cash-Anfangswert" onClose={onClose}>
<p className="text-sm text-muted">Startbestand des Cash-Kontos zu Beginn der ersten Lebensphase.</p>
<MoneyField label="Anfangswert (CHF)" value={value} onChange={setValue} />
{error && <p className="text-sm text-danger">{error}</p>}
<DialogActions saving={saving} onConfirm={save} onClose={onClose} confirmLabel="Speichern" />
</DialogShell>
);
}
// --- Dialog: Element-Werte einer Lebensphase (per Klick auf eine Phasenzelle) ---
function PhaseCellDialog({
element,
phase,
context,
phaseData,
onClose,
onSaved,
onDeleted,
}: {
element: ElementInput;
phase: PhaseComputed;
context: CellContext;
phaseData: PhaseData;
onClose: () => void;
onSaved: () => void;
onDeleted: () => void;
}) {
return (
<DialogShell title={`Lebensphase: ${phase.name}`} onClose={onClose} wide>
<ElementDetail
element={element}
context={context}
phaseData={phaseData}
transitionData={{}}
onSaved={onSaved}
onDeleteElement={onDeleted}
/>
</DialogShell>
);
}
// --- Dialog: einzelner Übergangs-Entscheid (per Klick auf eine Übergangszelle) ---
function TransitionCellDialog({
element,
fromPhase,
toPhase,
context,
onClose,
onSaved,
}: {
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 [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);
onSaved();
} catch (e) {
setError(e instanceof Error ? e.message : "Speichern fehlgeschlagen.");
} finally {
setSaving(false);
}
}
return (
<DialogShell title={`Übergang: ${element.name}`} onClose={onClose}>
<div className="text-xs text-muted">
{CATEGORY_LABELS[element.category]} · {fromPhase.name} {toPhase?.name ?? "Ende"}
</div>
<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 }))}
/>
</div>
{error && <p className="text-sm text-danger">{error}</p>}
<DialogActions saving={saving} onConfirm={save} onClose={onClose} confirmLabel="Speichern" />
</DialogShell>
);
}
// --- gemeinsame Dialog-Bausteine ---
function DialogShell({
title,
onClose,
children,
wide,
}: {
title: string;
onClose: () => void;
children: React.ReactNode;
wide?: boolean;
}) {
return (
<div className="fixed inset-0 z-40 flex items-start justify-center overflow-y-auto bg-black/40 px-4 py-8" onClick={onClose}>
<div
onClick={(e) => e.stopPropagation()}
className={`flex w-full ${wide ? "max-w-2xl" : "max-w-md"} flex-col gap-3 rounded-2xl border border-border bg-surface p-6 shadow-xl`}
>
<div className="flex items-center justify-between">
<h2 className="text-base font-semibold text-fg">{title}</h2>
<button type="button" onClick={onClose} aria-label="Schliessen" className="rounded-md p-1 text-faint hover:bg-surface-2">
<X className="h-4 w-4" />
</button>
</div>
{children}
</div>
</div>
);
}
function DialogActions({
saving,
onConfirm,
onClose,
confirmLabel,
}: {
saving: boolean;
onConfirm: () => void;
onClose: () => void;
confirmLabel: string;
}) {
return (
<div className="flex gap-2 pt-1">
<button
type="button"
disabled={saving}
onClick={onConfirm}
className="rounded-lg bg-accent px-4 py-2 text-sm font-medium text-accent-fg shadow-sm hover:bg-accent-hover disabled:opacity-50"
>
{saving ? "..." : confirmLabel}
</button>
<button
type="button"
onClick={onClose}
className="rounded-lg border border-border px-4 py-2 text-sm font-medium text-muted hover:bg-surface-2"
>
Abbrechen
</button>
</div>
);
}