Fix Uebergang: Entscheide werden zuverlaessig gespeichert; Klick auf Zelle oeffnet Popup
Deploy App / deploy (push) Successful in 1m0s
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:
@@ -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 (
|
||||
|
||||
+103
-35
@@ -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,7 +511,7 @@ export function PlanView({
|
||||
const element = plan.elements.find((e) => e.id === selected.elementId);
|
||||
if (!element) return null;
|
||||
|
||||
if (selected.type === "phaseCell") {
|
||||
// phaseCell
|
||||
const phase = computed.phases.find((p) => p.id === selected.phaseId)!;
|
||||
const context = buildPhaseContext(phase, element);
|
||||
return (
|
||||
@@ -499,23 +526,6 @@ export function PlanView({
|
||||
);
|
||||
}
|
||||
|
||||
// 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);
|
||||
return (
|
||||
<ElementDetail
|
||||
element={element}
|
||||
context={context}
|
||||
phaseData={{}}
|
||||
transitionData={element.transitionValues[fromPhase.id] ?? {}}
|
||||
onSaved={onChanged}
|
||||
onDeleteElement={() => deleteElement(element.id)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
async function deleteElement(id: string) {
|
||||
if (!confirm("Dieses Element wirklich loeschen (aus allen Phasen)?")) return;
|
||||
await api.delete(`/api/elements/${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,
|
||||
|
||||
+3
-1
@@ -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(),
|
||||
|
||||
Reference in New Issue
Block a user