Files
FPT/src/components/PlanView.tsx
T
admGitAICDS 6402583e0c
Deploy App / deploy (push) Successful in 57s
V5-Modell: Einkommen nominal, Ausgaben real->nominal, Inflation plan-weit, Sparquoten-Grafik
- Einkommen: nominale Basis + nominale Lohnerhoehung (Default 0). Real read-only als Info.
- Ausgaben: REALE Basis (heutige Kaufkraft) + reale Mehrausgaben (Default 0); nominal =
  real x plan-weite Inflation, read-only. Loest die "nominale Ausgaben verwirren"-Problematik.
- Inflation nur noch plan-weit (Phasen-Override entfernt).
- Engine liefert flowDeflatorEnd (Flow-Realwerte) + yearly-Serie (Jahr/Alter/Einkommen/
  Ausgabe nominal/real). Nominal/Real-Umschalter nutzt Flow- vs. Bestands-Deflatoren.
- Neue Grafik (Dashboard): Einkommen vs. nominale Ausgabe mit eingefaerbter Sparquoten-
  Flaeche (gruen/rot) + reale Ausgabe als Referenzlinie (Inflationskeil).
- In-Tool-Erklaerungen in den Einkommen/Ausgaben-Popups.
- Golden Tests aufs neue Modell hergeleitet (8 gruen, u.a. Ausgaben real->nominal, Ruin 94).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-15 22:20:06 +02:00

