Files
FPT/src/components/PlanView.tsx
T
admGitAICDS a97de5b1ed
Deploy App / deploy (push) Successful in 59s
Netto/Brutto-Klarstellung fuer die AHV + erweitertes Immobilien-Modul
Roadmap Nr. 9 (reduziert): Einkommen ist neu explizit als NETTOLOHN definiert
(Label + Hilfetext). Bisher stand nirgends, ob netto oder brutto gemeint ist -- fuer
den Cash-Fluss egal (beide Konventionen heben sich auf), aber seit der
einkommensabhaengigen AHV haengt eine Rente daran. Die AHV bemisst sich am
Bruttolohn, deshalb rechnet das Tool intern mit AHV_GROSS_FROM_NET_FACTOR = 1.12
hoch. Ohne das war die Rente um bis zu ~1'900/Jahr zu tief (Details: SPEZ 9.13).
Der Faktor ist hergeleitet und dokumentiert (AHV/IV/EO 5.3% + ALV 1.1% + NBU ~1% +
PK ~2-5% auf den koordinierten Lohn) -- fix vertretbar, weil das mdJE selbst ein
Karriere-Durchschnitt ist. Keine Aufschluesselung, kein sichtbares Feld (kommt mit
Roadmap Nr. 41 als erklaerte Konstante).

Roadmap Nr. 8: Immobilie neu mit Hypothekarzins (% der Restschuld, Zinsbetrag sinkt
mit der Amortisation, read-only "Beginn -> Ende") und Wertsteigerung.

WICHTIG: Die Wertsteigerung wirkt auf die LIEGENSCHAFT, nicht auf das Eigenkapital.
1% von 1 Mio sind 10'000/Jahr, also 10% eines Eigenkapitals von 100'000 -- das ist
der Hebel. Auf dem EK gerechnet waeren es 1'000 (Beispiel: 304'622 statt 210'462).
Kaufpreis und Verkehrswert laufen deshalb getrennt; die Grundstueckgewinnsteuer
bemisst sich weiterhin am urspruenglichen Kaufpreis.

Doppelzaehlung: Schalter interestHandling auf der Immobilie, Default INCLUDED --
bestehende Plaene haben die Zinsen in den Ausgaben und aendern sich nicht.

Keine Steuerschaetzung (Begruendung: SPEZ 9.14). Sechs Regressionstests (30 -> 36).
Spezifikation auf v0.5.

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

1467 lines
56 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 {
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 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" />,
};
// 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,
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);
// fromPhaseId des Cash-Uebergangs, der gerade bearbeitet wird.
const [editCashTransition, setEditCashTransition] = useState<string | null>(null);
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;
}
// 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,
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,
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/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>
) : (
(() => {
// 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" : "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="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, 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 })
}
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();
}}
/>
)}
{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();
}}
/>
);
})()}
{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={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 "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="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,
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/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 }) => 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,
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/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>
);
}