Pensionierungs-Bildschirm, Ampel mit drei Zustaenden, Planungshorizont

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-26 13:37:08 +02:00
parent 9907fda6f4
commit 1865db5de7
12 changed files with 1181 additions and 130 deletions
+156 -12
View File
@@ -15,6 +15,7 @@ import {
Pencil,
Maximize2,
PiggyBank,
Table2,
Plus,
Settings2,
ShoppingCart,
@@ -30,9 +31,20 @@ import { capitalPot } from "@/lib/distribution";
import {
isRetirementTransition,
openTransitionCount,
decisionsText,
type DecisionCounts,
transitionInactive as inactiveAtTransition,
TRANSITION_CATEGORIES,
retirementConfirmed,
} from "@/lib/decisions";
import { AHV_REFERENCE_AGE } from "@/lib/constants";
import {
RETIREMENT_CATEGORIES,
withRetirementDefaults,
type RetirementDecision,
} from "@/lib/retirement-decision";
import { RetirementFields } from "@/components/RetirementFields";
import { RetirementPanel } from "@/components/RetirementPanel";
import { Button, EmptyState, InspectorShell, Modal, useConfirm, useToast } from "@/components/ui";
import { ElementDetailDialog, PhaseDetailDialog } from "@/components/DetailView";
import {
@@ -170,6 +182,8 @@ export function PlanView({
// weil beide mehrere Elemente auf einmal bearbeiten.
const [distribute, setDistribute] = useState<{ kind: "capital" | "rates"; phaseId: string } | null>(null);
const [valueMode, setValueMode] = useState<ValueMode>("nominal");
// Matrix oder Pensionierung. Kein URL-Zustand: Es ist eine Arbeitsansicht, kein Ort.
const [view, setView] = useState<"matrix" | "retirement">("matrix");
// Die Tour (Start-Knopf, Auto-Start bei Plan-Erstellung, Rendering) liegt seit dem
// Layout-Umbau in AppShell -- sie liest die data-tour-Ziele im DOM dieser Ansicht.
// Nur-Lese-Detailansicht (Roadmap Nr. 43). Der Rechenweg wird erst beim Öffnen erzeugt.
@@ -352,6 +366,18 @@ export function PlanView({
onChanged();
}
// Kurzfassung der Pensionierung fuer die Matrix-Ansicht: die eine Zahl, die zaehlt, plus
// der Hinweis auf ungepruefte Vorgaben. Ein Klick fuehrt in den Bildschirm.
const retirementSummaryText = (() => {
const r = computed.retirement;
if (r.gapAnnual === null) return null;
const unconfirmed = plan.elements.filter(
(e) => RETIREMENT_CATEGORIES.includes(e.category) && !retirementConfirmed(e)
).length;
const luecke = r.gapAnnual < 0 ? `Rentenluecke ${formatChf(r.gapAnnual)}/Jahr` : `Ueberschuss ${formatChf(r.gapAnnual)}/Jahr`;
return unconfirmed > 0 ? `${luecke} · ${unconfirmed} Vorgaben ungeprueft` : luecke;
})();
const hasPhases = computed.phases.length > 0;
const firstPhase = computed.phases[0] ?? null;
const lastPhase = computed.phases[computed.phases.length - 1] ?? null;
@@ -438,9 +464,46 @@ export function PlanView({
</div>
</div>
{/* Ansicht: Matrix oder Pensionierung. Die Pensionierung steht bewusst gleichrangig
NEBEN der Matrix und nicht in ihr: Sie ist keine Zelle, sondern eine eigene Frage --
und die einzige, die man zwischen Szenarien systematisch variiert. */}
{hasPhases && (
<div className="flex flex-wrap items-center gap-2">
<div className="inline-flex rounded-lg border border-border bg-surface p-0.5 text-sm">
{(["matrix", "retirement"] as const).map((v) => (
<button
key={v}
type="button"
onClick={() => setView(v)}
className={`flex items-center gap-1.5 rounded-md px-3 py-1 font-medium ${
view === v ? "bg-accent text-accent-fg" : "text-muted hover:bg-surface-2"
}`}
>
{v === "matrix" ? <Table2 className="h-4 w-4" /> : <PiggyBank className="h-4 w-4" />}
{v === "matrix" ? "Matrix" : "Pensionierung"}
</button>
))}
</div>
{view === "matrix" && retirementSummaryText && (
<button
type="button"
onClick={() => setView("retirement")}
className="truncate text-xs text-muted underline decoration-dotted underline-offset-2 hover:text-fg"
title="Zur Pensionierung"
>
{retirementSummaryText}
</button>
)}
</div>
)}
{view === "retirement" && (
<RetirementPanel plan={plan} computed={computed} onSaved={onChanged} />
)}
{/* Anzeige-Umschalter (nominal/real, Plan/Ist) -- direkt über der Matrix, weil er nur
deren Zahlen steuert. */}
{hasPhases && (
{hasPhases && view === "matrix" && (
<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">
@@ -554,7 +617,7 @@ export function PlanView({
{/* Matrix: eigener Scrollbereich, damit Phasen-Köpfe (oben) UND Elementnamen (links)
beim Scrollen sichtbar bleiben. */}
{hasPhases && (
{hasPhases && view === "matrix" && (
<div data-tour="matrix" className="max-h-[75vh] overflow-auto rounded-xl border border-border bg-surface shadow-sm">
{/* Feste Spaltenbreiten: Alle Phasenspalten sind gleich breit -- bei einer einzigen
Phase bleibt die Tabelle dadurch schmal, bei vielen wird horizontal gescrollt.
@@ -589,7 +652,7 @@ export function PlanView({
) : (
<TransitionHeader
key={`t-${col.fromPhase.id}`}
openCount={transitionOpenCount(col.fromPhase, col.toPhase)}
counts={transitionOpenCount(col.fromPhase, col.toPhase)}
onClick={() => setReviewFromPhaseId(col.fromPhase.id)}
/>
)
@@ -880,6 +943,8 @@ export function PlanView({
const els = transitionElements(fromPhase, toPhase);
return (
<TransitionReviewDialog
plan={plan}
computed={computed}
fromPhase={fromPhase}
toPhase={toPhase}
elements={els}
@@ -1084,6 +1149,8 @@ export function PlanView({
const toPhase = computed.phases[computed.phases.findIndex((p) => p.id === fromPhase.id) + 1];
return (
<TransitionCellPanel
plan={plan}
computed={computed}
key={`${panel.elementId}-${panel.fromPhaseId}`}
element={element}
fromPhase={fromPhase}
@@ -1451,14 +1518,22 @@ function PhaseHeader({
);
}
function TransitionHeader({ openCount, onClick }: { openCount: number; onClick: () => void }) {
const done = openCount === 0;
function TransitionHeader({ counts, onClick }: { counts: DecisionCounts; onClick: () => void }) {
// Eine ungeprüfte Vorgabe ist kein vergessenes Eingabefeld -- sie wird deshalb eigens
// benannt und in gedeckterem Ton gezeigt (SPEZIFIKATION 3.5.3).
const done = counts.open === 0 && counts.unconfirmed === 0;
const onlyDefaults = counts.open === 0 && counts.unconfirmed > 0;
return (
<th
onClick={onClick}
data-tour="transition"
title={done ? undefined : decisionsText(counts)}
className={`sticky top-0 z-30 w-24 min-w-24 cursor-pointer border-b border-r border-border px-2 py-2 text-center align-top text-[11px] font-medium transition-colors ${
done ? "bg-surface-2 text-success" : "bg-attention text-attention-fg"
done
? "bg-surface-2 text-success"
: onlyDefaults
? "bg-surface-2 text-muted"
: "bg-attention text-attention-fg"
}`}
>
<div>Übergang</div>
@@ -1467,8 +1542,17 @@ function TransitionHeader({ openCount, onClick }: { openCount: number; onClick:
<CheckCircle2 className="h-3 w-3" /> geprüft
</div>
) : (
<div className="mt-1 rounded-full bg-attention-fg/20 px-1.5 py-0.5 text-[10px] font-semibold">
{openCount} offen
<div className="mt-1 flex flex-col gap-0.5">
{counts.open > 0 && (
<div className="rounded-full bg-attention-fg/20 px-1.5 py-0.5 text-[10px] font-semibold">
{counts.open} offen
</div>
)}
{counts.unconfirmed > 0 && (
<div className="rounded-full border border-border px-1.5 py-0.5 text-[10px] font-semibold">
{counts.unconfirmed} Vorgabe{counts.unconfirmed === 1 ? "" : "n"}
</div>
)}
</div>
)}
</th>
@@ -1496,7 +1580,7 @@ function NextSteps({
plan: PlanInput;
computed: PlanComputed;
columns: Column[];
openCountFor: (fromPhase: PhaseComputed, toPhase: PhaseComputed) => number;
openCountFor: (fromPhase: PhaseComputed, toPhase: PhaseComputed) => DecisionCounts;
onReview: (fromPhaseId: string) => void;
onAddElement: (category: ElementCategory) => void;
onAddPhase: () => void;
@@ -1512,14 +1596,14 @@ function NextSteps({
for (const col of columns) {
if (col.kind !== "transition") continue;
const n = openCountFor(col.fromPhase, col.toPhase);
openTotal += n;
if (n > 0 && firstOpenPhaseId === null) firstOpenPhaseId = col.fromPhase.id;
openTotal += n.open;
if (n.open > 0 && firstOpenPhaseId === null) firstOpenPhaseId = col.fromPhase.id;
}
if (openTotal > 0 && firstOpenPhaseId) {
const target = firstOpenPhaseId;
items.push({
key: "transitions",
text: `${openTotal} Übergangs-Entscheid${openTotal === 1 ? "" : "e"} offen z. B. was bei der Pensionierung mit PK und 3a passiert.`,
text: `${openTotal} Übergangs-Entscheid${openTotal === 1 ? "" : "e"} offen z. B. ob eine Immobilie verkauft wird.`,
action: "Jetzt durchgehen",
run: () => onReview(target),
});
@@ -1869,7 +1953,14 @@ function ProfilePanel({ plan, onClose, onSaved }: { plan: PlanInput; onClose: ()
}
// --- Dialog: geführter Übergang ---
// Pensionsalter des Element-Besitzers -- Bezugspunkt fuer die Vorgaben des Entscheids.
function retirementAgeOf(plan: PlanInput, el: ElementInput): number {
return plan.persons.find((p) => p.role === el.ownerRole)?.retirementAge ?? AHV_REFERENCE_AGE;
}
function TransitionReviewDialog({
plan,
computed,
fromPhase,
toPhase,
elements,
@@ -1879,6 +1970,8 @@ function TransitionReviewDialog({
onClose,
onSaved,
}: {
plan: PlanInput;
computed: PlanComputed;
fromPhase: PhaseComputed;
toPhase: PhaseComputed | undefined;
elements: ElementInput[];
@@ -1894,6 +1987,14 @@ function TransitionReviewDialog({
)
);
const [ct, setCt] = useState<CashTransitionData>(() => withCashTransitionDefaults(initialCash));
// Pensionierungs-Entscheide: eigener Entwurf, weil sie an einem anderen Ort liegen als die
// Übergangswerte -- und beim Speichern über einen eigenen Endpunkt gehen.
const [rds, setRds] = useState<Record<string, RetirementDecision>>({});
const effectiveRd = (el: ElementInput) =>
withRetirementDefaults(el.category, retirementAgeOf(plan, el), {
...(el.retirementDecision ?? {}),
...(rds[el.id] ?? {}),
});
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
@@ -1905,6 +2006,11 @@ function TransitionReviewDialog({
for (const e of elements) {
await api.put(`/api/elements/${e.id}/transition/${fromPhase.id}`, tds[e.id] ?? {});
}
// Pensionierungs-Entscheide liegen am ELEMENT und haben deshalb einen eigenen Endpunkt.
for (const [elementId, changes] of Object.entries(rds)) {
const el = plan.elements.find((x) => x.id === elementId);
await api.put(`/api/elements/${elementId}/retirement`, { ...(el?.retirementDecision ?? {}), ...changes });
}
onSaved();
} catch (err) {
setError(err instanceof Error ? err.message : "Speichern fehlgeschlagen.");
@@ -1974,6 +2080,16 @@ function TransitionReviewDialog({
context={ctx}
td={tds[el.id] ?? {}}
setT={(patch) => setTds((prev) => ({ ...prev, [el.id]: { ...prev[el.id], ...patch } }))}
retirement={
<RetirementFields
plan={plan}
el={el}
defaultOpen
rd={effectiveRd(el)}
patch={(id, patch) => setRds((prev) => ({ ...prev, [id]: { ...prev[id], ...patch } }))}
summary={computed.retirement.perPerson.find((x) => x.role === el.ownerRole)}
/>
}
/>
</div>
</div>
@@ -2073,6 +2189,8 @@ function CashInitialPanel({ plan, onClose, onSaved }: { plan: PlanInput; onClose
// --- Panel: einzelner Übergangs-Entscheid (per Klick auf eine Übergangszelle) ---
function TransitionCellPanel({
plan,
computed,
element,
fromPhase,
toPhase,
@@ -2080,6 +2198,8 @@ function TransitionCellPanel({
onClose,
onSaved,
}: {
plan: PlanInput;
computed: PlanComputed;
element: ElementInput;
fromPhase: PhaseComputed;
toPhase: PhaseComputed | undefined;
@@ -2090,6 +2210,12 @@ function TransitionCellPanel({
const [td, setTd] = useState<TransitionData>(() =>
withTransitionDefaults(element.category, context.isRetirementTransition, element.transitionValues[fromPhase.id] ?? {})
);
const [rd, setRd] = useState<RetirementDecision>({});
const effectiveRd = (el: ElementInput) =>
withRetirementDefaults(el.category, retirementAgeOf(plan, el), {
...(el.retirementDecision ?? {}),
...rd,
});
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
@@ -2098,6 +2224,12 @@ function TransitionCellPanel({
setError(null);
try {
await api.put(`/api/elements/${element.id}/transition/${fromPhase.id}`, td);
if (Object.keys(rd).length > 0) {
await api.put(`/api/elements/${element.id}/retirement`, {
...(element.retirementDecision ?? {}),
...rd,
});
}
onSaved();
} catch (e) {
setError(e instanceof Error ? e.message : "Speichern fehlgeschlagen.");
@@ -2118,6 +2250,18 @@ function TransitionCellPanel({
context={context}
td={td}
setT={(patch) => setTd((prev) => ({ ...prev, ...patch }))}
retirement={
<RetirementFields
plan={plan}
el={element}
defaultOpen
rd={effectiveRd(element)}
patch={(_id: string, patch: Partial<RetirementDecision>) =>
setRd((prev) => ({ ...prev, ...patch }))
}
summary={computed.retirement.perPerson.find((x) => x.role === element.ownerRole)}
/>
}
/>
</div>
{error && <p className="mt-3 text-sm text-danger">{error}</p>}