1865db5de7
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2435 lines
99 KiB
TypeScript
2435 lines
99 KiB
TypeScript
"use client";
|
||
|
||
import { useEffect, useMemo, useState } from "react";
|
||
import {
|
||
AlertCircle,
|
||
ArrowRight,
|
||
Building2,
|
||
CheckCircle2,
|
||
ChevronDown,
|
||
ChevronRight,
|
||
CreditCard,
|
||
Home,
|
||
Landmark,
|
||
ListTodo,
|
||
Pencil,
|
||
Maximize2,
|
||
PiggyBank,
|
||
Table2,
|
||
Plus,
|
||
Settings2,
|
||
ShoppingCart,
|
||
Tornado,
|
||
Trash2,
|
||
TrendingUp,
|
||
Wallet,
|
||
} from "lucide-react";
|
||
import { Timeline } from "@/components/Timeline";
|
||
import { Sparkline } from "@/components/Sparkline";
|
||
import { CapitalDistributionDialog, RateDistributionDialog } from "@/components/DistributionDialogs";
|
||
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 {
|
||
CashTransitionFields,
|
||
ElementDetail,
|
||
ElementPhaseFields,
|
||
ElementTransitionFields,
|
||
cashTransitionSummary,
|
||
isCashTransitionAnswered,
|
||
isTransitionAnswered,
|
||
withCashTransitionDefaults,
|
||
withTransitionDefaults,
|
||
type CellContext,
|
||
} from "@/components/ElementDetail";
|
||
import { PhaseDetail } from "@/components/PhaseDetail";
|
||
import { PlanProfileFields, type ProfileDraft } from "@/components/PlanProfileFields";
|
||
import { RetirementAdjuster } from "@/components/RetirementAdjuster";
|
||
import { MoneyField, SelectField, TextField } from "@/components/FormField";
|
||
import { api } from "@/lib/api-client";
|
||
import { formatChf } from "@/lib/format";
|
||
import { DATA_SOURCE_OPTIONS, type DataSource } from "@/lib/dataview";
|
||
import {
|
||
CATEGORY_LABELS,
|
||
CATEGORY_ORDER,
|
||
PERSON_ONLY_CATEGORIES,
|
||
inheritedPhaseValues,
|
||
num,
|
||
ownerLabel,
|
||
type CashTransitionData,
|
||
type ElementCategory,
|
||
type PhaseData,
|
||
type TransitionData,
|
||
} from "@/lib/elements";
|
||
import { computePlan, type PhaseComputed, type PlanComputed } from "@/lib/calculations";
|
||
import type { ScenarioDiff } from "@/lib/diff";
|
||
import type { ElementInput, PlanInput } from "@/lib/types";
|
||
|
||
const CATEGORY_ICON: Record<ElementCategory, React.ReactNode> = {
|
||
INCOME: <Wallet className="h-4 w-4" />,
|
||
EXPENSE: <ShoppingCart className="h-4 w-4" />,
|
||
AHV: <Landmark className="h-4 w-4" />,
|
||
PENSION_FUND: <Building2 className="h-4 w-4" />,
|
||
PILLAR_3A: <PiggyBank className="h-4 w-4" />,
|
||
REAL_ESTATE: <Home className="h-4 w-4" />,
|
||
OTHER_ASSET: <TrendingUp className="h-4 w-4" />,
|
||
OTHER_DEBT: <CreditCard className="h-4 w-4" />,
|
||
};
|
||
|
||
// Kategorien mit einem Übergangs-Entscheid. AHV ist dabei ein Sonderfall: nur beim
|
||
// Pensions-Übergang ist die Beitragskarriere zu prüfen (siehe transitionInactive).
|
||
const VALUE_CATEGORIES: ElementCategory[] = [
|
||
"PENSION_FUND",
|
||
"PILLAR_3A",
|
||
"REAL_ESTATE",
|
||
"OTHER_ASSET",
|
||
"OTHER_DEBT",
|
||
];
|
||
|
||
type Column =
|
||
| { kind: "phase"; phase: PhaseComputed }
|
||
| { kind: "transition"; fromPhase: PhaseComputed; toPhase: PhaseComputed };
|
||
|
||
// Alle Einzel-Bearbeitungen laufen über EIN rechtes Inspector-Panel (statt Modals):
|
||
// die Matrix bleibt sichtbar, ein Klick auf eine andere Zelle wechselt den Inhalt.
|
||
// Genau ein Panel kann offen sein -- deshalb eine Union statt sechs einzelner Zustände.
|
||
type Panel =
|
||
| { kind: "phase"; phaseId: string }
|
||
| { kind: "elementMeta"; elementId: string }
|
||
| { kind: "cell"; elementId: string; phaseId: string }
|
||
| { kind: "transition"; elementId: string; fromPhaseId: string }
|
||
| { kind: "cashTransition"; fromPhaseId: string }
|
||
| { kind: "cashInitial" }
|
||
| { kind: "profile" };
|
||
|
||
export function PlanView({
|
||
plan,
|
||
computed: planComputed,
|
||
actualComputed = null,
|
||
actualYears = [],
|
||
diff,
|
||
onChanged,
|
||
onOpenSpec,
|
||
onOpenSensitivity,
|
||
}: {
|
||
plan: PlanInput;
|
||
computed: PlanComputed;
|
||
// Zweiter Rechenlauf mit den effektiven Werten; null, wenn keine erfasst sind.
|
||
actualComputed?: PlanComputed | null;
|
||
// Kalenderjahre mit Ist-Satz (Marker auf der Zeitachse).
|
||
actualYears?: number[];
|
||
// Abweichungen gegenüber dem Eltern-Szenario; null im Basisszenario (nichts zu markieren).
|
||
diff: ScenarioDiff | null;
|
||
onChanged: () => void;
|
||
onOpenSpec?: (anchor: string) => void;
|
||
onOpenSensitivity?: () => void;
|
||
}) {
|
||
// Planzahlen oder effektive Zahlen (inkl. Abweichung). Bewusst zwei Möglichkeiten statt
|
||
// dreier: Plan UND Ist als Rohwerte nebeneinander wären mit nominal/real acht Zahlen je
|
||
// Zelle (siehe SPEZIFIKATION 9.29).
|
||
const [dataSource, setDataSource] = useState<DataSource>("PLAN");
|
||
const hasActuals = actualComputed !== null;
|
||
const computed = dataSource === "ACTUAL" && actualComputed ? actualComputed : planComputed;
|
||
|
||
// Planwert derselben Zelle -- Grundlage der Abweichung. In der Plan-Sicht null, dann zeigt
|
||
// die Zelle gar keine Abweichung an (statt eine von 0 zu behaupten).
|
||
const showDeviation = dataSource === "ACTUAL" && actualComputed !== null;
|
||
const planEndOf = (elementId: string | undefined, phaseId: string): number | null => {
|
||
if (!showDeviation || !elementId) return null;
|
||
const ph = planComputed.phases.find((p) => p.id === phaseId);
|
||
const el = ph?.elements.find((e) => e.elementId === elementId);
|
||
return el ? el.endValue : null;
|
||
};
|
||
const confirmDialog = useConfirm();
|
||
const toast = useToast();
|
||
// Markierungs-Klassen: geändert = gelb, neu = grün, entfernt = grau.
|
||
const cellDiff = (elementId: string, phaseId: string) =>
|
||
diff?.phaseCell.has(`${elementId}:${phaseId}`) ? "bg-diff-soft ring-1 ring-inset ring-diff/40" : "";
|
||
const transDiff = (elementId: string, fromPhaseId: string) =>
|
||
diff?.transitionCell.has(`${elementId}:${fromPhaseId}`) ? "bg-diff-soft ring-1 ring-inset ring-diff/40" : "";
|
||
const rowDiff = (elementId: string) => {
|
||
const k = diff?.elementRow.get(elementId);
|
||
return k === "added"
|
||
? "bg-diff-added-soft"
|
||
: k === "changed"
|
||
? "bg-diff-soft"
|
||
: "";
|
||
};
|
||
const [panel, setPanel] = useState<Panel | null>(null);
|
||
const [collapsedCats, setCollapsedCats] = useState<Set<ElementCategory>>(new Set());
|
||
const [showAdd, setShowAdd] = useState(false);
|
||
const [addCategory, setAddCategory] = useState<ElementCategory | null>(null);
|
||
const [showAddPhase, setShowAddPhase] = useState(false);
|
||
const [reviewFromPhaseId, setReviewFromPhaseId] = useState<string | null>(null);
|
||
// Offener Verteil-Dialog (Kapital bzw. Spar-/Verzehrquote) -- bewusst ein eigenes Popup,
|
||
// 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.
|
||
const [detailFor, setDetailFor] = useState<{ kind: "element"; id: string } | { kind: "phase"; id: string } | null>(null);
|
||
|
||
// Erklärte Berechnung: bewusst NUR wenn eine Detailansicht offen ist. computePlan ist rein
|
||
// und läuft im Browser -- es braucht dafür weder einen API-Aufruf noch eine grössere
|
||
// Server-Antwort, und das Ergebnis ist per Konstruktion identisch zum Serverergebnis.
|
||
const explained = useMemo(
|
||
() => (detailFor ? computePlan(plan, undefined, { explain: true }) : null),
|
||
[detailFor, plan]
|
||
);
|
||
|
||
useEffect(() => {
|
||
const stored = typeof window !== "undefined" ? window.localStorage.getItem(VALUE_MODE_KEY) : null;
|
||
// eslint-disable-next-line react-hooks/set-state-in-effect -- einmalige Übernahme der gespeicherten Wahl
|
||
if (stored === "nominal" || stored === "both" || stored === "real") setValueMode(stored);
|
||
}, []);
|
||
function changeValueMode(m: ValueMode) {
|
||
setValueMode(m);
|
||
if (typeof window !== "undefined") window.localStorage.setItem(VALUE_MODE_KEY, m);
|
||
}
|
||
|
||
const columns = useMemo<Column[]>(() => {
|
||
const cols: Column[] = [];
|
||
computed.phases.forEach((p, i) => {
|
||
cols.push({ kind: "phase", phase: p });
|
||
if (i < computed.phases.length - 1) {
|
||
cols.push({ kind: "transition", fromPhase: p, toPhase: computed.phases[i + 1] });
|
||
}
|
||
});
|
||
return cols;
|
||
}, [computed.phases]);
|
||
|
||
// Anzeigename einer Person: eigener Name, sonst Fallback "Person A"/"Person B".
|
||
function personLabel(role: string): string {
|
||
const p = plan.persons.find((x) => x.role === role);
|
||
if (p?.name && p.name.trim()) return p.name.trim();
|
||
return role === "PERSON_A" ? "Person A" : "Person B";
|
||
}
|
||
|
||
const personAxes = plan.persons.map((p) => ({
|
||
role: p.role,
|
||
label: personLabel(p.role),
|
||
currentAge: p.age,
|
||
retirementAge: p.retirementAge,
|
||
color: p.role === "PERSON_A" ? "var(--person-a)" : "var(--person-b)",
|
||
}));
|
||
|
||
const elementsByCategory = useMemo(() => {
|
||
const map = new Map<ElementCategory, ElementInput[]>();
|
||
for (const cat of CATEGORY_ORDER) map.set(cat, []);
|
||
for (const e of [...plan.elements].sort((a, b) => a.orderIndex - b.orderIndex)) {
|
||
map.get(e.category)!.push(e);
|
||
}
|
||
return map;
|
||
}, [plan.elements]);
|
||
|
||
function computedElement(phaseId: string, elementId: string) {
|
||
return computed.phases.find((p) => p.id === phaseId)?.elements.find((e) => e.elementId === elementId);
|
||
}
|
||
|
||
// isRetirementTransition/transitionInactive/openTransitionCount kommen aus lib/decisions --
|
||
// der Bericht zaehlt mit derselben Regel (sonst meldet er andere Zahlen als die Matrix).
|
||
|
||
// Beitragskarriere des Element-Besitzers (nur für AHV relevant).
|
||
function careerFor(element: ElementInput) {
|
||
if (element.category !== "AHV" || !element.ownerRole || element.ownerRole === "HOUSEHOLD") return null;
|
||
const person = plan.persons.find((p) => p.role === element.ownerRole);
|
||
return computed.ahvCareer.find((c) => c.personId === person?.id) ?? null;
|
||
}
|
||
|
||
// Anzahl Phasen NACH dieser (für den Fortschreibungs-Warnhinweis).
|
||
function laterPhaseCount(phase: PhaseComputed): number {
|
||
return computed.phases.length - phase.sequenceNumber;
|
||
}
|
||
|
||
// Phasen in ihrer Reihenfolge -- Grundlage der Vererbung (Roadmap Nr. 44, Punkt A).
|
||
const orderedPhaseIds = computed.phases.map((p) => p.id);
|
||
|
||
// Alter des Element-Besitzers im ersten Jahr einer Phase. Ohne Personenzuordnung gilt
|
||
// Person A, weil die Zeitachse ohnehin an ihr hängt.
|
||
function ownerAgeAtPhaseStart(phase: PhaseComputed, element: ElementInput): number {
|
||
const role = element.ownerRole && element.ownerRole !== "HOUSEHOLD" ? element.ownerRole : "PERSON_A";
|
||
return phase.persons.find((p) => p.role === role)?.startAge ?? 0;
|
||
}
|
||
|
||
// Baut den Kontext für eine Phasenzelle.
|
||
function buildPhaseContext(phase: PhaseComputed, element: ElementInput): CellContext {
|
||
const ce = computedElement(phase.id, element.id);
|
||
const ownerWorking =
|
||
element.ownerRole && element.ownerRole !== "HOUSEHOLD"
|
||
? phase.persons.find((p) => p.role === element.ownerRole)?.working ?? false
|
||
: phase.type !== "PENSION";
|
||
return {
|
||
kind: "phase",
|
||
phaseId: phase.id,
|
||
ownerWorking,
|
||
isConsumption: phase.isConsumption,
|
||
durationYears: phase.durationYears,
|
||
isRetirementTransition: false,
|
||
carriedEndValue: ce?.endValue ?? 0,
|
||
carried: ce?.carried ?? false,
|
||
derivedStart: ce?.baseValue ?? 0,
|
||
derivedMortgage: ce?.mortgageStart ?? 0,
|
||
mortgageEnd: ce?.mortgageEnd ?? 0,
|
||
// Verkehrswert = Eigenkapital + Resthypothek (beides am Phasenende).
|
||
propertyValueEnd: (ce?.endValue ?? 0) + (ce?.mortgageEnd ?? 0),
|
||
deflatorStart: phase.cumulativeInflationStart,
|
||
laterPhaseCount: laterPhaseCount(phase),
|
||
ahvCareer: careerFor(element),
|
||
inheritedValues: inheritedPhaseValues(element.phaseValues, orderedPhaseIds, phase.id),
|
||
ownerAgeStart: ownerAgeAtPhaseStart(phase, element),
|
||
investTargets: [],
|
||
capitalFromTransfer: ce?.capitalFromTransfer ?? 0,
|
||
capitalFromTransferSources: ce?.capitalFromTransferSources ?? [],
|
||
};
|
||
}
|
||
|
||
function buildTransitionContext(fromPhase: PhaseComputed, toPhase: PhaseComputed | undefined, element: ElementInput): CellContext {
|
||
const ce = computedElement(fromPhase.id, element.id);
|
||
return {
|
||
kind: "transition",
|
||
phaseId: fromPhase.id,
|
||
ownerWorking: true,
|
||
isConsumption: fromPhase.isConsumption,
|
||
durationYears: fromPhase.durationYears,
|
||
isRetirementTransition: toPhase ? isRetirementTransition(element, fromPhase, toPhase) : false,
|
||
carriedEndValue: ce?.endValue ?? 0,
|
||
carried: ce?.carried ?? false,
|
||
derivedStart: 0,
|
||
derivedMortgage: 0,
|
||
mortgageEnd: ce?.mortgageEnd ?? 0,
|
||
// Verkehrswert = Eigenkapital + Resthypothek (beides am Phasenende).
|
||
propertyValueEnd: (ce?.endValue ?? 0) + (ce?.mortgageEnd ?? 0),
|
||
deflatorStart: fromPhase.cumulativeInflationStart,
|
||
laterPhaseCount: laterPhaseCount(fromPhase),
|
||
ahvCareer: careerFor(element),
|
||
inheritedValues: {},
|
||
ownerAgeStart: ownerAgeAtPhaseStart(fromPhase, element),
|
||
// Ziele fuer die Anlage-Quote beim Kapitalbezug (Punkt C).
|
||
investTargets: investTargetsOf(plan, toPhase),
|
||
capitalFromTransfer: 0,
|
||
capitalFromTransferSources: [],
|
||
};
|
||
}
|
||
|
||
// Am Übergang nichts (mehr) zu tun: verkauft/getilgt ODER PK/3a nach der Pensionierung
|
||
// (Besitzer ist zu Beginn der Von-Phase bereits pensioniert -> bereits bezogen/verrentet)
|
||
// ODER AHV ausserhalb des Pensions-Übergangs.
|
||
const transitionInactive = (el: ElementInput, fromPhase: PhaseComputed, toPhase?: PhaseComputed) =>
|
||
inactiveAtTransition(computed, el, fromPhase, toPhase);
|
||
|
||
function cashTransitionFor(phaseId: string): CashTransitionData {
|
||
return plan.phases.find((p) => p.id === phaseId)?.cashTransition ?? {};
|
||
}
|
||
|
||
// Anzahl offener (noch nicht getroffener) Übergangs-Entscheide an einer Grenze.
|
||
// Der Cash-Entscheid (einmalige Sonderein-/ausgaben) zählt mit.
|
||
const transitionOpenCount = (fromPhase: PhaseComputed, toPhase: PhaseComputed) =>
|
||
openTransitionCount(plan, computed, fromPhase, toPhase);
|
||
|
||
// Ist der Übergangs-Entscheid dieses Elements noch offen?
|
||
function transitionUnanswered(el: ElementInput, fromPhase: PhaseComputed, toPhase: PhaseComputed): boolean {
|
||
if (transitionInactive(el, fromPhase, toPhase)) return false;
|
||
const retire = isRetirementTransition(el, fromPhase, toPhase);
|
||
return !isTransitionAnswered(el.category, retire, el.transitionValues[fromPhase.id] ?? {});
|
||
}
|
||
|
||
function transitionElements(fromPhase: PhaseComputed, toPhase?: PhaseComputed): ElementInput[] {
|
||
return plan.elements
|
||
.filter((el) => TRANSITION_CATEGORIES.includes(el.category))
|
||
.filter((el) => !transitionInactive(el, fromPhase, toPhase))
|
||
.sort((a, b) => a.orderIndex - b.orderIndex);
|
||
}
|
||
|
||
async function handleAddPhase(payload: { name?: string; durationYears?: number }) {
|
||
await api.post(`/api/scenarios/${plan.id}/phases`, payload);
|
||
setShowAddPhase(false);
|
||
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;
|
||
|
||
return (
|
||
<div className="flex flex-col gap-5">
|
||
<NextSteps
|
||
plan={plan}
|
||
computed={computed}
|
||
columns={columns}
|
||
openCountFor={transitionOpenCount}
|
||
onReview={(fromPhaseId) => setReviewFromPhaseId(fromPhaseId)}
|
||
onAddElement={(cat) => {
|
||
setAddCategory(cat);
|
||
setShowAdd(true);
|
||
}}
|
||
onAddPhase={() => setShowAddPhase(true)}
|
||
onOpenSensitivity={onOpenSensitivity}
|
||
/>
|
||
|
||
{/* Kompakter Klotz: Grundprofil (25 %) · Zeitachse (50 %) · Kennzahlen (25 %). Auf
|
||
schmalen Screens stapeln die drei. */}
|
||
<div className="grid grid-cols-1 gap-4 lg:grid-cols-4">
|
||
{/* Grundprofil -- Daten untereinander, «Bearbeiten» oben rechts. */}
|
||
<div
|
||
data-tour="profil"
|
||
className={`flex flex-col gap-2 rounded-xl border px-4 py-3 shadow-sm lg:col-span-1 ${
|
||
diff?.profileChanged ? "border-diff bg-diff-soft" : "border-border bg-surface"
|
||
}`}
|
||
>
|
||
<div className="flex items-start justify-between gap-2">
|
||
<span className="text-xs font-semibold uppercase tracking-wide text-faint">
|
||
Grundprofil{diff?.profileChanged ? " · abweichend" : ""}
|
||
</span>
|
||
<button
|
||
type="button"
|
||
onClick={() => setPanel({ kind: "profile" })}
|
||
className="flex shrink-0 items-center gap-1 rounded-lg border border-border px-2 py-0.5 text-xs font-medium text-muted hover:bg-surface-2"
|
||
>
|
||
<Settings2 className="h-3.5 w-3.5" /> Bearbeiten
|
||
</button>
|
||
</div>
|
||
<div className="flex flex-col gap-1">
|
||
{plan.persons.map((p) => (
|
||
<span key={p.role} className="text-xs text-muted">
|
||
{personLabel(p.role)}: {p.age} J., Pension {p.retirementAge}
|
||
</span>
|
||
))}
|
||
<span className="text-xs text-muted">Inflation {plan.inflationRateDefault}%</span>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Zeitachse */}
|
||
<div data-tour="timeline" className="lg:col-span-2">
|
||
<Timeline
|
||
phases={computed.phases}
|
||
persons={personAxes}
|
||
ruinAge={computed.ruinAge}
|
||
actualYears={actualYears}
|
||
startYear={plan.startYear}
|
||
/>
|
||
</div>
|
||
|
||
{/* Kennzahlen: Endvermögen nominal + real (+ Ruin, falls vorhanden). */}
|
||
<div data-tour="endwealth" className="flex flex-col justify-center gap-2 rounded-xl border border-border bg-surface px-4 py-3 shadow-sm lg:col-span-1">
|
||
<span className="text-xs font-semibold uppercase tracking-wide text-faint">Endvermögen</span>
|
||
<div>
|
||
<div className="text-[11px] text-muted">Nominal</div>
|
||
<div className="text-lg font-bold text-fg">
|
||
{lastPhase ? formatChf(Math.round(lastPhase.endWealthNominal)) : "—"}
|
||
</div>
|
||
</div>
|
||
<div>
|
||
<div className="text-[11px] text-muted">Real (heutige Kaufkraft)</div>
|
||
<div className="text-lg font-bold text-fg">
|
||
{lastPhase ? formatChf(Math.round(lastPhase.endWealthReal)) : "—"}
|
||
</div>
|
||
</div>
|
||
{computed.ruinAge !== null && (
|
||
<div className="mt-0.5 rounded-lg bg-danger-soft px-2 py-1 text-xs font-medium text-danger">
|
||
Kapital reicht nur bis Alter {computed.ruinAge}
|
||
</div>
|
||
)}
|
||
</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 && 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">
|
||
{(["nominal", "both", "real"] as ValueMode[]).map((m) => (
|
||
<button
|
||
key={m}
|
||
type="button"
|
||
onClick={() => changeValueMode(m)}
|
||
className={`rounded-md px-2.5 py-1 font-medium ${
|
||
valueMode === m ? "bg-accent text-accent-fg" : "text-muted hover:bg-surface-2"
|
||
}`}
|
||
>
|
||
{m === "nominal" ? "Nominal" : m === "both" ? "Beide" : "Real"}
|
||
</button>
|
||
))}
|
||
</div>
|
||
<span className="text-[11px] text-faint">real = kaufkraftbereinigt (Planbeginn)</span>
|
||
|
||
{/* Zweite Achse: Datenquelle. Erscheint nur, wenn es überhaupt Ist-Werte gibt --
|
||
sonst wäre es ein Umschalter ohne Gegenstück. */}
|
||
{hasActuals && (
|
||
<>
|
||
<span className="ml-2 text-xs text-muted">Zahlen</span>
|
||
<div className="inline-flex rounded-lg border border-border bg-surface p-0.5 text-xs">
|
||
{DATA_SOURCE_OPTIONS.map((o) => (
|
||
<button
|
||
key={o.value}
|
||
type="button"
|
||
onClick={() => setDataSource(o.value)}
|
||
title={o.label}
|
||
className={`rounded-md px-2.5 py-1 font-medium ${
|
||
dataSource === o.value ? "bg-accent text-accent-fg" : "text-muted hover:bg-surface-2"
|
||
}`}
|
||
>
|
||
{o.short}
|
||
</button>
|
||
))}
|
||
</div>
|
||
{dataSource === "ACTUAL" && (
|
||
<span className="text-[11px] text-faint">Abweichung gegenüber Plan farbig</span>
|
||
)}
|
||
</>
|
||
)}
|
||
</div>
|
||
)}
|
||
|
||
{!hasPhases && (
|
||
<EmptyState
|
||
icon={<Plus className="h-6 w-6" />}
|
||
title="Noch keine Lebensphasen"
|
||
text="Deine Planung besteht aus Lebensphasen – Abschnitten, in denen deine Finanzen ähnlich laufen (z. B. «bis zur Pension»). Leg die erste an, danach kommen die finanziellen Elemente."
|
||
>
|
||
<Button onClick={() => setShowAddPhase(true)}>
|
||
<Plus className="h-4 w-4" /> Erste Lebensphase
|
||
</Button>
|
||
</EmptyState>
|
||
)}
|
||
|
||
{hasPhases && (
|
||
<div className="flex flex-wrap items-center gap-2">
|
||
<Button size="sm" className="px-3 py-1.5 text-sm" onClick={() => setShowAdd(true)}>
|
||
<Plus className="h-4 w-4" /> Finanzielles Element
|
||
</Button>
|
||
<button
|
||
type="button"
|
||
onClick={() => setShowAddPhase(true)}
|
||
className="flex items-center gap-1.5 rounded-lg border border-dashed border-accent bg-accent-soft px-3 py-1.5 text-sm font-medium text-accent-soft-fg transition-colors hover:bg-accent-soft"
|
||
>
|
||
<Plus className="h-4 w-4" /> Lebensphase
|
||
</button>
|
||
{/* Alle Kategoriegruppen auf einen Schlag auf- oder zuklappen. */}
|
||
<button
|
||
type="button"
|
||
onClick={() =>
|
||
setCollapsedCats((prev) =>
|
||
prev.size === 0 ? new Set(CATEGORY_ORDER.filter((c) => (elementsByCategory.get(c)?.length ?? 0) > 0)) : new Set()
|
||
)
|
||
}
|
||
className="ml-auto flex items-center gap-1.5 rounded-lg border border-border px-2.5 py-1.5 text-xs font-medium text-muted transition-colors hover:bg-surface-2 hover:text-fg"
|
||
>
|
||
{collapsedCats.size === 0 ? (
|
||
<>
|
||
<ChevronRight className="h-3.5 w-3.5" /> Alle zuklappen
|
||
</>
|
||
) : (
|
||
<>
|
||
<ChevronDown className="h-3.5 w-3.5" /> Alle aufklappen
|
||
</>
|
||
)}
|
||
</button>
|
||
</div>
|
||
)}
|
||
|
||
{computed.ruinAge !== null && (
|
||
<div className="flex flex-wrap items-center gap-2 rounded-xl border border-danger bg-danger-soft px-4 py-2 text-sm font-medium text-danger">
|
||
<AlertCircle className="h-4 w-4 shrink-0" />
|
||
<span className="min-w-0 flex-1">
|
||
Kapital aufgebraucht mit Alter {computed.ruinAge} – das Gesamtvermögen (inkl. Cash) fällt danach unter 0.
|
||
</span>
|
||
{onOpenSensitivity && (
|
||
<button
|
||
type="button"
|
||
onClick={onOpenSensitivity}
|
||
className="flex shrink-0 items-center gap-1.5 rounded-lg border border-danger px-2.5 py-1 text-xs font-semibold text-danger transition-colors hover:bg-danger hover:text-white"
|
||
>
|
||
<Tornado className="h-3.5 w-3.5" /> Wirksamsten Hebel finden
|
||
</button>
|
||
)}
|
||
</div>
|
||
)}
|
||
|
||
{/* Matrix: eigener Scrollbereich, damit Phasen-Köpfe (oben) UND Elementnamen (links)
|
||
beim Scrollen sichtbar bleiben. */}
|
||
{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.
|
||
Ein w-full haette die Spalten je nach Anzahl unterschiedlich gedehnt. */}
|
||
<table className="border-collapse text-sm">
|
||
<thead>
|
||
<tr>
|
||
<th className="sticky left-0 top-0 z-40 w-56 min-w-56 border-b border-r border-border bg-surface-2 px-3 py-2 text-left text-xs font-semibold uppercase tracking-wide text-faint">
|
||
Finanzielle Elemente
|
||
</th>
|
||
{columns.map((col) =>
|
||
col.kind === "phase" ? (
|
||
<PhaseHeader
|
||
key={col.phase.id}
|
||
phase={col.phase}
|
||
personLabel={personLabel}
|
||
mode={valueMode}
|
||
diffKind={diff?.phaseHeader.get(col.phase.id) ?? null}
|
||
onClick={() => setPanel({ kind: "phase", phaseId: col.phase.id })}
|
||
onExpand={() => setDetailFor({ kind: "phase", id: col.phase.id })}
|
||
onDistributeCapital={
|
||
col.phase.sequenceNumber > 1 ? () => setDistribute({ kind: "capital", phaseId: col.phase.id }) : null
|
||
}
|
||
onDistributeRates={() => setDistribute({ kind: "rates", phaseId: col.phase.id })}
|
||
onDelete={
|
||
col.phase.sequenceNumber === computed.phases.length
|
||
? () => void deletePhase(col.phase.id, col.phase.name)
|
||
: null
|
||
}
|
||
active={panel?.kind === "phase" && panel.phaseId === col.phase.id}
|
||
/>
|
||
) : (
|
||
<TransitionHeader
|
||
key={`t-${col.fromPhase.id}`}
|
||
counts={transitionOpenCount(col.fromPhase, col.toPhase)}
|
||
onClick={() => setReviewFromPhaseId(col.fromPhase.id)}
|
||
/>
|
||
)
|
||
)}
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{/* Cash-Zeile (systemseitig, immer sichtbar, read-only). */}
|
||
<tr className="bg-surface-2/50">
|
||
<td data-tour="cash" className="sticky left-0 z-10 border-b border-r border-border bg-surface px-3 py-1.5">
|
||
<span className="flex items-center gap-1.5 text-xs font-semibold text-fg">
|
||
<Wallet className="h-4 w-4 text-accent" /> Cash
|
||
</span>
|
||
<span className="text-[10px] text-faint">verfügbares Kapital</span>
|
||
</td>
|
||
{columns.map((col) => {
|
||
const isFirst = col.kind === "phase" && col.phase.sequenceNumber === 1;
|
||
return col.kind === "phase" ? (
|
||
<td
|
||
key={`cash-${col.phase.id}`}
|
||
onClick={isFirst ? () => setPanel({ kind: "cashInitial" }) : undefined}
|
||
title={isFirst ? "Cash-Anfangswert bearbeiten" : undefined}
|
||
className={`border-b border-r border-border px-2 py-1.5 text-center text-xs ${
|
||
isFirst ? "cursor-pointer hover:bg-accent-soft" : ""
|
||
} ${col.phase.cashNegative ? "font-semibold text-danger" : "text-fg"} ${
|
||
isFirst && diff?.cashInitialChanged ? "bg-diff-soft ring-1 ring-inset ring-diff/40" : ""
|
||
}`}
|
||
>
|
||
<span className="whitespace-nowrap">
|
||
{valStr(col.phase.cashStart, col.phase.cumulativeInflationStart, valueMode)}{" "}
|
||
<span className="text-faint">→</span>{" "}
|
||
{valStr(col.phase.cashEnd, col.phase.cumulativeInflationEnd, valueMode)}
|
||
</span>
|
||
</td>
|
||
) : (
|
||
(() => {
|
||
// Übergangszelle der Cash-Zeile: einmalige Sonderein-/ausgaben.
|
||
const ct = cashTransitionFor(col.fromPhase.id);
|
||
const open = !isCashTransitionAnswered(ct);
|
||
return (
|
||
<td
|
||
key={`cash-t-${col.fromPhase.id}`}
|
||
onClick={() => setPanel({ kind: "cashTransition", fromPhaseId: col.fromPhase.id })}
|
||
title="Einmalige Sonderein-/ausgaben"
|
||
className={`cursor-pointer border-b border-r border-border px-2 py-1.5 text-center text-[11px] ${
|
||
open
|
||
? "bg-attention font-semibold text-attention-fg"
|
||
: diff?.cashTransitionCell.has(col.fromPhase.id)
|
||
? "bg-diff-soft text-diff ring-1 ring-inset ring-diff/40"
|
||
: "bg-accent-soft/40 text-accent"
|
||
}`}
|
||
>
|
||
{cashTransitionSummary(ct)}
|
||
</td>
|
||
);
|
||
})()
|
||
);
|
||
})}
|
||
</tr>
|
||
{CATEGORY_ORDER.map((cat) => {
|
||
const els = elementsByCategory.get(cat)!;
|
||
if (els.length === 0) return null;
|
||
const collapsed = collapsedCats.has(cat);
|
||
return (
|
||
<FragmentRows key={cat}>
|
||
<tr className="bg-surface-2">
|
||
<td
|
||
className="sticky left-0 z-10 cursor-pointer border-b border-r border-border bg-surface-2 px-3 py-1.5"
|
||
onClick={() =>
|
||
setCollapsedCats((prev) => {
|
||
const next = new Set(prev);
|
||
if (next.has(cat)) next.delete(cat);
|
||
else next.add(cat);
|
||
return next;
|
||
})
|
||
}
|
||
>
|
||
<span className="flex items-center gap-1.5 text-xs font-semibold text-muted">
|
||
{collapsed ? <ChevronRight className="h-3.5 w-3.5" /> : <ChevronDown className="h-3.5 w-3.5" />}
|
||
<span className="text-accent">{CATEGORY_ICON[cat]}</span>
|
||
{CATEGORY_LABELS[cat]}
|
||
</span>
|
||
</td>
|
||
{/* Zugeklappt: Summe der Elementwerte je Phase -- sonst verschwindet die
|
||
Information beim Zuklappen. Aufgeklappt bleibt die Zeile leer, weil
|
||
man die Einzelwerte darunter sieht. */}
|
||
{collapsed
|
||
? columns.map((col) =>
|
||
col.kind === "phase" ? (
|
||
<td
|
||
key={`sum-${cat}-${col.phase.id}`}
|
||
className="border-b border-r border-border bg-surface-2 px-2 py-1.5 text-center text-xs font-semibold text-muted"
|
||
>
|
||
{(() => {
|
||
const rows = els
|
||
.map((e) => computedElement(col.phase.id, e.id))
|
||
.filter((ce): ce is NonNullable<typeof ce> => !!ce && ce.status === "ACTIVE");
|
||
if (rows.length === 0) return "";
|
||
const start = rows.reduce((sum, ce) => sum + ce.startValue, 0);
|
||
const end = rows.reduce((sum, ce) => sum + ce.endValue, 0);
|
||
return (
|
||
<span className="whitespace-nowrap">
|
||
{valStr(start, col.phase.cumulativeInflationStart, valueMode)}{" "}
|
||
<span className="text-faint">→</span>{" "}
|
||
{valStr(end, col.phase.cumulativeInflationEnd, valueMode)}
|
||
</span>
|
||
);
|
||
})()}
|
||
</td>
|
||
) : (
|
||
<td key={`sum-t-${cat}-${col.fromPhase.id}`} className="border-b border-r border-border bg-surface-2" />
|
||
)
|
||
)
|
||
: <td colSpan={columns.length} className="border-b border-border" />}
|
||
</tr>
|
||
{!collapsed &&
|
||
els.map((el) => (
|
||
<tr key={el.id} className="hover:bg-surface-2">
|
||
<td className={`group/row sticky left-0 z-10 border-b border-r border-border px-3 py-1.5 ${rowDiff(el.id) || "bg-surface"}`}>
|
||
<div className="flex items-center gap-1 text-xs font-medium text-fg">
|
||
<span className="min-w-0 flex-1 truncate">{el.name}</span>
|
||
{diff?.elementRow.get(el.id) === "added" && (
|
||
<span className="rounded bg-diff-added px-1 text-[9px] font-semibold uppercase text-white">neu</span>
|
||
)}
|
||
{/* Aktionen zum Element selbst (phasenunabhängig): umbenennen,
|
||
löschen, Detailansicht -- bewusst IMMER sichtbar, damit sie
|
||
auch ohne Maus (Touch) erreichbar sind. */}
|
||
<button
|
||
type="button"
|
||
aria-label={`${el.name} umbenennen`}
|
||
title="Umbenennen und Zuordnung ändern"
|
||
onClick={(e) => {
|
||
e.stopPropagation();
|
||
setPanel({ kind: "elementMeta", elementId: el.id });
|
||
}}
|
||
className="shrink-0 rounded p-0.5 text-faint hover:bg-accent-soft hover:text-accent"
|
||
>
|
||
<Pencil className="h-3 w-3" />
|
||
</button>
|
||
<button
|
||
type="button"
|
||
aria-label={`${el.name} löschen`}
|
||
title="Element löschen"
|
||
onClick={(e) => {
|
||
e.stopPropagation();
|
||
void deleteElement(el.id, el.name);
|
||
}}
|
||
className="shrink-0 rounded p-0.5 text-faint hover:bg-danger-soft hover:text-danger"
|
||
>
|
||
<Trash2 className="h-3 w-3" />
|
||
</button>
|
||
<button
|
||
type="button"
|
||
aria-label={`Detailansicht ${el.name}`}
|
||
title="Detailansicht (nur lesen)"
|
||
onClick={(e) => {
|
||
e.stopPropagation();
|
||
setDetailFor({ kind: "element", id: el.id });
|
||
}}
|
||
className="shrink-0 rounded p-0.5 text-faint hover:bg-accent-soft hover:text-accent"
|
||
>
|
||
<Maximize2 className="h-3 w-3" />
|
||
</button>
|
||
</div>
|
||
{el.ownerRole && el.ownerRole !== "HOUSEHOLD" && (
|
||
<div className="text-[10px] text-faint">{personLabel(el.ownerRole)}</div>
|
||
)}
|
||
{/* Trend über alle Planjahre -- die per-Jahr-Werte existieren seit 0.11. */}
|
||
<Sparkline
|
||
values={computed.phases.flatMap(
|
||
(ph) => ph.elements.find((e) => e.elementId === el.id)?.yearly?.map((y) => y.value) ?? []
|
||
)}
|
||
className="mt-0.5"
|
||
/>
|
||
</td>
|
||
{columns.map((col) => {
|
||
if (col.kind === "phase") {
|
||
const ce = computedElement(col.phase.id, el.id);
|
||
return (
|
||
<td
|
||
key={col.phase.id}
|
||
onClick={() => setPanel({ kind: "cell", elementId: el.id, phaseId: col.phase.id })}
|
||
title={cellDiff(el.id, col.phase.id) ? "Weicht von der Vorlage ab" : undefined}
|
||
className={`cursor-pointer border-b border-r border-border px-2 py-1.5 text-center text-xs ${
|
||
ce?.locked ? "text-faint" : "text-fg"
|
||
} ${cellDiff(el.id, col.phase.id)}`}
|
||
>
|
||
{phaseCellContent(ce, col.phase, valueMode, planEndOf(ce?.elementId, col.phase.id))}
|
||
</td>
|
||
);
|
||
}
|
||
const locked = TRANSITION_CATEGORIES.includes(el.category) && transitionInactive(el, col.fromPhase, col.toPhase);
|
||
const canTransition = TRANSITION_CATEGORIES.includes(el.category) && !locked;
|
||
const open = canTransition && transitionUnanswered(el, col.fromPhase, col.toPhase);
|
||
return (
|
||
<td
|
||
key={`t-${col.fromPhase.id}`}
|
||
onClick={() =>
|
||
canTransition &&
|
||
setPanel({ kind: "transition", elementId: el.id, fromPhaseId: col.fromPhase.id })
|
||
}
|
||
title={transDiff(el.id, col.fromPhase.id) ? "Weicht von der Vorlage ab" : undefined}
|
||
className={`border-b border-r border-border px-2 py-1.5 text-center text-[11px] ${
|
||
canTransition ? "cursor-pointer" : "text-faint"
|
||
} ${
|
||
// Offene Entscheide tragen die Attention-Farbe (getrennt vom
|
||
// Akzent: "hier fehlt was" ist nicht "hier kannst du klicken").
|
||
open
|
||
? "bg-attention font-semibold text-attention-fg"
|
||
: transDiff(el.id, col.fromPhase.id)
|
||
? "bg-diff-soft text-diff ring-1 ring-inset ring-diff/40"
|
||
: canTransition
|
||
? "bg-accent-soft/40 text-accent"
|
||
: ""
|
||
}`}
|
||
>
|
||
{canTransition ? transitionSummary(el, col.fromPhase, col.toPhase) : locked ? "–" : "→"}
|
||
</td>
|
||
);
|
||
})}
|
||
</tr>
|
||
))}
|
||
</FragmentRows>
|
||
);
|
||
})}
|
||
{/* Geisterzeilen: in der Vorlage vorhanden, in diesem Szenario gelöscht. */}
|
||
{(diff?.removedElements ?? []).map((r) => (
|
||
<tr key={`removed-${r.id}`} className="bg-diff-removed-soft">
|
||
<td className="sticky left-0 z-10 border-b border-r border-border bg-diff-removed-soft px-3 py-1.5">
|
||
<div className="flex items-center gap-1 truncate text-xs font-medium text-diff-removed line-through">
|
||
{r.name}
|
||
</div>
|
||
<div className="text-[10px] text-diff-removed">{CATEGORY_LABELS[r.category]} · entfernt</div>
|
||
</td>
|
||
<td colSpan={columns.length} className="border-b border-border px-3 py-1.5 text-center text-[11px] text-diff-removed">
|
||
In diesem Szenario entfernt
|
||
</td>
|
||
</tr>
|
||
))}
|
||
{plan.elements.length === 0 && (diff?.removedElements.length ?? 0) === 0 && (
|
||
<tr>
|
||
<td className="sticky left-0 bg-surface px-3 py-4 text-xs text-faint" colSpan={columns.length + 1}>
|
||
Noch keine finanziellen Elemente. Füge oben dein erstes Element hinzu.
|
||
</td>
|
||
</tr>
|
||
)}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
)}
|
||
|
||
{/* Inspector-Panel: alle Einzel-Bearbeitungen. Der key erzwingt beim Wechsel der
|
||
Auswahl einen Neuaufbau, damit kein Formular-Zustand der vorherigen Zelle
|
||
übrig bleibt (gleiches Muster wie zuvor bei den Dialogen). */}
|
||
{panel && renderPanel()}
|
||
|
||
{showAdd && firstPhase && (
|
||
<AddElementDialog
|
||
plan={plan}
|
||
firstPhase={firstPhase}
|
||
initialCategory={addCategory}
|
||
onClose={() => {
|
||
setShowAdd(false);
|
||
setAddCategory(null);
|
||
}}
|
||
onCreated={() => {
|
||
setShowAdd(false);
|
||
setAddCategory(null);
|
||
toast("success", "Element angelegt.");
|
||
onChanged();
|
||
}}
|
||
/>
|
||
)}
|
||
|
||
{showAddPhase && (
|
||
<AddPhaseDialog
|
||
maxDurationYears={nextPhaseCap()}
|
||
onClose={() => setShowAddPhase(false)}
|
||
onCreate={handleAddPhase}
|
||
/>
|
||
)}
|
||
|
||
{reviewFromPhaseId && (() => {
|
||
const fromPhase = computed.phases.find((p) => p.id === reviewFromPhaseId);
|
||
if (!fromPhase) return null;
|
||
const toIndex = computed.phases.findIndex((p) => p.id === fromPhase.id) + 1;
|
||
const toPhase = computed.phases[toIndex];
|
||
const els = transitionElements(fromPhase, toPhase);
|
||
return (
|
||
<TransitionReviewDialog
|
||
plan={plan}
|
||
computed={computed}
|
||
fromPhase={fromPhase}
|
||
toPhase={toPhase}
|
||
elements={els}
|
||
initialCash={cashTransitionFor(fromPhase.id)}
|
||
buildContext={(el) => buildTransitionContext(fromPhase, toPhase, el)}
|
||
isRetirement={(el) => (toPhase ? isRetirementTransition(el, fromPhase, toPhase) : false)}
|
||
onClose={() => setReviewFromPhaseId(null)}
|
||
onSaved={() => {
|
||
setReviewFromPhaseId(null);
|
||
onChanged();
|
||
}}
|
||
/>
|
||
);
|
||
})()}
|
||
|
||
{detailFor?.kind === "element" && explained && (() => {
|
||
const el = plan.elements.find((e) => e.id === detailFor.id);
|
||
if (!el) return null;
|
||
return (
|
||
<ElementDetailDialog
|
||
key={`det-${detailFor.id}`}
|
||
elementId={el.id}
|
||
name={el.name}
|
||
category={el.category}
|
||
computed={explained}
|
||
onClose={() => setDetailFor(null)}
|
||
onOpenSpec={onOpenSpec}
|
||
/>
|
||
);
|
||
})()}
|
||
|
||
{detailFor?.kind === "phase" && explained && (() => {
|
||
const ph = explained.phases.find((p) => p.id === detailFor.id);
|
||
if (!ph) return null;
|
||
return (
|
||
<PhaseDetailDialog
|
||
key={`detph-${detailFor.id}`}
|
||
phase={ph}
|
||
computed={computed}
|
||
isFirst={ph.sequenceNumber === 1}
|
||
onClose={() => setDetailFor(null)}
|
||
onOpenSpec={onOpenSpec}
|
||
/>
|
||
);
|
||
})()}
|
||
|
||
{distribute?.kind === "capital" && (
|
||
<CapitalDistributionDialog
|
||
key={`cap-${distribute.phaseId}`}
|
||
plan={plan}
|
||
computed={computed}
|
||
phaseId={distribute.phaseId}
|
||
onClose={() => setDistribute(null)}
|
||
onSaved={() => {
|
||
setDistribute(null);
|
||
toast("success", "Kapital verteilt.");
|
||
onChanged();
|
||
}}
|
||
/>
|
||
)}
|
||
|
||
{distribute?.kind === "rates" && (
|
||
<RateDistributionDialog
|
||
key={`rate-${distribute.phaseId}`}
|
||
plan={plan}
|
||
computed={computed}
|
||
phaseId={distribute.phaseId}
|
||
onClose={() => setDistribute(null)}
|
||
onSaved={() => {
|
||
setDistribute(null);
|
||
toast("success", "Raten gespeichert.");
|
||
onChanged();
|
||
}}
|
||
/>
|
||
)}
|
||
</div>
|
||
);
|
||
|
||
// Nächste Phasen-Kappung (für das Phase-Popup).
|
||
// Element loeschen -- neu auch direkt aus der Zeile heraus.
|
||
async function deleteElement(elementId: string, elementName: string) {
|
||
const ok = await confirmDialog({
|
||
title: "Element löschen?",
|
||
message: `«${elementName}» wird aus ALLEN Lebensphasen entfernt, samt Werten und Übergangs-Entscheiden.`,
|
||
confirmLabel: "Endgültig löschen",
|
||
danger: true,
|
||
});
|
||
if (!ok) return;
|
||
try {
|
||
await api.delete(`/api/elements/${elementId}`);
|
||
setPanel(null);
|
||
onChanged();
|
||
toast("success", "Element gelöscht.");
|
||
} catch (e) {
|
||
toast("error", e instanceof Error ? e.message : "Löschen fehlgeschlagen.");
|
||
}
|
||
}
|
||
|
||
async function deletePhase(phaseId: string, phaseName: string) {
|
||
const ok = await confirmDialog({
|
||
title: "Lebensphase löschen?",
|
||
message: `«${phaseName}» wird mit allen erfassten Werten und Übergangs-Entscheiden entfernt.`,
|
||
confirmLabel: "Endgültig löschen",
|
||
danger: true,
|
||
});
|
||
if (!ok) return;
|
||
try {
|
||
await api.delete(`/api/phases/${phaseId}`);
|
||
setPanel(null);
|
||
onChanged();
|
||
toast("success", "Lebensphase gelöscht.");
|
||
} catch (e) {
|
||
toast("error", e instanceof Error ? e.message : "Löschen fehlgeschlagen.");
|
||
}
|
||
}
|
||
|
||
function nextPhaseCap(): number | null {
|
||
// Simpel aus den Personen ableiten (Jahre nach Planbeginn = Summe der Dauern).
|
||
const yearsBefore = plan.phases.reduce((s, p) => s + p.durationYears, 0);
|
||
const caps = plan.persons
|
||
.map((p) => p.retirementAge - (p.age + yearsBefore))
|
||
.filter((d) => d > 0);
|
||
return caps.length > 0 ? Math.min(...caps) : null;
|
||
}
|
||
|
||
// Inhalt des Inspector-Panels je nach Auswahl.
|
||
function renderPanel() {
|
||
if (!panel) return null;
|
||
const close = () => setPanel(null);
|
||
const closeAndRefresh = () => {
|
||
setPanel(null);
|
||
onChanged();
|
||
};
|
||
|
||
switch (panel.kind) {
|
||
case "elementMeta": {
|
||
const el = plan.elements.find((e) => e.id === panel.elementId);
|
||
if (!el) return null;
|
||
return (
|
||
<InspectorShell key={`meta-${panel.elementId}`} title={`Element: ${el.name}`} onClose={close}>
|
||
<ElementMetaPanel
|
||
element={el}
|
||
plan={plan}
|
||
personLabel={personLabel}
|
||
onSaved={closeAndRefresh}
|
||
onDelete={() => deleteElement(el.id, el.name)}
|
||
/>
|
||
</InspectorShell>
|
||
);
|
||
}
|
||
case "phase": {
|
||
const phase = computed.phases.find((p) => p.id === panel.phaseId);
|
||
const phaseInput = plan.phases.find((p) => p.id === panel.phaseId);
|
||
if (!phase || !phaseInput) return null;
|
||
return (
|
||
<InspectorShell key={`ph-${panel.phaseId}`} title={`Lebensphase: ${phase.name}`} onClose={close}>
|
||
<PhaseDetail
|
||
phase={phaseInput}
|
||
allPhases={plan.phases}
|
||
maxDurationYears={phase.maxDurationYears}
|
||
isLast={phase.sequenceNumber === computed.phases.length}
|
||
laterPhaseCount={laterPhaseCount(phase)}
|
||
onSaved={closeAndRefresh}
|
||
onDeleted={closeAndRefresh}
|
||
/>
|
||
</InspectorShell>
|
||
);
|
||
}
|
||
case "cell": {
|
||
const element = plan.elements.find((e) => e.id === panel.elementId);
|
||
const phase = computed.phases.find((p) => p.id === panel.phaseId);
|
||
if (!element || !phase) return null;
|
||
return (
|
||
<InspectorShell
|
||
key={`${panel.elementId}-${panel.phaseId}`}
|
||
title={element.name}
|
||
subtitle={`Lebensphase: ${phase.name}`}
|
||
onClose={close}
|
||
>
|
||
<ElementDetail
|
||
element={element}
|
||
context={buildPhaseContext(phase, element)}
|
||
phaseData={element.phaseValues[phase.id] ?? {}}
|
||
// Für die Übernahme von Raten über Phasen hinweg: alle Phasen und die dort
|
||
// bereits erfassten Werte (sie müssen beim Übertragen erhalten bleiben).
|
||
allPhases={plan.phases.map((p) => ({ id: p.id, sequenceNumber: p.sequenceNumber }))}
|
||
phaseDataByPhase={element.phaseValues}
|
||
transitionData={{}}
|
||
onSaved={closeAndRefresh}
|
||
onDeleteElement={() => {
|
||
setPanel(null);
|
||
deleteElement(element.id, element.name);
|
||
}}
|
||
/>
|
||
</InspectorShell>
|
||
);
|
||
}
|
||
case "transition": {
|
||
const element = plan.elements.find((e) => e.id === panel.elementId);
|
||
const fromPhase = computed.phases.find((p) => p.id === panel.fromPhaseId);
|
||
if (!element || !fromPhase) return null;
|
||
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}
|
||
toPhase={toPhase}
|
||
context={buildTransitionContext(fromPhase, toPhase, element)}
|
||
onClose={close}
|
||
onSaved={closeAndRefresh}
|
||
/>
|
||
);
|
||
}
|
||
case "cashTransition": {
|
||
const fromPhase = computed.phases.find((p) => p.id === panel.fromPhaseId);
|
||
if (!fromPhase) return null;
|
||
const toPhase = computed.phases[computed.phases.findIndex((p) => p.id === fromPhase.id) + 1];
|
||
return (
|
||
<CashTransitionPanel
|
||
key={`ct-${panel.fromPhaseId}`}
|
||
fromPhase={fromPhase}
|
||
toPhase={toPhase}
|
||
initial={cashTransitionFor(fromPhase.id)}
|
||
onClose={close}
|
||
onSaved={closeAndRefresh}
|
||
/>
|
||
);
|
||
}
|
||
case "cashInitial":
|
||
return <CashInitialPanel key="cash-init" plan={plan} onClose={close} onSaved={closeAndRefresh} />;
|
||
case "profile":
|
||
return <ProfilePanel key="profile" plan={plan} onClose={close} onSaved={closeAndRefresh} />;
|
||
}
|
||
}
|
||
|
||
|
||
function transitionSummary(el: ElementInput, fromPhase: PhaseComputed, toPhase: PhaseComputed): string {
|
||
const td = el.transitionValues[fromPhase.id] ?? {};
|
||
switch (el.category) {
|
||
case "AHV":
|
||
return td.reviewed === true ? "Geprüft" : "?";
|
||
case "OTHER_ASSET":
|
||
return td.decision === "SELL"
|
||
? "Verkauf"
|
||
: td.decision === "PARTIAL"
|
||
? `Teilverkauf −${formatChf(num(td.partialSaleAmount))}`
|
||
: td.decision === "HOLD"
|
||
? "Halten"
|
||
: "?";
|
||
case "REAL_ESTATE":
|
||
if (td.decision === "SELL") return "Verkauf";
|
||
if (td.decision === undefined) return "?";
|
||
return num(td.extraAmortization) > 0 ? `Sonderamort. −${formatChf(num(td.extraAmortization))}` : "Halten";
|
||
case "PENSION_FUND":
|
||
if (isRetirementTransition(el, fromPhase, toPhase)) {
|
||
return td.payoutMode === "CAPITAL" ? "Kapital" : td.payoutMode === "COMBI" ? "Kombi" : td.payoutMode === "PENSION" ? "Rente" : "?";
|
||
}
|
||
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";
|
||
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:
|
||
return "→";
|
||
}
|
||
}
|
||
}
|
||
|
||
// Zellinhalt: Start- UND Zielwert für wertbehaftete Elemente, sonst die Kennzahl.
|
||
const START_END_CATEGORIES: ElementCategory[] = [...VALUE_CATEGORIES, "INCOME", "EXPENSE"];
|
||
|
||
type ValueMode = "nominal" | "both" | "real";
|
||
const VALUE_MODE_KEY = "fpt-value-mode";
|
||
|
||
// Realwert = nominal / kumulierte Inflation (Kaufkraft zum Planbeginn).
|
||
function realOf(nominal: number, deflator: number): number {
|
||
return Math.round(nominal / (deflator || 1));
|
||
}
|
||
|
||
// Reiner Text (fuer Titel/Tooltips und einzeilige Faelle).
|
||
function valStr(nominal: number, deflator: number, mode: ValueMode): string {
|
||
if (mode === "real") return formatChf(realOf(nominal, deflator));
|
||
if (mode === "both") return `${formatChf(nominal)} (${formatChf(realOf(nominal, deflator))})`;
|
||
return formatChf(nominal);
|
||
}
|
||
|
||
// Im Modus "Beide" steht der Realwert in Klammern in einer EIGENEN Zeile unter dem nominalen
|
||
// Wert -- nebeneinander wird die Zeile zu lang und die Spalten unnoetig breit. Der Pfeil
|
||
// wiederholt sich auf der zweiten Zeile, damit der Zeitbezug Start -> Ende erhalten bleibt.
|
||
function ValuePair({
|
||
start,
|
||
end,
|
||
deflatorStart,
|
||
deflatorEnd,
|
||
mode,
|
||
devEnd,
|
||
}: {
|
||
start: number;
|
||
end: number;
|
||
deflatorStart: number;
|
||
deflatorEnd: number;
|
||
mode: ValueMode;
|
||
// Abweichung des ENDwerts gegenüber dem Plan (nur in der Ist-Sicht gesetzt). Bewusst nur
|
||
// ein Wert statt Start und Ende: Die Zelle soll nicht zur zweiten Tabelle werden.
|
||
devEnd?: number | null;
|
||
}) {
|
||
const arrow = <span className="text-faint">→</span>;
|
||
return (
|
||
<span className="inline-flex flex-col">
|
||
<span className="whitespace-nowrap tabular-nums">
|
||
{mode === "real" ? formatChf(realOf(start, deflatorStart)) : formatChf(start)} {arrow}{" "}
|
||
{mode === "real" ? formatChf(realOf(end, deflatorEnd)) : formatChf(end)}
|
||
<Deviation value={devEnd} deflator={mode === "real" ? deflatorEnd : 1} />
|
||
</span>
|
||
{mode === "both" && (
|
||
<span className="whitespace-nowrap tabular-nums text-faint">
|
||
({formatChf(realOf(start, deflatorStart))}) {arrow} ({formatChf(realOf(end, deflatorEnd))})
|
||
<Deviation value={devEnd} deflator={deflatorEnd} />
|
||
</span>
|
||
)}
|
||
</span>
|
||
);
|
||
}
|
||
|
||
// Die Abweichung trägt als einziges Element Farbe -- würde man die Beträge selbst einfärben,
|
||
// entstünde ein Ampelteppich, in dem nichts mehr heraussticht.
|
||
function Deviation({ value, deflator = 1 }: { value?: number | null; deflator?: number }) {
|
||
if (value == null || Math.round(value / (deflator || 1)) === 0) return null;
|
||
const v = Math.round(value / (deflator || 1));
|
||
return (
|
||
<span className={`ml-1.5 font-semibold ${v > 0 ? "text-success" : "text-danger"}`}>
|
||
{v > 0 ? "▲ +" : "▼ −"}
|
||
{formatChf(Math.abs(v))}
|
||
</span>
|
||
);
|
||
}
|
||
|
||
// Einzelwert, gleiche Konvention.
|
||
function ValueSingle({ value, deflator, mode }: { value: number; deflator: number; mode: ValueMode }) {
|
||
return (
|
||
<span className="inline-flex flex-col">
|
||
<span className="whitespace-nowrap tabular-nums">
|
||
{mode === "real" ? formatChf(realOf(value, deflator)) : formatChf(value)}
|
||
</span>
|
||
{mode === "both" && (
|
||
<span className="whitespace-nowrap tabular-nums text-faint">({formatChf(realOf(value, deflator))})</span>
|
||
)}
|
||
</span>
|
||
);
|
||
}
|
||
|
||
function phaseCellContent(
|
||
ce: ReturnType<PhaseComputed["elements"]["find"]> | undefined,
|
||
phase: PhaseComputed,
|
||
mode: ValueMode,
|
||
// Endwert desselben Elements in derselben Phase laut PLAN -- null in der Plan-Sicht.
|
||
planEnd?: number | null
|
||
): React.ReactNode {
|
||
if (!ce) return "–";
|
||
if (ce.note) return ce.note;
|
||
const isFlow = ce.category === "INCOME" || ce.category === "EXPENSE";
|
||
const dS = phase.cumulativeInflationStart;
|
||
const dE = isFlow ? phase.flowDeflatorEnd : phase.cumulativeInflationEnd;
|
||
const devEnd = planEnd == null ? null : ce.endValue - planEnd;
|
||
if (START_END_CATEGORIES.includes(ce.category) && (ce.startValue !== 0 || ce.endValue !== 0)) {
|
||
return (
|
||
<ValuePair start={ce.startValue} end={ce.endValue} deflatorStart={dS} deflatorEnd={dE} mode={mode} devEnd={devEnd} />
|
||
);
|
||
}
|
||
if ((ce.category === "AHV" || ce.category === "PENSION_FUND") && ce.startValue !== 0) {
|
||
return (
|
||
<span className="inline-flex flex-col">
|
||
<span className="whitespace-nowrap">Rente {mode === "real" ? formatChf(realOf(ce.startValue, dS)) : formatChf(ce.startValue)}</span>
|
||
{mode === "both" && (
|
||
<span className="whitespace-nowrap tabular-nums text-faint">({formatChf(realOf(ce.startValue, dS))})</span>
|
||
)}
|
||
</span>
|
||
);
|
||
}
|
||
return ce.summary || "–";
|
||
}
|
||
|
||
function PhaseHeader({
|
||
phase,
|
||
personLabel,
|
||
mode,
|
||
diffKind,
|
||
onClick,
|
||
onExpand,
|
||
onDistributeCapital,
|
||
onDistributeRates,
|
||
onDelete,
|
||
active,
|
||
}: {
|
||
phase: PhaseComputed;
|
||
personLabel: (role: string) => string;
|
||
mode: ValueMode;
|
||
diffKind: "changed" | "added" | "removed" | null;
|
||
onClick: () => void;
|
||
onExpand: () => void;
|
||
// null = Phase 1: dort gibt es kein verteilbares Übergangs-Kapital (additionalInvestment
|
||
// wird in der ersten Phase von der Berechnung ignoriert, dort zählt der Startwert).
|
||
onDistributeCapital: (() => void) | null;
|
||
onDistributeRates: () => void;
|
||
// null = nicht die letzte Phase (nur die letzte lässt sich löschen).
|
||
onDelete: (() => void) | null;
|
||
active: boolean;
|
||
}) {
|
||
const quotaLabel = phase.isConsumption ? "Verzehrquote" : "Sparquote";
|
||
const pot = capitalPot(phase);
|
||
const dS = phase.cumulativeInflationStart;
|
||
const dE = phase.cumulativeInflationEnd; // Bestandswerte (Cash, Vermögen)
|
||
const dF = phase.flowDeflatorEnd; // Flow-Werte (Einkommen, Ausgaben, Quote)
|
||
return (
|
||
<th
|
||
onClick={onClick}
|
||
className={`group/ph sticky top-0 z-30 w-52 min-w-52 max-w-52 cursor-pointer border-b border-r border-border px-2 py-2 text-left align-top ${
|
||
active
|
||
? "bg-accent-soft"
|
||
: diffKind === "added"
|
||
? "bg-diff-added-soft"
|
||
: diffKind === "changed"
|
||
? "bg-diff-soft"
|
||
: "bg-surface"
|
||
}`}
|
||
>
|
||
<div className="flex items-center gap-1">
|
||
<span className="min-w-0 flex-1 truncate text-xs font-semibold text-fg">{phase.name}</span>
|
||
{diffKind === "added" && (
|
||
<span className="rounded bg-diff-added px-1 text-[9px] font-semibold uppercase text-white">neu</span>
|
||
)}
|
||
{phase.cashNegative ? (
|
||
<AlertCircle className="h-3.5 w-3.5 shrink-0 text-danger" />
|
||
) : (
|
||
<CheckCircle2 className="h-3.5 w-3.5 shrink-0 text-success" />
|
||
)}
|
||
{/* Aktionen zur Phase. Der Kopf selbst öffnet ebenfalls das Bearbeiten-Panel; die
|
||
Knöpfe müssen das Event deshalb stoppen, sonst gingen zwei Dinge gleichzeitig auf.
|
||
Bewusst IMMER sichtbar (Touch hat kein Hover). */}
|
||
<button
|
||
type="button"
|
||
aria-label={`${phase.name} bearbeiten`}
|
||
title="Bezeichnung und Dauer ändern"
|
||
onClick={(e) => {
|
||
e.stopPropagation();
|
||
onClick();
|
||
}}
|
||
className="shrink-0 rounded p-0.5 text-faint hover:bg-accent-soft hover:text-accent"
|
||
>
|
||
<Pencil className="h-3 w-3" />
|
||
</button>
|
||
{onDelete && (
|
||
<button
|
||
type="button"
|
||
aria-label={`${phase.name} löschen`}
|
||
title="Lebensphase löschen"
|
||
onClick={(e) => {
|
||
e.stopPropagation();
|
||
onDelete();
|
||
}}
|
||
className="shrink-0 rounded p-0.5 text-faint hover:bg-danger-soft hover:text-danger"
|
||
>
|
||
<Trash2 className="h-3 w-3" />
|
||
</button>
|
||
)}
|
||
<button
|
||
type="button"
|
||
aria-label={`Detailansicht ${phase.name}`}
|
||
title="Detailansicht (nur lesen)"
|
||
onClick={(e) => {
|
||
e.stopPropagation();
|
||
onExpand();
|
||
}}
|
||
className="shrink-0 rounded p-0.5 text-faint hover:bg-accent-soft hover:text-accent"
|
||
>
|
||
<Maximize2 className="h-3 w-3" />
|
||
</button>
|
||
</div>
|
||
<div className="mt-0.5 flex flex-wrap gap-1">
|
||
<span className="rounded bg-surface-2 px-1 text-[10px] text-muted">
|
||
{phase.type === "ERWERB" ? "Erwerb" : phase.type === "PENSION" ? "Pension" : "Misch"}
|
||
</span>
|
||
<span className="text-[10px] text-faint">{phase.durationYears} J.</span>
|
||
</div>
|
||
<div className="mt-1 flex flex-col gap-1 text-[10px] leading-tight text-muted">
|
||
<div>
|
||
{phase.persons.map((p) => (
|
||
<div key={p.role} className="whitespace-nowrap text-faint">
|
||
{personLabel(p.role)} {p.startAge} → {p.endAge}
|
||
</div>
|
||
))}
|
||
</div>
|
||
|
||
{/* Verfügbares Kapital -- die Grösse, die man für die Planung DIESER Phase braucht.
|
||
Nur zeigen, wenn überhaupt etwas da ist. */}
|
||
{onDistributeCapital && pot.total > 0 && (
|
||
<div className="rounded-md bg-surface-2 px-1.5 py-1">
|
||
<div className="flex items-baseline justify-between gap-2 whitespace-nowrap">
|
||
<span className="font-medium text-fg">Verfügbares Kapital</span>
|
||
<ValueSingle value={pot.total} deflator={dS} mode={mode} />
|
||
</div>
|
||
{pot.allocatedInvestments + pot.allocatedRepayments > 0 && (
|
||
<div className="flex items-baseline justify-between gap-2 whitespace-nowrap pl-2 text-faint">
|
||
<span>davon verteilt</span>
|
||
<ValueSingle value={pot.allocatedInvestments + pot.allocatedRepayments} deflator={dS} mode={mode} />
|
||
</div>
|
||
)}
|
||
<div className="flex items-baseline justify-between gap-2 whitespace-nowrap pl-2 text-faint">
|
||
<span>Rest auf Cash</span>
|
||
<ValueSingle value={pot.rest} deflator={dS} mode={mode} />
|
||
</div>
|
||
<button
|
||
type="button"
|
||
onClick={(e) => {
|
||
e.stopPropagation();
|
||
onDistributeCapital();
|
||
}}
|
||
className="mt-1 w-full rounded border border-accent px-1.5 py-0.5 text-[10px] font-semibold text-accent transition-colors hover:bg-accent hover:text-accent-fg"
|
||
>
|
||
Kapital verteilen
|
||
</button>
|
||
</div>
|
||
)}
|
||
|
||
{/* Spar- bzw. Verzehrquote */}
|
||
<div className="rounded-md bg-surface-2 px-1.5 py-1">
|
||
<div className="flex items-baseline justify-between gap-2 whitespace-nowrap">
|
||
<span className={`font-medium ${phase.isConsumption ? "text-danger" : "text-fg"}`}>{quotaLabel}</span>
|
||
<ValuePair start={phase.quotaStart} end={phase.quotaEnd} deflatorStart={dS} deflatorEnd={dF} mode={mode} />
|
||
</div>
|
||
<button
|
||
type="button"
|
||
onClick={(e) => {
|
||
e.stopPropagation();
|
||
onDistributeRates();
|
||
}}
|
||
className="mt-1 w-full rounded border border-accent px-1.5 py-0.5 text-[10px] font-semibold text-accent transition-colors hover:bg-accent hover:text-accent-fg"
|
||
>
|
||
{phase.isConsumption ? "Bezug verteilen" : "Sparquote verteilen"}
|
||
</button>
|
||
</div>
|
||
|
||
<div className="flex items-baseline justify-between gap-2 whitespace-nowrap font-medium text-fg">
|
||
<span>Vermögen</span>
|
||
<ValuePair
|
||
start={phase.startWealthNominal}
|
||
end={phase.endWealthNominal}
|
||
deflatorStart={dS}
|
||
deflatorEnd={dE}
|
||
mode={mode}
|
||
/>
|
||
</div>
|
||
|
||
{(phase.oneOffInflow > 0 || phase.oneOffOutflow > 0) && (
|
||
<div className={`whitespace-nowrap ${phase.oneOffOutflow > phase.oneOffInflow ? "text-danger" : "text-success"}`}>
|
||
{phase.oneOffInflow > 0 ? `+ ${phase.oneOffInflowLabel ?? "Zufluss"}` : ""}
|
||
{phase.oneOffInflow > 0 && phase.oneOffOutflow > 0 ? " · " : ""}
|
||
{phase.oneOffOutflow > 0 ? `− ${phase.oneOffOutflowLabel ?? "Kosten"}` : ""}
|
||
</div>
|
||
)}
|
||
{/* Alles Weitere (Einkommen, Ausgaben, Raten im Detail, Kapitalinvestitionen) steht
|
||
in der Detailansicht -- erreichbar über das Expand-Icon oben. */}
|
||
</div>
|
||
</th>
|
||
);
|
||
}
|
||
|
||
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"
|
||
: onlyDefaults
|
||
? "bg-surface-2 text-muted"
|
||
: "bg-attention text-attention-fg"
|
||
}`}
|
||
>
|
||
<div>Übergang</div>
|
||
{done ? (
|
||
<div className="mt-1 flex items-center justify-center gap-1 text-[10px] font-semibold">
|
||
<CheckCircle2 className="h-3 w-3" /> geprüft
|
||
</div>
|
||
) : (
|
||
<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>
|
||
);
|
||
}
|
||
|
||
function FragmentRows({ children }: { children: React.ReactNode }) {
|
||
return <>{children}</>;
|
||
}
|
||
|
||
// --- "Nächste Schritte": abgeleitete Checkliste ------------------------------------------
|
||
// Führt neue (und wiederkehrende) Nutzer zum jeweils nächsten sinnvollen Schritt, statt
|
||
// sie vor der Matrix raten zu lassen. Alles wird aus den vorhandenen Daten abgeleitet --
|
||
// kein eigener Zustand, kein API-Aufruf, verschwindet von selbst, wenn nichts offen ist.
|
||
function NextSteps({
|
||
plan,
|
||
computed,
|
||
columns,
|
||
openCountFor,
|
||
onReview,
|
||
onAddElement,
|
||
onAddPhase,
|
||
onOpenSensitivity,
|
||
}: {
|
||
plan: PlanInput;
|
||
computed: PlanComputed;
|
||
columns: Column[];
|
||
openCountFor: (fromPhase: PhaseComputed, toPhase: PhaseComputed) => DecisionCounts;
|
||
onReview: (fromPhaseId: string) => void;
|
||
onAddElement: (category: ElementCategory) => void;
|
||
onAddPhase: () => void;
|
||
onOpenSensitivity?: () => void;
|
||
}) {
|
||
if (computed.phases.length === 0) return null;
|
||
|
||
const items: { key: string; text: string; action: string; run: () => void }[] = [];
|
||
|
||
// Offene Übergangs-Entscheide (inkl. Cash) -- der wichtigste Handlungsbedarf.
|
||
let openTotal = 0;
|
||
let firstOpenPhaseId: string | null = null;
|
||
for (const col of columns) {
|
||
if (col.kind !== "transition") continue;
|
||
const n = openCountFor(col.fromPhase, col.toPhase);
|
||
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. ob eine Immobilie verkauft wird.`,
|
||
action: "Jetzt durchgehen",
|
||
run: () => onReview(target),
|
||
});
|
||
}
|
||
|
||
if (!plan.elements.some((e) => e.category === "INCOME")) {
|
||
items.push({
|
||
key: "income",
|
||
text: "Noch kein Einkommen erfasst – ohne Einkommen gibt es keine Sparquote und keine AHV-Rente.",
|
||
action: "Einkommen erfassen",
|
||
run: () => onAddElement("INCOME"),
|
||
});
|
||
}
|
||
if (!plan.elements.some((e) => e.category === "EXPENSE")) {
|
||
items.push({
|
||
key: "expense",
|
||
text: "Noch keine Ausgaben erfasst – sie sind erfahrungsgemäss der stärkste Hebel der ganzen Planung.",
|
||
action: "Ausgaben erfassen",
|
||
run: () => onAddElement("EXPENSE"),
|
||
});
|
||
}
|
||
|
||
// Plan endet vor der Pensionierung: die spannendste Phase fehlt noch.
|
||
const totalYears = computed.phases.reduce((s, p) => s + p.durationYears, 0);
|
||
if (plan.persons.some((p) => p.age + totalYears < p.retirementAge)) {
|
||
items.push({
|
||
key: "pension-phase",
|
||
text: "Dein Plan endet vor der Pensionierung – die entscheidende Phase fehlt noch.",
|
||
action: "Phase anlegen",
|
||
run: onAddPhase,
|
||
});
|
||
}
|
||
|
||
if (computed.ruinAge !== null && onOpenSensitivity) {
|
||
items.push({
|
||
key: "ruin",
|
||
text: `Das Kapital reicht nicht bis ans Planende (Ruin mit ${computed.ruinAge}). Finde heraus, welche Annahme am meisten bewegt.`,
|
||
action: "Einflussfaktoren",
|
||
run: onOpenSensitivity,
|
||
});
|
||
}
|
||
|
||
if (items.length === 0) return null;
|
||
|
||
return (
|
||
<div className="rounded-xl border border-attention-soft-fg/30 bg-attention-soft px-4 py-3">
|
||
<div className="mb-2 flex items-center gap-2 text-xs font-semibold uppercase tracking-wide text-attention-soft-fg">
|
||
<ListTodo className="h-4 w-4" /> Nächste Schritte
|
||
</div>
|
||
<ul className="flex flex-col gap-1.5">
|
||
{items.map((it) => (
|
||
<li key={it.key} className="flex flex-wrap items-center gap-2 text-sm text-fg">
|
||
<span className="min-w-0 flex-1">{it.text}</span>
|
||
<button
|
||
type="button"
|
||
onClick={it.run}
|
||
className="flex shrink-0 items-center gap-1 rounded-lg border border-attention-soft-fg/40 px-2.5 py-1 text-xs font-semibold text-attention-soft-fg transition-colors hover:bg-attention hover:text-attention-fg"
|
||
>
|
||
{it.action} <ArrowRight className="h-3 w-3" />
|
||
</button>
|
||
</li>
|
||
))}
|
||
</ul>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// --- Dialog: neues finanzielles Element mit Direkteingabe der Phase-1-Werte ---
|
||
function AddElementDialog({
|
||
plan,
|
||
firstPhase,
|
||
initialCategory,
|
||
onClose,
|
||
onCreated,
|
||
}: {
|
||
plan: PlanInput;
|
||
firstPhase: PhaseComputed;
|
||
initialCategory?: ElementCategory | null;
|
||
onClose: () => void;
|
||
onCreated: () => void;
|
||
}) {
|
||
const [category, setCategory] = useState<ElementCategory>(initialCategory ?? "INCOME");
|
||
const [name, setName] = useState("");
|
||
const [ownerRole, setOwnerRole] = useState<string>("PERSON_A");
|
||
const [pd, setPd] = useState<PhaseData>({});
|
||
const [saving, setSaving] = useState(false);
|
||
const [error, setError] = useState<string | null>(null);
|
||
|
||
const needsPerson = PERSON_ONLY_CATEGORIES.includes(category);
|
||
const isCouple = plan.householdType === "COUPLE";
|
||
const pLabel = (role: string) => {
|
||
const p = plan.persons.find((x) => x.role === role);
|
||
return p?.name && p.name.trim() ? p.name.trim() : role === "PERSON_A" ? "Person A" : "Person B";
|
||
};
|
||
|
||
const ownerOptions = needsPerson
|
||
? isCouple
|
||
? [
|
||
{ value: "PERSON_A", label: pLabel("PERSON_A") },
|
||
{ value: "PERSON_B", label: pLabel("PERSON_B") },
|
||
]
|
||
: [{ value: "PERSON_A", label: pLabel("PERSON_A") }]
|
||
: isCouple
|
||
? [
|
||
{ value: "HOUSEHOLD", label: "Gemeinsam" },
|
||
{ value: "PERSON_A", label: pLabel("PERSON_A") },
|
||
{ value: "PERSON_B", label: pLabel("PERSON_B") },
|
||
]
|
||
: [
|
||
{ value: "HOUSEHOLD", label: "Gemeinsam" },
|
||
{ value: "PERSON_A", label: pLabel("PERSON_A") },
|
||
];
|
||
|
||
// Kontext für die Phase-1-Felder des neuen Elements.
|
||
const owner = needsPerson || ownerRole !== "HOUSEHOLD" ? ownerRole : null;
|
||
const ownerWorking =
|
||
owner && owner !== "HOUSEHOLD"
|
||
? firstPhase.persons.find((p) => p.role === owner)?.working ?? false
|
||
: firstPhase.type !== "PENSION";
|
||
const context: CellContext = {
|
||
kind: "phase",
|
||
phaseId: firstPhase.id,
|
||
ownerWorking,
|
||
isConsumption: firstPhase.isConsumption,
|
||
durationYears: firstPhase.durationYears,
|
||
isRetirementTransition: false,
|
||
carriedEndValue: 0,
|
||
carried: false,
|
||
derivedStart: 0,
|
||
derivedMortgage: 0,
|
||
mortgageEnd: 0,
|
||
propertyValueEnd: 0,
|
||
deflatorStart: firstPhase.cumulativeInflationStart,
|
||
laterPhaseCount: 0, // beim Anlegen bewusst kein Warnhinweis
|
||
ahvCareer: null,
|
||
// Beim Anlegen gibt es keine Vorphase, aus der etwas zu erben waere.
|
||
inheritedValues: {},
|
||
ownerAgeStart: firstPhase.persons.find((p) => p.role === (owner ?? "PERSON_A"))?.startAge ?? 0,
|
||
investTargets: [],
|
||
capitalFromTransfer: 0,
|
||
capitalFromTransferSources: [],
|
||
};
|
||
|
||
async function create() {
|
||
setSaving(true);
|
||
setError(null);
|
||
try {
|
||
const { element } = await api.post<{ element: { id: string } }>(`/api/scenarios/${plan.id}/elements`, {
|
||
category,
|
||
name: name.trim() || CATEGORY_LABELS[category],
|
||
ownerRole,
|
||
});
|
||
// Ist-Zustand direkt in Phase 1 speichern (sofern Felder ausgefuellt).
|
||
if (Object.keys(pd).length > 0) {
|
||
await api.put(`/api/elements/${element.id}/phase/${firstPhase.id}`, pd);
|
||
}
|
||
onCreated();
|
||
} catch (e) {
|
||
setError(e instanceof Error ? e.message : "Erstellen fehlgeschlagen.");
|
||
} finally {
|
||
setSaving(false);
|
||
}
|
||
}
|
||
|
||
return (
|
||
<DialogShell title="Finanzielles Element" onClose={onClose} wide>
|
||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||
<div>
|
||
<label className="mb-1 block text-xs font-medium text-muted">Kategorie</label>
|
||
<select
|
||
value={category}
|
||
onChange={(e) => {
|
||
const c = e.target.value as ElementCategory;
|
||
setCategory(c);
|
||
setPd({});
|
||
if (PERSON_ONLY_CATEGORIES.includes(c) && ownerRole === "HOUSEHOLD") setOwnerRole("PERSON_A");
|
||
if (!name) setName(CATEGORY_LABELS[c]);
|
||
}}
|
||
className="w-full rounded-lg border border-border bg-input px-3 py-2 text-sm text-fg focus:border-accent focus:outline-none focus:ring-2 focus:ring-accent/25"
|
||
>
|
||
{CATEGORY_ORDER.map((c) => (
|
||
<option key={c} value={c}>
|
||
{CATEGORY_LABELS[c]}
|
||
</option>
|
||
))}
|
||
</select>
|
||
</div>
|
||
<div>
|
||
<label className="mb-1 block text-xs font-medium text-muted">Zuordnung</label>
|
||
<select
|
||
value={ownerRole}
|
||
onChange={(e) => setOwnerRole(e.target.value)}
|
||
className="w-full rounded-lg border border-border bg-input px-3 py-2 text-sm text-fg focus:border-accent focus:outline-none focus:ring-2 focus:ring-accent/25"
|
||
>
|
||
{ownerOptions.map((o) => (
|
||
<option key={o.value} value={o.value}>
|
||
{o.label}
|
||
</option>
|
||
))}
|
||
</select>
|
||
</div>
|
||
<div className="sm:col-span-2">
|
||
<label className="mb-1 block text-xs font-medium text-muted">Bezeichnung</label>
|
||
<input
|
||
value={name}
|
||
onChange={(e) => setName(e.target.value)}
|
||
placeholder={CATEGORY_LABELS[category]}
|
||
className="w-full rounded-lg border border-border bg-input px-3 py-2 text-sm text-fg focus:border-accent focus:outline-none focus:ring-2 focus:ring-accent/25"
|
||
/>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="mt-1 border-t border-border pt-3">
|
||
<div className="mb-2 text-xs font-semibold uppercase tracking-wide text-faint">Werte (erste Lebensphase)</div>
|
||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||
<ElementPhaseFields
|
||
element={{ category }}
|
||
context={context}
|
||
pd={pd}
|
||
setP={(patch) => setPd((prev) => ({ ...prev, ...patch }))}
|
||
/>
|
||
</div>
|
||
</div>
|
||
|
||
{error && <p className="text-sm text-danger">{error}</p>}
|
||
<DialogActions saving={saving} onConfirm={create} onClose={onClose} confirmLabel="Erstellen" />
|
||
</DialogShell>
|
||
);
|
||
}
|
||
|
||
// --- Dialog: neue Lebensphase ---
|
||
function AddPhaseDialog({
|
||
maxDurationYears,
|
||
onClose,
|
||
onCreate,
|
||
}: {
|
||
maxDurationYears: number | null;
|
||
onClose: () => void;
|
||
onCreate: (payload: { name?: string; durationYears?: number }) => void;
|
||
}) {
|
||
const cap = maxDurationYears;
|
||
const [name, setName] = useState("");
|
||
const [durationYears, setDurationYears] = useState(cap ?? 10);
|
||
const [saving, setSaving] = useState(false);
|
||
|
||
return (
|
||
<DialogShell title="Neue Lebensphase" onClose={onClose}>
|
||
<div className="grid grid-cols-1 gap-3">
|
||
<div>
|
||
<label className="mb-1 block text-xs font-medium text-muted">Bezeichnung (optional)</label>
|
||
<input
|
||
value={name}
|
||
onChange={(e) => setName(e.target.value)}
|
||
placeholder="automatisch (Erwerb/Pension)"
|
||
className="w-full rounded-lg border border-border bg-input px-3 py-2 text-sm text-fg focus:border-accent focus:outline-none focus:ring-2 focus:ring-accent/25"
|
||
/>
|
||
</div>
|
||
<div>
|
||
<label className="mb-1 block text-xs font-medium text-muted">
|
||
Dauer (Jahre){cap != null ? ` · max. ${cap}` : ""}
|
||
</label>
|
||
<input
|
||
type="number"
|
||
value={durationYears}
|
||
min={1}
|
||
max={cap ?? undefined}
|
||
onFocus={(e) => e.currentTarget.select()}
|
||
onChange={(e) => {
|
||
const v = e.target.valueAsNumber || 1;
|
||
setDurationYears(cap != null ? Math.min(v, cap) : v);
|
||
}}
|
||
className="w-full rounded-lg border border-border bg-input px-3 py-2 text-sm text-fg focus:border-accent focus:outline-none focus:ring-2 focus:ring-accent/25"
|
||
/>
|
||
</div>
|
||
</div>
|
||
<DialogActions
|
||
saving={saving}
|
||
onConfirm={() => {
|
||
setSaving(true);
|
||
onCreate({ name: name.trim() || undefined, durationYears });
|
||
}}
|
||
onClose={onClose}
|
||
confirmLabel="Erstellen"
|
||
/>
|
||
</DialogShell>
|
||
);
|
||
}
|
||
|
||
// Ziele für die Anlage-Quote der Kapitalverwendung (Roadmap Nr. 44, Punkt C): alle
|
||
// Vermögens-Elemente, die in der Folgephase noch aktiv sind.
|
||
// Der Name allein genuegt hier nicht: Zwei Personen nennen ihr Wertschriftendepot beide "ETF",
|
||
// und im Dropdown waehlt man dann blind das falsche -- was erst Phasen spaeter auffaellt.
|
||
function investTargetsOf(plan: PlanInput, toPhase: PhaseComputed | undefined): { id: string; name: string }[] {
|
||
return (toPhase?.elements ?? [])
|
||
.filter((e) => e.category === "OTHER_ASSET" && e.status === "ACTIVE")
|
||
.map((e) => {
|
||
const owner = plan.elements.find((x) => x.id === e.elementId)?.ownerRole ?? null;
|
||
return { id: e.elementId, name: `${e.name} · ${ownerLabel(plan.persons, owner)}` };
|
||
});
|
||
}
|
||
|
||
// --- Panel: Szenario-Profil (Grundprofil bearbeiten) ---
|
||
function ProfilePanel({ plan, onClose, onSaved }: { plan: PlanInput; onClose: () => void; onSaved: () => void }) {
|
||
const [draft, setDraft] = useState<ProfileDraft>({
|
||
householdType: plan.householdType,
|
||
inflationRateDefault: plan.inflationRateDefault,
|
||
startYear: plan.startYear ?? new Date().getFullYear(),
|
||
persons: plan.persons.map((p) => ({ role: p.role, name: p.name ?? "", age: p.age, retirementAge: p.retirementAge })),
|
||
});
|
||
const [saving, setSaving] = useState(false);
|
||
const [error, setError] = useState<string | null>(null);
|
||
|
||
async function save() {
|
||
setSaving(true);
|
||
setError(null);
|
||
try {
|
||
await api.patch(`/api/scenarios/${plan.id}`, draft);
|
||
onSaved();
|
||
} catch (e) {
|
||
setError(e instanceof Error ? e.message : "Speichern fehlgeschlagen.");
|
||
} finally {
|
||
setSaving(false);
|
||
}
|
||
}
|
||
|
||
return (
|
||
<InspectorShell title="Szenario-Profil" subtitle="Haushalt, Personen, Planstart und Inflation dieses Szenarios" onClose={onClose}>
|
||
<div className="flex flex-col gap-3">
|
||
<PlanProfileFields draft={draft} onChange={setDraft} lockRetirement={plan.phases.length > 0} />
|
||
{error && <p className="text-sm text-danger">{error}</p>}
|
||
<div className="flex gap-2 pt-1">
|
||
<Button disabled={saving} onClick={save}>{saving ? "…" : "Speichern"}</Button>
|
||
<Button variant="secondary" onClick={onClose}>Abbrechen</Button>
|
||
</div>
|
||
|
||
{plan.phases.length > 0 && (
|
||
<div className="mt-3 border-t border-border pt-3">
|
||
<h3 className="mb-2 text-sm font-semibold text-fg">Pensionsalter anpassen</h3>
|
||
{/* Wirkt SOFORT (eigener Endpunkt), weil dabei Phasendauern verschoben werden --
|
||
das lässt sich nicht sinnvoll mit dem Profil-Formular zusammen abspeichern. */}
|
||
<RetirementAdjuster plan={plan} onSaved={onSaved} />
|
||
</div>
|
||
)}
|
||
</div>
|
||
</InspectorShell>
|
||
);
|
||
}
|
||
|
||
// --- 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,
|
||
initialCash,
|
||
buildContext,
|
||
isRetirement,
|
||
onClose,
|
||
onSaved,
|
||
}: {
|
||
plan: PlanInput;
|
||
computed: PlanComputed;
|
||
fromPhase: PhaseComputed;
|
||
toPhase: PhaseComputed | undefined;
|
||
elements: ElementInput[];
|
||
initialCash: CashTransitionData;
|
||
buildContext: (el: ElementInput) => CellContext;
|
||
isRetirement: (el: ElementInput) => boolean;
|
||
onClose: () => void;
|
||
onSaved: () => void;
|
||
}) {
|
||
const [tds, setTds] = useState<Record<string, TransitionData>>(() =>
|
||
Object.fromEntries(
|
||
elements.map((e) => [e.id, withTransitionDefaults(e.category, isRetirement(e), e.transitionValues[fromPhase.id] ?? {})])
|
||
)
|
||
);
|
||
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);
|
||
|
||
async function saveAll() {
|
||
setSaving(true);
|
||
setError(null);
|
||
try {
|
||
await api.put(`/api/phases/${fromPhase.id}/cash-transition`, ct);
|
||
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.");
|
||
} finally {
|
||
setSaving(false);
|
||
}
|
||
}
|
||
|
||
return (
|
||
<DialogShell title={`Übergang prüfen: ${fromPhase.name} → ${toPhase?.name ?? "Ende"}`} onClose={onClose} wide>
|
||
<p className="text-sm text-muted">
|
||
Geh die Positionen durch und triff je Element den Übergangs-Entscheid (Halten, Verkaufen,
|
||
Bezug). Danach werden gehaltene Werte automatisch in die nächste Phase fortgeschrieben.
|
||
</p>
|
||
<div className="flex flex-col gap-3">
|
||
{/* Cash zuerst: einmalige Sonderein-/ausgaben betreffen jeden Übergang. */}
|
||
<div className="rounded-xl border border-border bg-surface-2 p-3">
|
||
<div className="mb-2 flex items-center gap-2">
|
||
<span className="text-accent">
|
||
<Wallet className="h-4 w-4" />
|
||
</span>
|
||
<span className="text-sm font-semibold text-fg">Cash</span>
|
||
<span className="text-xs text-faint">Einmalige Sonderein-/ausgaben</span>
|
||
</div>
|
||
<p className="mb-2 text-xs text-accent-soft-fg">
|
||
Einmalige Ereignisse wie Erbschaft, Autokauf oder Poolbau werden hier direkt dem Cash-Konto
|
||
gutgeschrieben bzw. belastet.
|
||
</p>
|
||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||
<CashTransitionFields
|
||
ct={ct}
|
||
setC={(patch) => setCt((prev) => ({ ...prev, ...patch }))}
|
||
deflatorEnd={fromPhase.cumulativeInflationEnd}
|
||
/>
|
||
</div>
|
||
</div>
|
||
|
||
{elements.length === 0 && (
|
||
<p className="rounded-lg border border-dashed border-border bg-surface-2 p-3 text-sm text-muted">
|
||
An diesem Übergang gibt es sonst keine zu entscheidenden Positionen.
|
||
</p>
|
||
)}
|
||
{elements.map((el) => {
|
||
const ctx = buildContext(el);
|
||
const retire = isRetirement(el);
|
||
const hint =
|
||
el.category === "AHV" && retire
|
||
? "Pensionierung: Beitragskarriere prüfen – die Rente hängt vom Durchschnittseinkommen ab."
|
||
: (el.category === "PENSION_FUND" || el.category === "PILLAR_3A") && !retire
|
||
? "Hier könntest du optional Kapital beziehen."
|
||
: el.category === "PENSION_FUND" && retire
|
||
? "Pensionierung: Bezugsart wählen (Rente / Kapital / Kombination)."
|
||
: el.category === "PILLAR_3A" && retire
|
||
? "Wird bei Pensionierung vollständig bezogen."
|
||
: null;
|
||
return (
|
||
<div key={el.id} className="rounded-xl border border-border bg-surface-2 p-3">
|
||
<div className="mb-2 flex items-center gap-2">
|
||
<span className="text-accent">{CATEGORY_ICON[el.category]}</span>
|
||
<span className="text-sm font-semibold text-fg">{el.name}</span>
|
||
<span className="text-xs text-faint">{CATEGORY_LABELS[el.category]}</span>
|
||
</div>
|
||
{hint && <p className="mb-2 text-xs text-accent-soft-fg">{hint}</p>}
|
||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||
<ElementTransitionFields
|
||
element={el}
|
||
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>
|
||
);
|
||
})}
|
||
</div>
|
||
{error && <p className="text-sm text-danger">{error}</p>}
|
||
<DialogActions saving={saving} onConfirm={saveAll} onClose={onClose} confirmLabel="Alle speichern" />
|
||
</DialogShell>
|
||
);
|
||
}
|
||
|
||
// --- Panel: Cash-Übergang (einmalige Sonderein-/ausgaben) ---
|
||
function CashTransitionPanel({
|
||
fromPhase,
|
||
toPhase,
|
||
initial,
|
||
onClose,
|
||
onSaved,
|
||
}: {
|
||
fromPhase: PhaseComputed;
|
||
toPhase: PhaseComputed | undefined;
|
||
initial: CashTransitionData;
|
||
onClose: () => void;
|
||
onSaved: () => void;
|
||
}) {
|
||
// Vorbelegung, damit ein blosses "Speichern" den sichtbaren Default (1:1) persistiert.
|
||
const [ct, setCt] = useState<CashTransitionData>(() => withCashTransitionDefaults(initial));
|
||
const [saving, setSaving] = useState(false);
|
||
const [error, setError] = useState<string | null>(null);
|
||
|
||
async function save() {
|
||
setSaving(true);
|
||
setError(null);
|
||
try {
|
||
await api.put(`/api/phases/${fromPhase.id}/cash-transition`, ct);
|
||
onSaved();
|
||
} catch (e) {
|
||
setError(e instanceof Error ? e.message : "Speichern fehlgeschlagen.");
|
||
} finally {
|
||
setSaving(false);
|
||
}
|
||
}
|
||
|
||
return (
|
||
<InspectorShell
|
||
title="Übergang: Cash"
|
||
subtitle={`Einmalige Sonderein-/ausgaben · ${fromPhase.name} → ${toPhase?.name ?? "Ende"}`}
|
||
onClose={onClose}
|
||
>
|
||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||
<CashTransitionFields
|
||
ct={ct}
|
||
setC={(patch) => setCt((prev) => ({ ...prev, ...patch }))}
|
||
deflatorEnd={fromPhase.cumulativeInflationEnd}
|
||
/>
|
||
</div>
|
||
{error && <p className="mt-3 text-sm text-danger">{error}</p>}
|
||
<PanelActions saving={saving} onConfirm={save} onClose={onClose} confirmLabel="Speichern" />
|
||
</InspectorShell>
|
||
);
|
||
}
|
||
|
||
// --- Panel: Cash-Anfangswert (erste Lebensphase) ---
|
||
function CashInitialPanel({ plan, onClose, onSaved }: { plan: PlanInput; onClose: () => void; onSaved: () => void }) {
|
||
const [value, setValue] = useState(plan.initialCash);
|
||
const [saving, setSaving] = useState(false);
|
||
const [error, setError] = useState<string | null>(null);
|
||
|
||
async function save() {
|
||
setSaving(true);
|
||
setError(null);
|
||
try {
|
||
await api.patch(`/api/scenarios/${plan.id}`, { initialCash: value });
|
||
onSaved();
|
||
} catch (e) {
|
||
setError(e instanceof Error ? e.message : "Speichern fehlgeschlagen.");
|
||
} finally {
|
||
setSaving(false);
|
||
}
|
||
}
|
||
|
||
return (
|
||
<InspectorShell
|
||
title="Cash-Anfangswert"
|
||
subtitle="Startbestand des Ausgleichskontos zu Beginn der ersten Lebensphase"
|
||
onClose={onClose}
|
||
>
|
||
<div className="flex flex-col gap-3">
|
||
<MoneyField label="Anfangswert (CHF)" value={value} onChange={setValue} />
|
||
{error && <p className="text-sm text-danger">{error}</p>}
|
||
<PanelActions saving={saving} onConfirm={save} onClose={onClose} confirmLabel="Speichern" />
|
||
</div>
|
||
</InspectorShell>
|
||
);
|
||
}
|
||
|
||
// --- Panel: einzelner Übergangs-Entscheid (per Klick auf eine Übergangszelle) ---
|
||
function TransitionCellPanel({
|
||
plan,
|
||
computed,
|
||
element,
|
||
fromPhase,
|
||
toPhase,
|
||
context,
|
||
onClose,
|
||
onSaved,
|
||
}: {
|
||
plan: PlanInput;
|
||
computed: PlanComputed;
|
||
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 [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);
|
||
|
||
async function save() {
|
||
setSaving(true);
|
||
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.");
|
||
} finally {
|
||
setSaving(false);
|
||
}
|
||
}
|
||
|
||
return (
|
||
<InspectorShell
|
||
title={`Übergang: ${element.name}`}
|
||
subtitle={`${CATEGORY_LABELS[element.category]} · ${fromPhase.name} → ${toPhase?.name ?? "Ende"}`}
|
||
onClose={onClose}
|
||
>
|
||
<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 }))}
|
||
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>}
|
||
<PanelActions saving={saving} onConfirm={save} onClose={onClose} confirmLabel="Speichern" />
|
||
</InspectorShell>
|
||
);
|
||
}
|
||
|
||
// --- gemeinsame Bausteine ---
|
||
// Erstell-Flows und der geführte Übergang bleiben Modals (ui.Modal: ESC, Fokus-Falle,
|
||
// Animation); Einzel-Bearbeitungen laufen über das Inspector-Panel.
|
||
function DialogShell({
|
||
title,
|
||
onClose,
|
||
children,
|
||
wide,
|
||
}: {
|
||
title: string;
|
||
onClose: () => void;
|
||
children: React.ReactNode;
|
||
wide?: boolean;
|
||
}) {
|
||
return (
|
||
<Modal title={title} onClose={onClose} wide={wide}>
|
||
{children}
|
||
</Modal>
|
||
);
|
||
}
|
||
|
||
function DialogActions({
|
||
saving,
|
||
onConfirm,
|
||
onClose,
|
||
confirmLabel,
|
||
}: {
|
||
saving: boolean;
|
||
onConfirm: () => void;
|
||
onClose: () => void;
|
||
confirmLabel: string;
|
||
}) {
|
||
return (
|
||
<div className="flex gap-2 pt-1">
|
||
<Button disabled={saving} onClick={onConfirm}>
|
||
{saving ? "…" : confirmLabel}
|
||
</Button>
|
||
<Button variant="secondary" onClick={onClose}>
|
||
Abbrechen
|
||
</Button>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// Aktionsleiste im Inspector-Panel.
|
||
function PanelActions({
|
||
saving,
|
||
onConfirm,
|
||
onClose,
|
||
confirmLabel,
|
||
}: {
|
||
saving: boolean;
|
||
onConfirm: () => void;
|
||
onClose: () => void;
|
||
confirmLabel: string;
|
||
}) {
|
||
return (
|
||
<div className="mt-4 flex gap-2 border-t border-border pt-3">
|
||
<Button disabled={saving} onClick={onConfirm}>
|
||
{saving ? "…" : confirmLabel}
|
||
</Button>
|
||
<Button variant="secondary" onClick={onClose}>
|
||
Abbrechen
|
||
</Button>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// --- Panel: Element umbenennen und zuordnen ----------------------------------------------
|
||
// Beides sind Eigenschaften des ELEMENTS, nicht einer Phase -- deshalb ein eigenes Panel,
|
||
// erreichbar über das Stift-Symbol in der Element-Zeile.
|
||
function ElementMetaPanel({
|
||
element,
|
||
plan,
|
||
personLabel,
|
||
onSaved,
|
||
onDelete,
|
||
}: {
|
||
element: ElementInput;
|
||
plan: PlanInput;
|
||
personLabel: (role: string) => string;
|
||
onSaved: () => void;
|
||
onDelete: () => void;
|
||
}) {
|
||
const [name, setName] = useState(element.name);
|
||
const [ownerRole, setOwnerRole] = useState<string>(element.ownerRole ?? "HOUSEHOLD");
|
||
const [saving, setSaving] = useState(false);
|
||
const [error, setError] = useState<string | null>(null);
|
||
|
||
const needsPerson = PERSON_ONLY_CATEGORIES.includes(element.category);
|
||
const isCouple = plan.householdType === "COUPLE";
|
||
const options = needsPerson
|
||
? isCouple
|
||
? [
|
||
{ value: "PERSON_A", label: personLabel("PERSON_A") },
|
||
{ value: "PERSON_B", label: personLabel("PERSON_B") },
|
||
]
|
||
: [{ value: "PERSON_A", label: personLabel("PERSON_A") }]
|
||
: isCouple
|
||
? [
|
||
{ value: "HOUSEHOLD", label: "Gemeinsam" },
|
||
{ value: "PERSON_A", label: personLabel("PERSON_A") },
|
||
{ value: "PERSON_B", label: personLabel("PERSON_B") },
|
||
]
|
||
: [
|
||
{ value: "HOUSEHOLD", label: "Gemeinsam" },
|
||
{ value: "PERSON_A", label: personLabel("PERSON_A") },
|
||
];
|
||
|
||
async function save() {
|
||
setSaving(true);
|
||
setError(null);
|
||
try {
|
||
await api.patch(`/api/elements/${element.id}`, { name: name.trim() || element.name, ownerRole });
|
||
onSaved();
|
||
} catch (e) {
|
||
setError(e instanceof Error ? e.message : "Speichern fehlgeschlagen.");
|
||
} finally {
|
||
setSaving(false);
|
||
}
|
||
}
|
||
|
||
return (
|
||
<div className="flex flex-col gap-4">
|
||
<div className="flex items-center justify-between">
|
||
<div className="text-xs font-semibold uppercase tracking-wide text-accent-soft-fg">
|
||
{CATEGORY_LABELS[element.category]}
|
||
</div>
|
||
<button
|
||
type="button"
|
||
onClick={onDelete}
|
||
className="flex items-center gap-1 rounded-lg border border-border px-2 py-1 text-xs text-muted hover:border-danger hover:bg-danger-soft hover:text-danger"
|
||
>
|
||
<Trash2 className="h-3.5 w-3.5" /> Element löschen
|
||
</button>
|
||
</div>
|
||
|
||
<TextField label="Bezeichnung" value={name} onChange={setName} />
|
||
<SelectField
|
||
label="Zuordnung"
|
||
help={
|
||
needsPerson
|
||
? "AHV, Pensionskasse und Säule 3a hängen am individuellen Pensionierungszeitpunkt und gehören deshalb immer genau einer Person."
|
||
: "Gemeinsame Elemente zählen zum Haushalt. Bei Paaren wird ein gemeinsames Einkommen NICHT für die AHV-Rente angerechnet."
|
||
}
|
||
value={ownerRole}
|
||
onChange={setOwnerRole}
|
||
options={options}
|
||
/>
|
||
<p className="text-xs text-faint">
|
||
Die Werte je Lebensphase bearbeitest du direkt in den Zellen der Matrix.
|
||
</p>
|
||
|
||
{error && <p className="text-sm text-danger">{error}</p>}
|
||
<div className="flex gap-2">
|
||
<Button disabled={saving} onClick={save}>
|
||
{saving ? "Speichern…" : "Speichern"}
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|