Fix Uebergang: Entscheide werden zuverlaessig gespeichert; Klick auf Zelle oeffnet Popup
Deploy App / deploy (push) Successful in 1m0s

- Ursache des "?"-Bugs: sichtbarer Default (Halten/Rente) wurde ohne aktive Auswahl
  nicht in den Datensatz geschrieben -> beim Speichern blieb der Entscheid leer.
  Fix: withTransitionDefaults belegt den Entscheid explizit vor (Popup + Review-Panel),
  sodass ein blosses Speichern den Default persistiert.
- Uebergangszellen oeffnen neu ein kleines Popup (TransitionCellDialog) statt des
  unteren Detail-Panels; offene Zellen sind kraeftig eingefaerbt.
- PK und 3a bekommen im normalen Uebergang einen echten Bezugs-Entscheid
  (Kein Bezug / Bezug + Betrag) inkl. offenem "?"-Status bis entschieden.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-14 07:45:05 +02:00
parent f49163af60
commit 737bc2051f
3 changed files with 182 additions and 58 deletions
+72 -18
View File
@@ -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<TransitionData>) => void;
max: number;
label: string;
}) {
const mode = td.withdrawalMode ?? "NONE";
return (
<>
<SelectField
label="Bezug?"
value={mode}
onChange={(v: "NONE" | "AMOUNT") => setT(v === "NONE" ? { withdrawalMode: v, withdrawal: 0 } : { withdrawalMode: v })}
options={[
{ value: "NONE", label: "Kein Bezug" },
{ value: "AMOUNT", label: "Bezug" },
]}
/>
{mode === "AMOUNT" && (
<MoneyField
label={label}
help={`Maximal ${formatChf(max)} (Endwert der Vorphase).`}
value={num(td.withdrawal)}
max={max}
onChange={(v) => 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 (
<MoneyField
label="PK-Bezug (CHF)"
help={`Optionaler Bezug. Maximal ${formatChf(context.carriedEndValue)} (Endwert der Vorphase).`}
value={num(td.withdrawal)}
max={context.carriedEndValue}
onChange={(v) => setT({ withdrawal: v })}
/>
);
return <WithdrawalDecision td={td} setT={setT} max={context.carriedEndValue} label="PK-Bezug (CHF)" />;
case "PILLAR_3A":
if (context.isRetirementTransition) {
return (
@@ -306,15 +368,7 @@ export function ElementTransitionFields({
/>
);
}
return (
<MoneyField
label="3a-Bezug (CHF)"
help={`Maximal ${formatChf(context.carriedEndValue)} (Endwert der Vorphase).`}
value={num(td.withdrawal)}
max={context.carriedEndValue}
onChange={(v) => setT({ withdrawal: v })}
/>
);
return <WithdrawalDecision td={td} setT={setT} max={context.carriedEndValue} label="3a-Bezug (CHF)" />;
case "REAL_ESTATE": {
const decision = td.decision ?? "HOLD";
return (
+107 -39
View File
@@ -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<string | null>(null);
const [editTransition, setEditTransition] = useState<{ elementId: string; fromPhaseId: string } | null>(null);
const columns = useMemo<Column[]>(() => {
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 (
<td
key={`t-${col.fromPhase.id}`}
onClick={() =>
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) : "→"}
</td>
@@ -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 (
<TransitionCellDialog
element={element}
fromPhase={fromPhase}
toPhase={toPhase}
context={context}
onClose={() => setEditTransition(null)}
onSaved={() => {
setEditTransition(null);
onChanged();
}}
/>
);
})()}
</div>
);
@@ -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 (
<ElementDetail
element={element}
context={context}
phaseData={element.phaseValues[phase.id] ?? {}}
transitionData={{}}
onSaved={onChanged}
onDeleteElement={() => 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 (
<ElementDetail
element={element}
context={context}
phaseData={{}}
transitionData={element.transitionValues[fromPhase.id] ?? {}}
phaseData={element.phaseValues[phase.id] ?? {}}
transitionData={{}}
onSaved={onChanged}
onDeleteElement={() => 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<Record<string, import("@/lib/elements").TransitionData>>(() =>
Object.fromEntries(elements.map((e) => [e.id, { ...(e.transitionValues[fromPhase.id] ?? {}) }]))
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);
@@ -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<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,