Modul-Review 4: Matrix -- Kapitalverwendung, Phasendauer, Bedienung
Deploy App / deploy (push) Successful in 1m10s
Deploy App / deploy (push) Successful in 1m10s
Kapitalverwendung (Punkt C) am richtigen Ort: - Prozent-Aufteilung des bezogenen Alterskapitals wandert vom Cash-Uebergang zum Bezugs-Entscheid der PK (nur bei Kapitalbezug) bzw. der Saeule 3a -- mit zwei Guthaben liess sie sich vorher gar nicht getrennt beantworten - Dialoge fuehren neu brutto -> Steuersatz -> netto -> Verteilung - BUGFIX: Der zugeteilte Betrag erhoehte still den internen Bestand, deshalb zeigten Ziel-Element und "Kapital verteilen" eine 0. Er laeuft jetzt ueber Carry.capitalIn als Zusatzeinlage der Folgephase und ist ueberall sichtbar - Saeule 3a ist am Pensions-Uebergang neu ein offener Entscheid Phasendauer (gemeldeter Fehler): - Die Folgephase gleicht eine geaenderte Dauer aus; Gesamtdauer bleibt gleich - Vorher kappte das Tool nur die bearbeitete Phase -> Phase 2 ueberspannte danach die Pensionierung und die Invariante aus Punkt 44 kippte - Rueckfrage vorher, Blockade wenn die Folgephase unter 1 Jahr fiele - neue reine Funktion planDurationChange Bedienung: - Element-Zeile und Phasenkopf: Stift (umbenennen, beim Element inkl. Zuordnung), Papierkorb, Expand -- alle immer sichtbar - PATCH /api/elements/<id> nimmt neu auch ownerRole - Hilfetexte via Portal (wurden in scrollenden Dialogen abgeschnitten) - Verteil-Dialoge: Zuordnung je Zeile, nach vom/ins Cash gruppiert, Vorbelegung mit dem EFFEKTIVEN Wert inkl. Vererbung (zeigte vorher 0) - Matrix: gleiche Spaltenbreiten + horizontales Scrollen, "Alle auf-/ zuklappen", Kategorie-Summe in der zugeklappten Zeile - Phasen-Detailansicht nutzt die neue Aufteilungs-Grafik - Uebersicht: "Leer starten" auch im leeren Zustand Nebenbei: dritte verstuemmelte Hex-Farbe (#7c3aed) repariert, Phasen-Panel nutzt den eigenen Bestaetigungs-Dialog statt window.confirm; mehrere veraltete Referenzen und die buildCarryData-Tabelle in der Spez nachgezogen. SPEZIFIKATION 0.33. 278 -> 288 Tests. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
+285
-30
@@ -12,12 +12,14 @@ import {
|
||||
Home,
|
||||
Landmark,
|
||||
ListTodo,
|
||||
Pencil,
|
||||
Maximize2,
|
||||
PiggyBank,
|
||||
Plus,
|
||||
Settings2,
|
||||
ShoppingCart,
|
||||
Tornado,
|
||||
Trash2,
|
||||
TrendingUp,
|
||||
Wallet,
|
||||
} from "lucide-react";
|
||||
@@ -48,7 +50,7 @@ import {
|
||||
import { PhaseDetail } from "@/components/PhaseDetail";
|
||||
import { PlanProfileFields, type ProfileDraft } from "@/components/PlanProfileFields";
|
||||
import { RetirementAdjuster } from "@/components/RetirementAdjuster";
|
||||
import { MoneyField } from "@/components/FormField";
|
||||
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";
|
||||
@@ -97,6 +99,7 @@ type Column =
|
||||
// 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 }
|
||||
@@ -279,6 +282,8 @@ export function PlanView({
|
||||
ahvCareer: careerFor(element),
|
||||
inheritedValues: inheritedPhaseValues(element.phaseValues, orderedPhaseIds, phase.id),
|
||||
ownerAgeStart: ownerAgeAtPhaseStart(phase, element),
|
||||
investTargets: [],
|
||||
capitalFromTransfer: ce?.capitalFromTransfer ?? 0,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -303,6 +308,9 @@ export function PlanView({
|
||||
ahvCareer: careerFor(element),
|
||||
inheritedValues: {},
|
||||
ownerAgeStart: ownerAgeAtPhaseStart(fromPhase, element),
|
||||
// Ziele fuer die Anlage-Quote beim Kapitalbezug (Punkt C).
|
||||
investTargets: investTargetsOf(toPhase),
|
||||
capitalFromTransfer: 0,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -500,6 +508,26 @@ export function PlanView({
|
||||
>
|
||||
<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>
|
||||
)}
|
||||
|
||||
@@ -525,10 +553,13 @@ export function PlanView({
|
||||
beim Scrollen sichtbar bleiben. */}
|
||||
{hasPhases && (
|
||||
<div data-tour="matrix" className="max-h-[75vh] overflow-auto rounded-xl border border-border bg-surface shadow-sm">
|
||||
<table className="w-full border-collapse text-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 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">
|
||||
<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) =>
|
||||
@@ -545,6 +576,11 @@ export function PlanView({
|
||||
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}
|
||||
/>
|
||||
) : (
|
||||
@@ -634,7 +670,37 @@ export function PlanView({
|
||||
{CATEGORY_LABELS[cat]}
|
||||
</span>
|
||||
</td>
|
||||
<td colSpan={columns.length} className="border-b border-border" />
|
||||
{/* 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) => (
|
||||
@@ -645,6 +711,33 @@ export function PlanView({
|
||||
{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}`}
|
||||
@@ -653,7 +746,7 @@ export function PlanView({
|
||||
e.stopPropagation();
|
||||
setDetailFor({ kind: "element", id: el.id });
|
||||
}}
|
||||
className="shrink-0 rounded p-0.5 text-faint opacity-0 hover:bg-accent-soft hover:text-accent group-hover/row:opacity-100"
|
||||
className="shrink-0 rounded p-0.5 text-faint hover:bg-accent-soft hover:text-accent"
|
||||
>
|
||||
<Maximize2 className="h-3 w-3" />
|
||||
</button>
|
||||
@@ -822,6 +915,7 @@ export function PlanView({
|
||||
<PhaseDetailDialog
|
||||
key={`detph-${detailFor.id}`}
|
||||
phase={ph}
|
||||
computed={computed}
|
||||
isFirst={ph.sequenceNumber === 1}
|
||||
onClose={() => setDetailFor(null)}
|
||||
onOpenSpec={onOpenSpec}
|
||||
@@ -862,6 +956,43 @@ export function PlanView({
|
||||
);
|
||||
|
||||
// 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);
|
||||
@@ -881,6 +1012,21 @@ export function PlanView({
|
||||
};
|
||||
|
||||
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);
|
||||
@@ -889,6 +1035,7 @@ export function PlanView({
|
||||
<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)}
|
||||
@@ -921,7 +1068,7 @@ export function PlanView({
|
||||
onSaved={closeAndRefresh}
|
||||
onDeleteElement={() => {
|
||||
setPanel(null);
|
||||
deleteElement(element.id);
|
||||
deleteElement(element.id, element.name);
|
||||
}}
|
||||
/>
|
||||
</InspectorShell>
|
||||
@@ -966,19 +1113,6 @@ export function PlanView({
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteElement(id: string) {
|
||||
const ok = await confirmDialog({
|
||||
title: "Element löschen?",
|
||||
message: "Das Element wird aus ALLEN Lebensphasen entfernt – samt aller Werte und Übergangs-Entscheide.",
|
||||
confirmLabel: "Endgültig löschen",
|
||||
danger: true,
|
||||
});
|
||||
if (!ok) return;
|
||||
await api.delete(`/api/elements/${id}`);
|
||||
toast("success", "Element gelöscht.");
|
||||
setPanel(null);
|
||||
onChanged();
|
||||
}
|
||||
|
||||
function transitionSummary(el: ElementInput, fromPhase: PhaseComputed, toPhase: PhaseComputed): string {
|
||||
const td = el.transitionValues[fromPhase.id] ?? {};
|
||||
@@ -1138,6 +1272,7 @@ function PhaseHeader({
|
||||
onExpand,
|
||||
onDistributeCapital,
|
||||
onDistributeRates,
|
||||
onDelete,
|
||||
active,
|
||||
}: {
|
||||
phase: PhaseComputed;
|
||||
@@ -1150,6 +1285,8 @@ function PhaseHeader({
|
||||
// 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";
|
||||
@@ -1160,7 +1297,7 @@ function PhaseHeader({
|
||||
return (
|
||||
<th
|
||||
onClick={onClick}
|
||||
className={`group/ph sticky top-0 z-30 min-w-44 cursor-pointer border-b border-r border-border px-2 py-2 text-left align-top ${
|
||||
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"
|
||||
@@ -1180,8 +1317,35 @@ function PhaseHeader({
|
||||
) : (
|
||||
<CheckCircle2 className="h-3.5 w-3.5 shrink-0 text-success" />
|
||||
)}
|
||||
{/* Der Kopf öffnet per Klick das Bearbeiten-Popup -- das Expand-Icon muss das Event
|
||||
deshalb stoppen, sonst gingen beide Dialoge gleichzeitig auf. */}
|
||||
{/* 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}`}
|
||||
@@ -1190,7 +1354,7 @@ function PhaseHeader({
|
||||
e.stopPropagation();
|
||||
onExpand();
|
||||
}}
|
||||
className="shrink-0 rounded p-0.5 text-faint opacity-60 transition-opacity hover:bg-accent-soft hover:text-accent group-hover/ph:opacity-100"
|
||||
className="shrink-0 rounded p-0.5 text-faint hover:bg-accent-soft hover:text-accent"
|
||||
>
|
||||
<Maximize2 className="h-3 w-3" />
|
||||
</button>
|
||||
@@ -1290,7 +1454,7 @@ function TransitionHeader({ openCount, onClick }: { openCount: number; onClick:
|
||||
<th
|
||||
onClick={onClick}
|
||||
data-tour="transition"
|
||||
className={`sticky top-0 z-30 cursor-pointer border-b border-r border-border px-2 py-2 text-center align-top text-[11px] font-medium transition-colors ${
|
||||
className={`sticky top-0 z-30 w-24 min-w-24 cursor-pointer border-b border-r border-border px-2 py-2 text-center align-top text-[11px] font-medium transition-colors ${
|
||||
done ? "bg-surface-2 text-success" : "bg-attention text-attention-fg"
|
||||
}`}
|
||||
>
|
||||
@@ -1491,6 +1655,8 @@ function AddElementDialog({
|
||||
// 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,
|
||||
};
|
||||
|
||||
async function create() {
|
||||
@@ -1763,9 +1929,6 @@ function TransitionReviewDialog({
|
||||
ct={ct}
|
||||
setC={(patch) => setCt((prev) => ({ ...prev, ...patch }))}
|
||||
deflatorEnd={fromPhase.cumulativeInflationEnd}
|
||||
showCapitalUse={(toPhase?.capitalInflow ?? 0) > 0}
|
||||
capitalInflow={toPhase?.capitalInflow ?? 0}
|
||||
investTargets={investTargetsOf(toPhase)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1857,9 +2020,6 @@ function CashTransitionPanel({
|
||||
ct={ct}
|
||||
setC={(patch) => setCt((prev) => ({ ...prev, ...patch }))}
|
||||
deflatorEnd={fromPhase.cumulativeInflationEnd}
|
||||
showCapitalUse={(toPhase?.capitalInflow ?? 0) > 0}
|
||||
capitalInflow={toPhase?.capitalInflow ?? 0}
|
||||
investTargets={investTargetsOf(toPhase)}
|
||||
/>
|
||||
</div>
|
||||
{error && <p className="mt-3 text-sm text-danger">{error}</p>}
|
||||
@@ -2024,3 +2184,98 @@ function PanelActions({
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user