V3: 3 Farbschemata, Grundprofil auf Plan-Ebene, Erstellungs-Popups, integer-Zahlenfelder mit Beschleunigungs-Spinner, Carry-Forward des Zielwerts, gefuehrter Uebergang
Deploy App / deploy (push) Successful in 1m51s
Deploy App / deploy (push) Successful in 1m51s
- Theming: semantische CSS-Tokens + 3 waehlbare Schemata (Hell/Dunkel/Warm/Sunset), Umschalter im Profil-Menue, FOUC-frei via Inline-Script, localStorage; Klassen-Sweep aller Komponenten, Recharts aus Tokens - Datenmodell: Household entfaellt; Plan traegt Haushaltsform/Personen/Inflation selbst (Person -> planId, Plan -> userId); destruktive Migration (TRUNCATE); Onboarding/HouseholdSettings entfernt; Plan-Erstellung & -Einstellungen mit Profilfeldern - Popups: Element-Erstellung mit Inline-Feldern (geteilte ElementPhaseFields/ElementTransitionFields), Phase- und Plan-Popups mit Direkteingabe - Zahlenfelder: 1'000er-Runden entfernt (floorToThousand/roundToHundred weg), integer MoneyInput mit beschleunigendem Press-and-Hold-Spinner, 0-Bug-Fix, harte Live-Caps - Quote: Amortisation + Tilgung neu quotenwirksam; Restquote sichtbar (sinkt beim Verteilen); Invest-Deckel = verfuegbares Kapital + fortgeschriebener Zielwert - Carry-Forward: Startwert der Folgephase = Zielwert der Vorphase minus Uebergangs-Bezug (live abgeleitet); optionale Zusatzinvestition aus verfuegbarem Kapital - Matrix: Zelle zeigt Start -> Ziel; Uebergangs-Spaltenkopf mit "n offen"-Badge + gefuehrtem Pruef-Panel Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
+581
-148
@@ -12,13 +12,21 @@ import {
|
||||
Landmark,
|
||||
PiggyBank,
|
||||
Plus,
|
||||
Settings2,
|
||||
ShoppingCart,
|
||||
TrendingUp,
|
||||
Wallet,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import { Timeline } from "@/components/Timeline";
|
||||
import { ElementDetail, type CellContext } from "@/components/ElementDetail";
|
||||
import {
|
||||
ElementDetail,
|
||||
ElementPhaseFields,
|
||||
ElementTransitionFields,
|
||||
type CellContext,
|
||||
} from "@/components/ElementDetail";
|
||||
import { PhaseDetail } from "@/components/PhaseDetail";
|
||||
import { PlanProfileFields, type ProfileDraft } from "@/components/PlanProfileFields";
|
||||
import { api } from "@/lib/api-client";
|
||||
import { formatChf } from "@/lib/format";
|
||||
import {
|
||||
@@ -27,12 +35,10 @@ import {
|
||||
PERSON_ONLY_CATEGORIES,
|
||||
num,
|
||||
type ElementCategory,
|
||||
type PhaseData,
|
||||
} from "@/lib/elements";
|
||||
import { resolveRetirementAge, type PhaseComputed, type PlanComputed } from "@/lib/calculations";
|
||||
import type { ElementInput, HouseholdInput, PlanInput } from "@/lib/types";
|
||||
|
||||
const PERSON_A_COLOR = "#4f46e5";
|
||||
const PERSON_B_COLOR = "#0ea5e9";
|
||||
import type { PhaseComputed, PlanComputed } from "@/lib/calculations";
|
||||
import type { ElementInput, PlanInput } from "@/lib/types";
|
||||
|
||||
const CATEGORY_ICON: Record<ElementCategory, React.ReactNode> = {
|
||||
INCOME: <Wallet className="h-4 w-4" />,
|
||||
@@ -53,6 +59,14 @@ const TRANSITION_CATEGORIES: ElementCategory[] = [
|
||||
"OTHER_DEBT",
|
||||
];
|
||||
|
||||
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 };
|
||||
@@ -64,18 +78,19 @@ type Selection =
|
||||
|
||||
export function PlanView({
|
||||
plan,
|
||||
household,
|
||||
computed,
|
||||
onChanged,
|
||||
}: {
|
||||
plan: PlanInput;
|
||||
household: HouseholdInput;
|
||||
computed: PlanComputed;
|
||||
onChanged: () => void;
|
||||
}) {
|
||||
const [selected, setSelected] = useState<Selection | null>(null);
|
||||
const [collapsedCats, setCollapsedCats] = useState<Set<ElementCategory>>(new Set());
|
||||
const [showAdd, setShowAdd] = useState(false);
|
||||
const [showAddPhase, setShowAddPhase] = useState(false);
|
||||
const [showSettings, setShowSettings] = useState(false);
|
||||
const [reviewFromPhaseId, setReviewFromPhaseId] = useState<string | null>(null);
|
||||
|
||||
const columns = useMemo<Column[]>(() => {
|
||||
const cols: Column[] = [];
|
||||
@@ -88,12 +103,12 @@ export function PlanView({
|
||||
return cols;
|
||||
}, [computed.phases]);
|
||||
|
||||
const personAxes = household.persons.map((p) => ({
|
||||
const personAxes = plan.persons.map((p) => ({
|
||||
role: p.role,
|
||||
label: p.role === "PERSON_A" ? "Person A" : "Person B",
|
||||
currentAge: p.age,
|
||||
retirementAge: resolveRetirementAge(p.role, plan, p.retirementAge),
|
||||
color: p.role === "PERSON_A" ? PERSON_A_COLOR : PERSON_B_COLOR,
|
||||
retirementAge: p.retirementAge,
|
||||
color: p.role === "PERSON_A" ? "var(--person-a)" : "var(--person-b)",
|
||||
}));
|
||||
|
||||
const elementsByCategory = useMemo(() => {
|
||||
@@ -116,48 +131,117 @@ export function PlanView({
|
||||
return !!before?.working && !!after && !after.working;
|
||||
}
|
||||
|
||||
async function handleAddPhase() {
|
||||
await api.post(`/api/plans/${plan.id}/phases`, {});
|
||||
// Baut den Kontext (inkl. Live-Caps) fuer 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";
|
||||
const otherQuota = phase.quotaAllocated - (ce?.quotaUse ?? 0);
|
||||
const quotaRateMax = Math.max(0, Math.abs(phase.quota) - otherQuota);
|
||||
const capitalMax =
|
||||
phase.availableCapital == null
|
||||
? undefined
|
||||
: Math.max(0, phase.availableCapital - (phase.availableCapitalUsed - (ce?.capitalUse ?? 0)));
|
||||
const derivedStart = (ce?.startValue ?? 0) - (ce?.capitalUse ?? 0);
|
||||
return {
|
||||
kind: "phase",
|
||||
phaseId: phase.id,
|
||||
ownerWorking,
|
||||
isConsumption: phase.isConsumption,
|
||||
durationYears: phase.durationYears,
|
||||
isRetirementTransition: false,
|
||||
carriedEndValue: ce?.endValue ?? 0,
|
||||
carried: ce?.carried ?? false,
|
||||
derivedStart,
|
||||
quotaRateMax,
|
||||
capitalMax,
|
||||
};
|
||||
}
|
||||
|
||||
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,
|
||||
quotaRateMax: 0,
|
||||
capitalMax: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
// Anzahl offener (noch nicht getroffener) Uebergangs-Entscheide an einer Grenze.
|
||||
function transitionOpenCount(fromPhase: PhaseComputed, toPhase: PhaseComputed): number {
|
||||
let n = 0;
|
||||
for (const el of plan.elements) {
|
||||
if (!TRANSITION_CATEGORIES.includes(el.category)) continue;
|
||||
const ce = computedElement(fromPhase.id, el.id);
|
||||
if (ce && ce.status !== "ACTIVE") continue;
|
||||
const td = el.transitionValues[fromPhase.id] ?? {};
|
||||
if (el.category === "REAL_ESTATE" || el.category === "OTHER_ASSET") {
|
||||
if (td.decision === undefined) n++;
|
||||
} else if (el.category === "PENSION_FUND" && isRetirementTransition(el, fromPhase, toPhase)) {
|
||||
if (td.payoutMode === undefined) n++;
|
||||
}
|
||||
}
|
||||
return n;
|
||||
}
|
||||
|
||||
function transitionElements(fromPhase: PhaseComputed): ElementInput[] {
|
||||
return plan.elements
|
||||
.filter((el) => TRANSITION_CATEGORIES.includes(el.category))
|
||||
.filter((el) => {
|
||||
const ce = computedElement(fromPhase.id, el.id);
|
||||
return !ce || ce.status === "ACTIVE";
|
||||
})
|
||||
.sort((a, b) => a.orderIndex - b.orderIndex);
|
||||
}
|
||||
|
||||
async function handleAddPhase(payload: { name?: string; durationYears?: number; inflationRate?: number | null }) {
|
||||
await api.post(`/api/plans/${plan.id}/phases`, payload);
|
||||
setShowAddPhase(false);
|
||||
onChanged();
|
||||
}
|
||||
|
||||
const hasPhases = computed.phases.length > 0;
|
||||
const firstPhase = computed.phases[0] ?? null;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-5">
|
||||
<Timeline phases={computed.phases} persons={personAxes} />
|
||||
|
||||
{/* Pensionsalter-Overrides */}
|
||||
<div className="flex flex-wrap items-center gap-3 rounded-xl border border-zinc-200/70 bg-white px-4 py-3 text-sm shadow-sm dark:border-zinc-800 dark:bg-zinc-900">
|
||||
<span className="text-xs font-semibold uppercase tracking-wide text-zinc-400">Pensionsalter (Plan)</span>
|
||||
{household.persons.map((p) => (
|
||||
<label key={p.role} className="flex items-center gap-1.5 text-xs text-zinc-600 dark:text-zinc-300">
|
||||
{p.role === "PERSON_A" ? "Person A" : "Person B"}:
|
||||
<input
|
||||
type="number"
|
||||
defaultValue={resolveRetirementAge(p.role, plan, p.retirementAge)}
|
||||
className="w-16 rounded-lg border border-zinc-300 px-2 py-1 text-sm focus:border-indigo-500 focus:outline-none focus:ring-2 focus:ring-indigo-500/20 dark:border-zinc-700 dark:bg-zinc-800"
|
||||
onBlur={async (e) => {
|
||||
const val = parseInt(e.target.value, 10);
|
||||
if (!Number.isFinite(val)) return;
|
||||
await api.patch(`/api/plans/${plan.id}`, {
|
||||
[p.role === "PERSON_A" ? "retirementAgeA" : "retirementAgeB"]: val,
|
||||
});
|
||||
onChanged();
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
{/* Plan-Profil */}
|
||||
<div className="flex flex-wrap items-center gap-3 rounded-xl border border-border bg-surface px-4 py-3 text-sm shadow-sm">
|
||||
<span className="text-xs font-semibold uppercase tracking-wide text-faint">Grundprofil (Plan)</span>
|
||||
{plan.persons.map((p) => (
|
||||
<span key={p.role} className="text-xs text-muted">
|
||||
{plan.householdType === "COUPLE" ? (p.role === "PERSON_A" ? "Person A" : "Person B") : "Person"}: {p.age} J., Pension {p.retirementAge}
|
||||
</span>
|
||||
))}
|
||||
<span className="text-[11px] text-zinc-400">Standard aus Grundprofil, hier pro Plan uebersteuerbar.</span>
|
||||
<span className="text-xs text-muted">Inflation {plan.inflationRateDefault}%</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowSettings(true)}
|
||||
className="ml-auto flex items-center gap-1.5 rounded-lg border border-border px-2.5 py-1 text-xs font-medium text-muted hover:bg-surface-2"
|
||||
>
|
||||
<Settings2 className="h-3.5 w-3.5" /> Einstellungen
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{!hasPhases && (
|
||||
<div className="rounded-xl border border-dashed border-zinc-300 bg-white p-8 text-center dark:border-zinc-700 dark:bg-zinc-900">
|
||||
<p className="text-sm text-zinc-500">Dieser Plan hat noch keine Lebensphasen.</p>
|
||||
<div className="rounded-xl border border-dashed border-border bg-surface p-8 text-center">
|
||||
<p className="text-sm text-muted">Dieser Plan hat noch keine Lebensphasen.</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleAddPhase}
|
||||
className="mt-3 inline-flex items-center gap-1.5 rounded-lg bg-indigo-600 px-4 py-2 text-sm font-medium text-white hover:bg-indigo-500 dark:bg-indigo-500"
|
||||
onClick={() => setShowAddPhase(true)}
|
||||
className="mt-3 inline-flex items-center gap-1.5 rounded-lg bg-accent px-4 py-2 text-sm font-medium text-accent-fg hover:bg-accent-hover"
|
||||
>
|
||||
<Plus className="h-4 w-4" /> Erste Lebensphase
|
||||
</button>
|
||||
@@ -169,14 +253,14 @@ export function PlanView({
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowAdd(true)}
|
||||
className="flex items-center gap-1.5 rounded-lg bg-indigo-600 px-3 py-1.5 text-sm font-medium text-white shadow-sm hover:bg-indigo-500 dark:bg-indigo-500 dark:hover:bg-indigo-400"
|
||||
className="flex items-center gap-1.5 rounded-lg bg-accent px-3 py-1.5 text-sm font-medium text-accent-fg shadow-sm hover:bg-accent-hover"
|
||||
>
|
||||
<Plus className="h-4 w-4" /> Finanzielles Element
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleAddPhase}
|
||||
className="flex items-center gap-1.5 rounded-lg border border-dashed border-indigo-300 bg-indigo-50/50 px-3 py-1.5 text-sm font-medium text-indigo-700 hover:bg-indigo-50 dark:border-indigo-500/30 dark:bg-indigo-500/10 dark:text-indigo-300"
|
||||
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 hover:bg-accent-soft"
|
||||
>
|
||||
<Plus className="h-4 w-4" /> Lebensphase
|
||||
</button>
|
||||
@@ -185,11 +269,11 @@ export function PlanView({
|
||||
|
||||
{/* Matrix */}
|
||||
{hasPhases && (
|
||||
<div className="overflow-x-auto rounded-xl border border-zinc-200/70 bg-white shadow-sm dark:border-zinc-800 dark:bg-zinc-900">
|
||||
<div className="overflow-x-auto rounded-xl border border-border bg-surface shadow-sm">
|
||||
<table className="w-full border-collapse text-sm">
|
||||
<thead>
|
||||
<tr>
|
||||
<th className="sticky left-0 z-20 min-w-44 border-b border-r border-zinc-200 bg-zinc-50 px-3 py-2 text-left text-xs font-semibold uppercase tracking-wide text-zinc-500 dark:border-zinc-800 dark:bg-zinc-800/60">
|
||||
<th className="sticky left-0 z-20 min-w-44 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) =>
|
||||
@@ -201,12 +285,11 @@ export function PlanView({
|
||||
active={selected?.type === "phase" && selected.phaseId === col.phase.id}
|
||||
/>
|
||||
) : (
|
||||
<th
|
||||
<TransitionHeader
|
||||
key={`t-${col.fromPhase.id}`}
|
||||
className="border-b border-r border-zinc-200 bg-indigo-50/40 px-2 py-2 text-center text-[11px] font-medium text-indigo-500 dark:border-zinc-800 dark:bg-indigo-500/5"
|
||||
>
|
||||
Uebergang
|
||||
</th>
|
||||
openCount={transitionOpenCount(col.fromPhase, col.toPhase)}
|
||||
onClick={() => setReviewFromPhaseId(col.fromPhase.id)}
|
||||
/>
|
||||
)
|
||||
)}
|
||||
</tr>
|
||||
@@ -218,9 +301,9 @@ export function PlanView({
|
||||
const collapsed = collapsedCats.has(cat);
|
||||
return (
|
||||
<FragmentRows key={cat}>
|
||||
<tr className="bg-zinc-50/60 dark:bg-zinc-800/30">
|
||||
<tr className="bg-surface-2">
|
||||
<td
|
||||
className="sticky left-0 z-10 cursor-pointer border-b border-r border-zinc-200 bg-zinc-50/90 px-3 py-1.5 dark:border-zinc-800 dark:bg-zinc-800/60"
|
||||
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);
|
||||
@@ -230,21 +313,21 @@ export function PlanView({
|
||||
})
|
||||
}
|
||||
>
|
||||
<span className="flex items-center gap-1.5 text-xs font-semibold text-zinc-600 dark:text-zinc-300">
|
||||
<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-indigo-500 dark:text-indigo-400">{CATEGORY_ICON[cat]}</span>
|
||||
<span className="text-accent">{CATEGORY_ICON[cat]}</span>
|
||||
{CATEGORY_LABELS[cat]}
|
||||
</span>
|
||||
</td>
|
||||
<td colSpan={columns.length} className="border-b border-zinc-200 dark:border-zinc-800" />
|
||||
<td colSpan={columns.length} className="border-b border-border" />
|
||||
</tr>
|
||||
{!collapsed &&
|
||||
els.map((el) => (
|
||||
<tr key={el.id} className="hover:bg-zinc-50/50 dark:hover:bg-zinc-800/20">
|
||||
<td className="sticky left-0 z-10 border-b border-r border-zinc-200 bg-white px-3 py-1.5 dark:border-zinc-800 dark:bg-zinc-900">
|
||||
<div className="truncate text-xs font-medium text-zinc-800 dark:text-zinc-200">{el.name}</div>
|
||||
<tr key={el.id} className="hover:bg-surface-2">
|
||||
<td className="sticky left-0 z-10 border-b border-r border-border bg-surface px-3 py-1.5">
|
||||
<div className="truncate text-xs font-medium text-fg">{el.name}</div>
|
||||
{el.ownerRole && el.ownerRole !== "HOUSEHOLD" && (
|
||||
<div className="text-[10px] text-zinc-400">
|
||||
<div className="text-[10px] text-faint">
|
||||
{el.ownerRole === "PERSON_A" ? "Person A" : "Person B"}
|
||||
</div>
|
||||
)}
|
||||
@@ -260,11 +343,11 @@ export function PlanView({
|
||||
<td
|
||||
key={col.phase.id}
|
||||
onClick={() => setSelected({ type: "phaseCell", elementId: el.id, phaseId: col.phase.id })}
|
||||
className={`cursor-pointer border-b border-r border-zinc-200 px-2 py-1.5 text-center text-xs dark:border-zinc-800 ${
|
||||
isSel ? "bg-indigo-100 dark:bg-indigo-500/20" : ""
|
||||
} ${ce?.locked ? "text-zinc-400" : "text-zinc-700 dark:text-zinc-200"}`}
|
||||
className={`cursor-pointer border-b border-r border-border px-2 py-1.5 text-center text-xs ${
|
||||
isSel ? "bg-accent-soft" : ""
|
||||
} ${ce?.locked ? "text-faint" : "text-fg"}`}
|
||||
>
|
||||
{ce?.summary ?? "–"}
|
||||
{phaseCellContent(ce)}
|
||||
</td>
|
||||
);
|
||||
}
|
||||
@@ -280,9 +363,9 @@ export function PlanView({
|
||||
canTransition &&
|
||||
setSelected({ type: "transitionCell", elementId: el.id, fromPhaseId: col.fromPhase.id })
|
||||
}
|
||||
className={`border-b border-r border-zinc-200 px-2 py-1.5 text-center text-[11px] dark:border-zinc-800 ${
|
||||
canTransition ? "cursor-pointer text-indigo-500" : "text-zinc-300 dark:text-zinc-600"
|
||||
} ${isSel ? "bg-indigo-100 dark:bg-indigo-500/20" : "bg-indigo-50/30 dark:bg-indigo-500/5"}`}
|
||||
className={`border-b border-r border-border px-2 py-1.5 text-center text-[11px] ${
|
||||
canTransition ? "cursor-pointer text-accent" : "text-faint"
|
||||
} ${isSel ? "bg-accent-soft" : "bg-accent-soft/40"}`}
|
||||
>
|
||||
{canTransition ? transitionSummary(el, col.fromPhase, col.toPhase) : "→"}
|
||||
</td>
|
||||
@@ -295,7 +378,7 @@ export function PlanView({
|
||||
})}
|
||||
{plan.elements.length === 0 && (
|
||||
<tr>
|
||||
<td className="sticky left-0 bg-white px-3 py-4 text-xs text-zinc-400 dark:bg-zinc-900" colSpan={columns.length + 1}>
|
||||
<td className="sticky left-0 bg-surface px-3 py-4 text-xs text-faint" colSpan={columns.length + 1}>
|
||||
Noch keine finanziellen Elemente. Fuegen Sie oben Ihr erstes Element hinzu.
|
||||
</td>
|
||||
</tr>
|
||||
@@ -307,25 +390,74 @@ export function PlanView({
|
||||
|
||||
{/* Detail-Panel */}
|
||||
{selected && (
|
||||
<div className="rounded-xl border border-indigo-200 bg-white p-4 shadow-sm dark:border-indigo-500/30 dark:bg-zinc-900">
|
||||
{renderDetail()}
|
||||
</div>
|
||||
<div className="rounded-xl border border-accent bg-surface p-4 shadow-sm">{renderDetail()}</div>
|
||||
)}
|
||||
|
||||
{showAdd && (
|
||||
{showAdd && firstPhase && (
|
||||
<AddElementDialog
|
||||
household={household}
|
||||
plan={plan}
|
||||
firstPhase={firstPhase}
|
||||
onClose={() => setShowAdd(false)}
|
||||
onCreate={async (payload) => {
|
||||
await api.post(`/api/plans/${plan.id}/elements`, payload);
|
||||
onCreated={() => {
|
||||
setShowAdd(false);
|
||||
onChanged();
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{showAddPhase && (
|
||||
<AddPhaseDialog
|
||||
maxDurationYears={nextPhaseCap()}
|
||||
onClose={() => setShowAddPhase(false)}
|
||||
onCreate={handleAddPhase}
|
||||
/>
|
||||
)}
|
||||
|
||||
{showSettings && (
|
||||
<PlanSettingsDialog
|
||||
plan={plan}
|
||||
onClose={() => setShowSettings(false)}
|
||||
onSaved={() => {
|
||||
setShowSettings(false);
|
||||
onChanged();
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{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);
|
||||
return (
|
||||
<TransitionReviewDialog
|
||||
fromPhase={fromPhase}
|
||||
toPhase={toPhase}
|
||||
elements={els}
|
||||
buildContext={(el) => buildTransitionContext(fromPhase, toPhase, el)}
|
||||
isRetirement={(el) => (toPhase ? isRetirementTransition(el, fromPhase, toPhase) : false)}
|
||||
onClose={() => setReviewFromPhaseId(null)}
|
||||
onSaved={() => {
|
||||
setReviewFromPhaseId(null);
|
||||
onChanged();
|
||||
}}
|
||||
/>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
);
|
||||
|
||||
// Naechste Phasen-Kappung (fuer das Phase-Popup).
|
||||
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;
|
||||
}
|
||||
|
||||
function renderDetail() {
|
||||
if (!selected) return null;
|
||||
|
||||
@@ -339,7 +471,7 @@ export function PlanView({
|
||||
phase={phaseInput}
|
||||
maxDurationYears={phase.maxDurationYears}
|
||||
isLast={isLast}
|
||||
household={household}
|
||||
inflationDefault={plan.inflationRateDefault}
|
||||
onSaved={onChanged}
|
||||
onDeleted={() => {
|
||||
setSelected(null);
|
||||
@@ -354,19 +486,7 @@ export function PlanView({
|
||||
|
||||
if (selected.type === "phaseCell") {
|
||||
const phase = computed.phases.find((p) => p.id === selected.phaseId)!;
|
||||
const ownerWorking = element.ownerRole && element.ownerRole !== "HOUSEHOLD"
|
||||
? phase.persons.find((p) => p.role === element.ownerRole)?.working ?? false
|
||||
: phase.type !== "PENSION";
|
||||
const ce = computedElement(phase.id, element.id);
|
||||
const context: CellContext = {
|
||||
kind: "phase",
|
||||
phaseId: phase.id,
|
||||
ownerWorking,
|
||||
isConsumption: phase.isConsumption,
|
||||
durationYears: phase.durationYears,
|
||||
isRetirementTransition: false,
|
||||
carriedEndValue: ce?.endValue ?? 0,
|
||||
};
|
||||
const context = buildPhaseContext(phase, element);
|
||||
return (
|
||||
<ElementDetail
|
||||
element={element}
|
||||
@@ -383,16 +503,7 @@ export function PlanView({
|
||||
const fromPhase = computed.phases.find((p) => p.id === selected.fromPhaseId)!;
|
||||
const toIndex = computed.phases.findIndex((p) => p.id === fromPhase.id) + 1;
|
||||
const toPhase = computed.phases[toIndex];
|
||||
const ce = computedElement(fromPhase.id, element.id);
|
||||
const context: CellContext = {
|
||||
kind: "transition",
|
||||
phaseId: fromPhase.id,
|
||||
ownerWorking: true,
|
||||
isConsumption: fromPhase.isConsumption,
|
||||
durationYears: fromPhase.durationYears,
|
||||
isRetirementTransition: toPhase ? isRetirementTransition(element, fromPhase, toPhase) : false,
|
||||
carriedEndValue: ce?.endValue ?? 0,
|
||||
};
|
||||
const context = buildTransitionContext(fromPhase, toPhase, element);
|
||||
return (
|
||||
<ElementDetail
|
||||
element={element}
|
||||
@@ -417,10 +528,10 @@ export function PlanView({
|
||||
switch (el.category) {
|
||||
case "REAL_ESTATE":
|
||||
case "OTHER_ASSET":
|
||||
return td.decision === "SELL" ? "Verkauf" : "Halten";
|
||||
return td.decision === "SELL" ? "Verkauf" : td.decision === "HOLD" ? "Halten" : "?";
|
||||
case "PENSION_FUND":
|
||||
if (isRetirementTransition(el, fromPhase, toPhase)) {
|
||||
return td.payoutMode === "CAPITAL" ? "Kapital" : td.payoutMode === "COMBI" ? "Kombi" : "Rente";
|
||||
return td.payoutMode === "CAPITAL" ? "Kapital" : td.payoutMode === "COMBI" ? "Kombi" : td.payoutMode === "PENSION" ? "Rente" : "?";
|
||||
}
|
||||
return num(td.withdrawal) > 0 ? `−${formatChf(num(td.withdrawal))}` : "→";
|
||||
case "PILLAR_3A":
|
||||
@@ -434,63 +545,108 @@ export function PlanView({
|
||||
}
|
||||
}
|
||||
|
||||
// Zellinhalt: Start- UND Zielwert fuer wertbehaftete Elemente, sonst die Kennzahl.
|
||||
function phaseCellContent(ce: ReturnType<PhaseComputed["elements"]["find"]> | undefined): React.ReactNode {
|
||||
if (!ce) return "–";
|
||||
if (ce.note) return ce.note;
|
||||
if (VALUE_CATEGORIES.includes(ce.category) && (ce.startValue !== 0 || ce.endValue !== 0)) {
|
||||
return (
|
||||
<span className="whitespace-nowrap">
|
||||
{formatChf(ce.startValue)} <span className="text-faint">→</span> {formatChf(ce.endValue)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
return ce.summary || "–";
|
||||
}
|
||||
|
||||
function PhaseHeader({ phase, onClick, active }: { phase: PhaseComputed; onClick: () => void; active: boolean }) {
|
||||
const quotaLabel = phase.isConsumption ? "Verzehr" : "Sparquote";
|
||||
const quotaRemaining = Math.max(0, phase.quotaRemaining);
|
||||
return (
|
||||
<th
|
||||
onClick={onClick}
|
||||
className={`min-w-40 cursor-pointer border-b border-r border-zinc-200 px-2 py-2 text-left align-top dark:border-zinc-800 ${
|
||||
active ? "bg-indigo-100 dark:bg-indigo-500/20" : "bg-white dark:bg-zinc-900"
|
||||
className={`min-w-40 cursor-pointer border-b border-r border-border px-2 py-2 text-left align-top ${
|
||||
active ? "bg-accent-soft" : "bg-surface"
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-1">
|
||||
<span className="truncate text-xs font-semibold text-zinc-800 dark:text-zinc-100">{phase.name}</span>
|
||||
<span className="truncate text-xs font-semibold text-fg">{phase.name}</span>
|
||||
{phase.incomplete ? (
|
||||
<AlertCircle className="h-3.5 w-3.5 shrink-0 text-red-500" />
|
||||
<AlertCircle className="h-3.5 w-3.5 shrink-0 text-danger" />
|
||||
) : (
|
||||
<CheckCircle2 className="h-3.5 w-3.5 shrink-0 text-emerald-500" />
|
||||
<CheckCircle2 className="h-3.5 w-3.5 shrink-0 text-success" />
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-0.5 flex flex-wrap gap-1">
|
||||
<span className="rounded bg-zinc-100 px-1 text-[10px] text-zinc-500 dark:bg-zinc-800">
|
||||
<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-zinc-400">{phase.durationYears} J.</span>
|
||||
<span className="text-[10px] text-zinc-400">Alter {phase.persons.map((p) => p.startAge).join("/")}</span>
|
||||
<span className="text-[10px] text-faint">{phase.durationYears} J.</span>
|
||||
<span className="text-[10px] text-faint">Alter {phase.persons.map((p) => p.startAge).join("/")}</span>
|
||||
</div>
|
||||
<div className="mt-1 space-y-0.5 text-[10px] leading-tight text-zinc-500 dark:text-zinc-400">
|
||||
<div className="mt-1 space-y-0.5 text-[10px] leading-tight text-muted">
|
||||
<div>Einkommen {formatChf(phase.incomeTotal)}</div>
|
||||
<div>Ausgaben {formatChf(phase.expenseTotal)}</div>
|
||||
<div className={phase.quotaComplete ? "text-emerald-600 dark:text-emerald-400" : "text-red-600 dark:text-red-400"}>
|
||||
<div className={phase.quotaComplete ? "text-success" : "text-danger"}>
|
||||
{quotaLabel} {formatChf(Math.abs(phase.quota))}
|
||||
{!phase.quotaComplete && <span> · offen {formatChf(quotaRemaining)}</span>}
|
||||
</div>
|
||||
<div className={phase.availableCapitalComplete ? "" : "text-red-600 dark:text-red-400"}>
|
||||
<div className={phase.availableCapitalComplete ? "" : "text-danger"}>
|
||||
Kapital {phase.availableCapital === null ? "n.a." : formatChf(phase.availableCapital)}
|
||||
{phase.availableCapital !== null && !phase.availableCapitalComplete && (
|
||||
<span> · offen {formatChf(Math.max(0, phase.availableCapitalRemaining))}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</th>
|
||||
);
|
||||
}
|
||||
|
||||
function TransitionHeader({ openCount, onClick }: { openCount: number; onClick: () => void }) {
|
||||
return (
|
||||
<th
|
||||
onClick={onClick}
|
||||
className={`cursor-pointer border-b border-r border-border px-2 py-2 text-center align-top text-[11px] font-medium ${
|
||||
openCount > 0 ? "bg-accent text-accent-fg" : "bg-accent-soft text-accent-soft-fg"
|
||||
}`}
|
||||
>
|
||||
<div>Uebergang</div>
|
||||
{openCount > 0 ? (
|
||||
<div className="mt-1 rounded-full bg-accent-fg/20 px-1.5 py-0.5 text-[10px] font-semibold">
|
||||
{openCount} offen
|
||||
</div>
|
||||
) : (
|
||||
<div className="mt-1 text-[10px] opacity-80">pruefen</div>
|
||||
)}
|
||||
</th>
|
||||
);
|
||||
}
|
||||
|
||||
function FragmentRows({ children }: { children: React.ReactNode }) {
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
||||
// --- Dialog: neues finanzielles Element mit Direkteingabe der Phase-1-Werte ---
|
||||
function AddElementDialog({
|
||||
household,
|
||||
plan,
|
||||
firstPhase,
|
||||
onClose,
|
||||
onCreate,
|
||||
onCreated,
|
||||
}: {
|
||||
household: HouseholdInput;
|
||||
plan: PlanInput;
|
||||
firstPhase: PhaseComputed;
|
||||
onClose: () => void;
|
||||
onCreate: (payload: { category: ElementCategory; name: string; ownerRole: string | null }) => void;
|
||||
onCreated: () => void;
|
||||
}) {
|
||||
const [category, setCategory] = useState<ElementCategory>("INCOME");
|
||||
const [name, setName] = useState("");
|
||||
const [ownerRole, setOwnerRole] = useState<string>(household.householdType === "COUPLE" ? "PERSON_A" : "PERSON_A");
|
||||
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 = household.householdType === "COUPLE";
|
||||
const isCouple = plan.householdType === "COUPLE";
|
||||
|
||||
const ownerOptions = needsPerson
|
||||
? isCouple
|
||||
@@ -510,24 +666,62 @@ function AddElementDialog({
|
||||
{ value: "PERSON_A", label: "Person A" },
|
||||
];
|
||||
|
||||
// Kontext fuer 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,
|
||||
quotaRateMax: Math.max(0, Math.abs(firstPhase.quota) - firstPhase.quotaAllocated),
|
||||
capitalMax: firstPhase.availableCapital == null ? undefined : Math.max(0, firstPhase.availableCapital - firstPhase.availableCapitalUsed),
|
||||
};
|
||||
|
||||
async function create() {
|
||||
setSaving(true);
|
||||
setError(null);
|
||||
try {
|
||||
const { element } = await api.post<{ element: { id: string } }>(`/api/plans/${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 (
|
||||
<div className="fixed inset-0 z-40 flex items-center justify-center bg-black/40 px-4" onClick={onClose}>
|
||||
<div
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="flex w-full max-w-md flex-col gap-3 rounded-2xl border border-zinc-200 bg-white p-6 shadow-xl dark:border-zinc-700 dark:bg-zinc-900"
|
||||
>
|
||||
<h2 className="text-base font-semibold text-zinc-900 dark:text-zinc-50">Finanzielles Element</h2>
|
||||
<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-zinc-600 dark:text-zinc-400">Kategorie</label>
|
||||
<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-zinc-300 px-3 py-2 text-sm focus:border-indigo-500 focus:outline-none focus:ring-2 focus:ring-indigo-500/20 dark:border-zinc-600 dark:bg-zinc-950"
|
||||
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}>
|
||||
@@ -537,20 +731,11 @@ function AddElementDialog({
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-1 block text-xs font-medium text-zinc-600 dark:text-zinc-400">Bezeichnung</label>
|
||||
<input
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder={CATEGORY_LABELS[category]}
|
||||
className="w-full rounded-lg border border-zinc-300 px-3 py-2 text-sm focus:border-indigo-500 focus:outline-none focus:ring-2 focus:ring-indigo-500/20 dark:border-zinc-600 dark:bg-zinc-950"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-1 block text-xs font-medium text-zinc-600 dark:text-zinc-400">Zuordnung</label>
|
||||
<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-zinc-300 px-3 py-2 text-sm focus:border-indigo-500 focus:outline-none focus:ring-2 focus:ring-indigo-500/20 dark:border-zinc-600 dark:bg-zinc-950"
|
||||
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}>
|
||||
@@ -559,23 +744,271 @@ function AddElementDialog({
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onCreate({ category, name: name.trim() || CATEGORY_LABELS[category], ownerRole })}
|
||||
className="rounded-lg bg-indigo-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-indigo-500 dark:bg-indigo-500 dark:hover:bg-indigo-400"
|
||||
>
|
||||
Erstellen
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="rounded-lg border border-zinc-300 px-4 py-2 text-sm font-medium text-zinc-700 hover:bg-zinc-50 dark:border-zinc-700 dark:text-zinc-200 dark:hover:bg-zinc-800"
|
||||
>
|
||||
Abbrechen
|
||||
<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; inflationRate?: number | null }) => 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>
|
||||
);
|
||||
}
|
||||
|
||||
// --- Dialog: Plan-Einstellungen (Grundprofil bearbeiten) ---
|
||||
function PlanSettingsDialog({ plan, onClose, onSaved }: { plan: PlanInput; onClose: () => void; onSaved: () => void }) {
|
||||
const [draft, setDraft] = useState<ProfileDraft>({
|
||||
householdType: plan.householdType,
|
||||
inflationRateDefault: plan.inflationRateDefault,
|
||||
persons: plan.persons.map((p) => ({ role: p.role, 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/plans/${plan.id}`, draft);
|
||||
onSaved();
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : "Speichern fehlgeschlagen.");
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<DialogShell title="Plan-Einstellungen" onClose={onClose}>
|
||||
<PlanProfileFields draft={draft} onChange={setDraft} />
|
||||
{error && <p className="text-sm text-danger">{error}</p>}
|
||||
<DialogActions saving={saving} onConfirm={save} onClose={onClose} confirmLabel="Speichern" />
|
||||
</DialogShell>
|
||||
);
|
||||
}
|
||||
|
||||
// --- Dialog: geführter Übergang ---
|
||||
function TransitionReviewDialog({
|
||||
fromPhase,
|
||||
toPhase,
|
||||
elements,
|
||||
buildContext,
|
||||
isRetirement,
|
||||
onClose,
|
||||
onSaved,
|
||||
}: {
|
||||
fromPhase: PhaseComputed;
|
||||
toPhase: PhaseComputed | undefined;
|
||||
elements: ElementInput[];
|
||||
buildContext: (el: ElementInput) => CellContext;
|
||||
isRetirement: (el: ElementInput) => boolean;
|
||||
onClose: () => void;
|
||||
onSaved: () => void;
|
||||
}) {
|
||||
const [tds, setTds] = useState<Record<string, import("@/lib/elements").TransitionData>>(() =>
|
||||
Object.fromEntries(elements.map((e) => [e.id, { ...(e.transitionValues[fromPhase.id] ?? {}) }]))
|
||||
);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
async function saveAll() {
|
||||
setSaving(true);
|
||||
setError(null);
|
||||
try {
|
||||
for (const e of elements) {
|
||||
await api.put(`/api/elements/${e.id}/transition/${fromPhase.id}`, tds[e.id] ?? {});
|
||||
}
|
||||
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">
|
||||
Gehen Sie die Positionen durch und treffen Sie 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">
|
||||
{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 keine zu entscheidenden Positionen.
|
||||
</p>
|
||||
)}
|
||||
{elements.map((el) => {
|
||||
const ctx = buildContext(el);
|
||||
const retire = isRetirement(el);
|
||||
const hint =
|
||||
(el.category === "PENSION_FUND" || el.category === "PILLAR_3A") && !retire
|
||||
? "Hier könnten Sie 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 } }))}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{error && <p className="text-sm text-danger">{error}</p>}
|
||||
<DialogActions saving={saving} onConfirm={saveAll} onClose={onClose} confirmLabel="Alle speichern" />
|
||||
</DialogShell>
|
||||
);
|
||||
}
|
||||
|
||||
// --- gemeinsame Dialog-Bausteine ---
|
||||
function DialogShell({
|
||||
title,
|
||||
onClose,
|
||||
children,
|
||||
wide,
|
||||
}: {
|
||||
title: string;
|
||||
onClose: () => void;
|
||||
children: React.ReactNode;
|
||||
wide?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<div className="fixed inset-0 z-40 flex items-start justify-center overflow-y-auto bg-black/40 px-4 py-8" onClick={onClose}>
|
||||
<div
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className={`flex w-full ${wide ? "max-w-2xl" : "max-w-md"} flex-col gap-3 rounded-2xl border border-border bg-surface p-6 shadow-xl`}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-base font-semibold text-fg">{title}</h2>
|
||||
<button type="button" onClick={onClose} aria-label="Schliessen" className="rounded-md p-1 text-faint hover:bg-surface-2">
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function DialogActions({
|
||||
saving,
|
||||
onConfirm,
|
||||
onClose,
|
||||
confirmLabel,
|
||||
}: {
|
||||
saving: boolean;
|
||||
onConfirm: () => void;
|
||||
onClose: () => void;
|
||||
confirmLabel: string;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex gap-2 pt-1">
|
||||
<button
|
||||
type="button"
|
||||
disabled={saving}
|
||||
onClick={onConfirm}
|
||||
className="rounded-lg bg-accent px-4 py-2 text-sm font-medium text-accent-fg shadow-sm hover:bg-accent-hover disabled:opacity-50"
|
||||
>
|
||||
{saving ? "..." : confirmLabel}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="rounded-lg border border-border px-4 py-2 text-sm font-medium text-muted hover:bg-surface-2"
|
||||
>
|
||||
Abbrechen
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user