Pensionierungs-Bildschirm, Ampel mit drei Zustaenden, Planungshorizont
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
+156
-12
@@ -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>}
|
||||
|
||||
Reference in New Issue
Block a user