6402583e0c
Deploy App / deploy (push) Successful in 57s
- 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>
1295 lines
48 KiB
TypeScript
1295 lines
48 KiB
TypeScript
"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>
|
||
);
|
||
}
|