diff --git a/src/components/ElementDetail.tsx b/src/components/ElementDetail.tsx index 6946e87..78342b5 100644 --- a/src/components/ElementDetail.tsx +++ b/src/components/ElementDetail.tsx @@ -44,6 +44,76 @@ function DerivedField({ label, value, help }: { label: string; value: number; he ); } +// PK/3a-Bezugs-Entscheid im normalen Uebergang: Kein Bezug / Bezug (+ Betrag). +function WithdrawalDecision({ + td, + setT, + max, + label, +}: { + td: TransitionData; + setT: (patch: Partial) => void; + max: number; + label: string; +}) { + const mode = td.withdrawalMode ?? "NONE"; + return ( + <> + setT(v === "NONE" ? { withdrawalMode: v, withdrawal: 0 } : { withdrawalMode: v })} + options={[ + { value: "NONE", label: "Kein Bezug" }, + { value: "AMOUNT", label: "Bezug" }, + ]} + /> + {mode === "AMOUNT" && ( + setT({ withdrawal: v })} + /> + )} + + ); +} + +// Vorbelegung expliziter Entscheide, damit ein blosses "Speichern" den sichtbaren Default +// (Halten / Kein Bezug / Rente) auch tatsaechlich persistiert. +export function withTransitionDefaults(category: ElementCategory, isRetirement: boolean, td: TransitionData): TransitionData { + const out = { ...td }; + if (category === "REAL_ESTATE" || category === "OTHER_ASSET") { + if (out.decision === undefined) out.decision = "HOLD"; + } else if (category === "PENSION_FUND") { + if (isRetirement) { + if (out.payoutMode === undefined) out.payoutMode = "PENSION"; + } else if (out.withdrawalMode === undefined) { + out.withdrawalMode = "NONE"; + } + } else if (category === "PILLAR_3A") { + if (!isRetirement && out.withdrawalMode === undefined) out.withdrawalMode = "NONE"; + } + return out; +} + +// "Beantwortet" = ein konkreter Entscheid liegt vor (kein offenes Fragezeichen). +export function isTransitionAnswered(category: ElementCategory, isRetirement: boolean, td: TransitionData): boolean { + switch (category) { + case "REAL_ESTATE": + case "OTHER_ASSET": + return td.decision !== undefined; + case "PENSION_FUND": + return isRetirement ? td.payoutMode !== undefined : td.withdrawalMode !== undefined; + case "PILLAR_3A": + return isRetirement ? true : td.withdrawalMode !== undefined; + default: + return true; + } +} + // --- Wiederverwendbare Feldgruppen (Detail-Panel, Erstell-Popup, Uebergangs-Review) --- export function ElementPhaseFields({ @@ -285,15 +355,7 @@ export function ElementTransitionFields({ ); } - return ( - setT({ withdrawal: v })} - /> - ); + return ; case "PILLAR_3A": if (context.isRetirementTransition) { return ( @@ -306,15 +368,7 @@ export function ElementTransitionFields({ /> ); } - return ( - setT({ withdrawal: v })} - /> - ); + return ; case "REAL_ESTATE": { const decision = td.decision ?? "HOLD"; return ( diff --git a/src/components/PlanView.tsx b/src/components/PlanView.tsx index 5ff5421..79630d9 100644 --- a/src/components/PlanView.tsx +++ b/src/components/PlanView.tsx @@ -23,6 +23,8 @@ import { ElementDetail, ElementPhaseFields, ElementTransitionFields, + isTransitionAnswered, + withTransitionDefaults, type CellContext, } from "@/components/ElementDetail"; import { PhaseDetail } from "@/components/PhaseDetail"; @@ -36,6 +38,7 @@ import { num, type ElementCategory, type PhaseData, + type TransitionData, } from "@/lib/elements"; import type { PhaseComputed, PlanComputed } from "@/lib/calculations"; import type { ElementInput, PlanInput } from "@/lib/types"; @@ -73,7 +76,6 @@ type Column = type Selection = | { type: "phaseCell"; elementId: string; phaseId: string } - | { type: "transitionCell"; elementId: string; fromPhaseId: string } | { type: "phase"; phaseId: string }; export function PlanView({ @@ -91,6 +93,7 @@ export function PlanView({ const [showAddPhase, setShowAddPhase] = useState(false); const [showSettings, setShowSettings] = useState(false); const [reviewFromPhaseId, setReviewFromPhaseId] = useState(null); + const [editTransition, setEditTransition] = useState<{ elementId: string; fromPhaseId: string } | null>(null); const columns = useMemo(() => { const cols: Column[] = []; @@ -185,15 +188,20 @@ export function PlanView({ const ce = computedElement(fromPhase.id, el.id); if (ce && ce.status !== "ACTIVE") continue; const td = el.transitionValues[fromPhase.id] ?? {}; - if (el.category === "REAL_ESTATE" || el.category === "OTHER_ASSET") { - if (td.decision === undefined) n++; - } else if (el.category === "PENSION_FUND" && isRetirementTransition(el, fromPhase, toPhase)) { - if (td.payoutMode === undefined) n++; - } + 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 { + const ce = computedElement(fromPhase.id, el.id); + if (ce && ce.status !== "ACTIVE") 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)) @@ -352,20 +360,17 @@ export function PlanView({ ); } const canTransition = TRANSITION_CATEGORIES.includes(el.category); - const isSel = - selected?.type === "transitionCell" && - selected.elementId === el.id && - selected.fromPhaseId === col.fromPhase.id; + const open = canTransition && transitionUnanswered(el, col.fromPhase, col.toPhase); return ( canTransition && - setSelected({ type: "transitionCell", elementId: el.id, fromPhaseId: col.fromPhase.id }) + 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-accent" : "text-faint" - } ${isSel ? "bg-accent-soft" : "bg-accent-soft/40"}`} + 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) : "→"} @@ -445,6 +450,28 @@ export function PlanView({ /> ); })()} + + {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 ( + setEditTransition(null)} + onSaved={() => { + setEditTransition(null); + onChanged(); + }} + /> + ); + })()} ); @@ -484,32 +511,15 @@ export function PlanView({ const element = plan.elements.find((e) => e.id === selected.elementId); if (!element) return null; - if (selected.type === "phaseCell") { - const phase = computed.phases.find((p) => p.id === selected.phaseId)!; - const context = buildPhaseContext(phase, element); - return ( - deleteElement(element.id)} - /> - ); - } - - // transitionCell - const fromPhase = computed.phases.find((p) => p.id === selected.fromPhaseId)!; - const toIndex = computed.phases.findIndex((p) => p.id === fromPhase.id) + 1; - const toPhase = computed.phases[toIndex]; - const context = buildTransitionContext(fromPhase, toPhase, element); + // phaseCell + const phase = computed.phases.find((p) => p.id === selected.phaseId)!; + const context = buildPhaseContext(phase, element); return ( deleteElement(element.id)} /> @@ -533,10 +543,12 @@ export function PlanView({ if (isRetirementTransition(el, fromPhase, toPhase)) { return td.payoutMode === "CAPITAL" ? "Kapital" : td.payoutMode === "COMBI" ? "Kombi" : td.payoutMode === "PENSION" ? "Rente" : "?"; } - return num(td.withdrawal) > 0 ? `−${formatChf(num(td.withdrawal))}` : "→"; + 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"; - return num(td.withdrawal) > 0 ? `−${formatChf(num(td.withdrawal))}` : "→"; + 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: @@ -881,8 +893,10 @@ function TransitionReviewDialog({ onClose: () => void; onSaved: () => void; }) { - const [tds, setTds] = useState>(() => - Object.fromEntries(elements.map((e) => [e.id, { ...(e.transitionValues[fromPhase.id] ?? {}) }])) + const [tds, setTds] = useState>(() => + 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(null); @@ -951,6 +965,60 @@ function TransitionReviewDialog({ ); } +// --- 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(() => + withTransitionDefaults(element.category, context.isRetirementTransition, element.transitionValues[fromPhase.id] ?? {}) + ); + const [saving, setSaving] = useState(false); + const [error, setError] = useState(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 ( + +
+ {CATEGORY_LABELS[element.category]} · {fromPhase.name} → {toPhase?.name ?? "Ende"} +
+
+ setTd((prev) => ({ ...prev, ...patch }))} + /> +
+ {error &&

{error}

} + +
+ ); +} + // --- gemeinsame Dialog-Bausteine --- function DialogShell({ title, diff --git a/src/lib/elements.ts b/src/lib/elements.ts index 306af5e..d149e58 100644 --- a/src/lib/elements.ts +++ b/src/lib/elements.ts @@ -74,7 +74,8 @@ export type TransitionDecision = "HOLD" | "SELL"; export type PkPayoutMode = "CAPITAL" | "PENSION" | "COMBI"; export interface TransitionData { - // PENSION_FUND / PILLAR_3A (normaler Uebergang) + // PENSION_FUND / PILLAR_3A (normaler Uebergang): expliziter Bezugs-Entscheid. + withdrawalMode?: "NONE" | "AMOUNT"; withdrawal?: number; // PENSION_FUND (Pensions-Uebergang) payoutMode?: PkPayoutMode; @@ -112,6 +113,7 @@ export const phaseDataSchema = z export const transitionDataSchema = z .object({ + withdrawalMode: z.enum(["NONE", "AMOUNT"]).optional(), withdrawal: nonNeg.optional(), payoutMode: z.enum(["CAPITAL", "PENSION", "COMBI"]).optional(), capitalAmount: nonNeg.optional(),