1295 lines
48 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,
PiggyBank,
Plus,
Settings2,
ShoppingCart,
TrendingUp,
Wallet,
X,
} from "lucide-react";
import { Timeline } from "@/components/Timeline";
import {
ElementDetail,
ElementPhaseFields,
ElementTransitionFields,
isTransitionAnswered,
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 ElementCategory,
type PhaseData,
type TransitionData,
} from "@/lib/elements";
import type { PhaseComputed, PlanComputed } from "@/lib/calculations";
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" />,
};
const TRANSITION_CATEGORIES: ElementCategory[] = [
"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,
onChanged,
}: {
plan: PlanInput;
computed: PlanComputed;
onChanged: () => void;
}) {
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);
const [valueMode, setValueMode] = useState<ValueMode>("nominal");
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;
}
function phaseInflationFor(phaseId: string): number {
return plan.phases.find((p) => p.id === phaseId)?.inflationRate ?? plan.inflationRateDefault;
}
// 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,
phaseInflation: phaseInflationFor(phase.id),
deflatorStart: phase.cumulativeInflationStart,
};
}
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,
phaseInflation: phaseInflationFor(fromPhase.id),
deflatorStart: fromPhase.cumulativeInflationStart,
};
}
// 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).
function transitionInactive(el: ElementInput, fromPhase: PhaseComputed): boolean {
const ce = computedElement(fromPhase.id, el.id);
if (ce && ce.status !== "ACTIVE") return true;
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;
}
// Anzahl offener (noch nicht getroffener) Uebergangs-Entscheide an einer Grenze.
function transitionOpenCount(fromPhase: PhaseComputed, toPhase: PhaseComputed): number {
let n = 0;
for (const el of plan.elements) {
if (!TRANSITION_CATEGORIES.includes(el.category)) continue;
if (transitionInactive(el, fromPhase)) 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)) return false;
const retire = isRetirementTransition(el, fromPhase, toPhase);
return !isTransitionAnswered(el.category, retire, el.transitionValues[fromPhase.id] ?? {});
}
function transitionElements(fromPhase: PhaseComputed): ElementInput[] {
return plan.elements
.filter((el) => TRANSITION_CATEGORIES.includes(el.category))
.filter((el) => {
const ce = computedElement(fromPhase.id, el.id);
return !ce || ce.status === "ACTIVE";
})
.sort((a, b) => a.orderIndex - b.orderIndex);
}
async function handleAddPhase(payload: { name?: string; durationYears?: number; inflationRate?: number | null }) {
await api.post(`/api/plans/${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} />
{/* Plan-Profil */}
<div className="flex flex-wrap items-center gap-3 rounded-xl border border-border bg-surface px-4 py-3 text-sm shadow-sm">
<span className="text-xs font-semibold uppercase tracking-wide text-faint">Grundprofil (Plan)</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}
onClick={() => setSelected({ type: "phase", phaseId: 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"}`}
>
<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>
) : (
<td key={`cash-t-${col.fromPhase.id}`} className="border-b border-r border-border px-2 py-1.5 text-center text-[11px] text-faint">
</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="sticky left-0 z-10 border-b border-r border-border bg-surface px-3 py-1.5">
<div className="truncate text-xs font-medium text-fg">{el.name}</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 })}
className={`cursor-pointer border-b border-r border-border px-2 py-1.5 text-center text-xs ${
ce?.locked ? "text-faint" : "text-fg"
}`}
>
{phaseCellContent(ce, col.phase, valueMode)}
</td>
);
}
const locked = TRANSITION_CATEGORIES.includes(el.category) && transitionInactive(el, col.fromPhase);
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 })
}
className={`border-b border-r border-border px-2 py-1.5 text-center text-[11px] ${
canTransition ? "cursor-pointer" : "text-faint"
} ${open ? "bg-accent font-semibold text-accent-fg" : canTransition ? "bg-accent-soft/40 text-accent" : ""}`}
>
{canTransition ? transitionSummary(el, col.fromPhase, col.toPhase) : locked ? "" : "→"}
</td>
);
})}
</tr>
))}
</FragmentRows>
);
})}
{plan.elements.length === 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>
)}
{/* Detail-Panel. Der key erzwingt beim Wechsel von Zelle/Phase einen Neuaufbau,
damit der lokale Formular-Zustand nicht vom vorher geoeffneten Element uebrig bleibt. */}
{selected && (
<div key={`ph-${selected.phaseId}`} className="rounded-xl border border-accent bg-surface p-4 shadow-sm">
{renderDetail()}
</div>
)}
{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();
}}
/>
)}
{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);
return (
<TransitionReviewDialog
fromPhase={fromPhase}
toPhase={toPhase}
elements={els}
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();
}}
/>
);
})()}
{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}
onSaved={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 "REAL_ESTATE":
case "OTHER_ASSET":
return td.decision === "SELL" ? "Verkauf" : td.decision === "HOLD" ? "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,
onClick,
active,
}: {
phase: PhaseComputed;
personLabel: (role: string) => string;
mode: ValueMode;
onClick: () => 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={`min-w-44 cursor-pointer border-b border-r border-border px-2 py-2 text-left align-top ${
active ? "bg-accent-soft" : "bg-surface"
}`}
>
<div className="flex items-center gap-1">
<span className="truncate text-xs font-semibold text-fg">{phase.name}</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" />
)}
</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={phase.cashNegative ? "text-danger font-medium" : ""}>
Cash {valStr(phase.cashStart, dS, mode)} {valStr(phase.cashEnd, dE, mode)}
</div>
<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,
phaseInflation: plan.inflationRateDefault,
deflatorStart: firstPhase.cumulativeInflationStart,
};
async function create() {
setSaving(true);
setError(null);
try {
const { element } = await api.post<{ element: { id: string } }>(`/api/plans/${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; inflationRate?: number | null }) => 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,
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/plans/${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,
buildContext,
isRetirement,
onClose,
onSaved,
}: {
fromPhase: PhaseComputed;
toPhase: PhaseComputed | undefined;
elements: ElementInput[];
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 [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
async function saveAll() {
setSaving(true);
setError(null);
try {
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">
{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 keine zu entscheidenden Positionen.
</p>
)}
{elements.map((el) => {
const ctx = buildContext(el);
const retire = isRetirement(el);
const hint =
(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-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/plans/${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>
);
}