Modul-Review 4: Matrix -- Kapitalverwendung, Phasendauer, Bedienung
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:
2026-07-25 23:09:03 +02:00
parent 0761f6b3e2
commit 0953880a80
17 changed files with 1040 additions and 267 deletions
+55 -14
View File
@@ -4,11 +4,14 @@ import { useState } from "react";
import { Trash2 } from "lucide-react";
import { NumberField, TextField } from "@/components/FormField";
import { CarryWarning } from "@/components/ElementDetail";
import { Button, useConfirm, useToast } from "@/components/ui";
import { api } from "@/lib/api-client";
import { planDurationChange } from "@/lib/phaseplan";
import type { PhaseInput } from "@/lib/types";
export function PhaseDetail({
phase,
allPhases,
maxDurationYears,
isLast,
laterPhaseCount,
@@ -16,6 +19,9 @@ export function PhaseDetail({
onDeleted,
}: {
phase: PhaseInput;
// Alle Phasen des Szenarios -- nötig, um die Auswirkung einer geänderten Dauer auf die
// Folgephase VORHER zu zeigen (siehe planDurationChange).
allPhases: PhaseInput[];
maxDurationYears: number | null;
isLast: boolean;
laterPhaseCount: number;
@@ -26,10 +32,32 @@ export function PhaseDetail({
const [durationYears, setDurationYears] = useState(phase.durationYears);
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
const confirm = useConfirm();
const toast = useToast();
const cap = maxDurationYears;
// Vorschau: Was passiert mit der Folgephase? Wird live berechnet, damit die Auswirkung
// sichtbar ist, BEVOR gespeichert wird.
const change = planDurationChange(allPhases, phase.id, durationYears);
async function save() {
// Verschiebt sich eine Folgephase, wird das vorher bestätigt -- sonst ändert ein Klick
// stillschweigend zwei Phasen.
if (change.ok && change.neighbourId && change.delta !== 0) {
const jahre = Math.abs(change.delta);
const ok = await confirm({
title: "Folgephase wird angepasst",
message:
`Wenn du «${phase.name}» um ${jahre} Jahr${jahre === 1 ? "" : "e"} ` +
`${change.delta > 0 ? "verlängerst" : "verkürzst"}, wird «${change.neighbourName}» automatisch auf ` +
`${change.neighbourNewDuration} Jahr${change.neighbourNewDuration === 1 ? "" : "e"} ` +
`${change.delta > 0 ? "gekürzt" : "verlängert"} die Gesamtdauer des Plans bleibt gleich. ` +
`Ohne diesen Ausgleich würde eine spätere Lebensphase eine Pensionierung überspannen.`,
confirmLabel: "Fortfahren",
});
if (!ok) return;
}
setSaving(true);
setError(null);
try {
@@ -43,21 +71,25 @@ export function PhaseDetail({
}
async function remove() {
if (!confirm(`Phase "${phase.name}" wirklich löschen?`)) return;
const ok = await confirm({
title: "Lebensphase löschen?",
message: `«${phase.name}» 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/${phase.id}`);
onDeleted();
} catch (e) {
alert(e instanceof Error ? e.message : "Löschen fehlgeschlagen.");
toast("error", e instanceof Error ? e.message : "Löschen fehlgeschlagen.");
}
}
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">
Lebensphase
</div>
<div className="text-xs font-semibold uppercase tracking-wide text-accent-soft-fg">Lebensphase</div>
{isLast && (
<button
type="button"
@@ -82,18 +114,27 @@ export function PhaseDetail({
onChange={(v) => setDurationYears(cap != null ? Math.min(v, cap) : v)}
/>
</div>
<p className="text-xs text-faint">Die Inflationsrate gilt plan-weit und wird in den Plan-Einstellungen gesetzt.</p>
{/* Auswirkung auf die Folgephase -- sichtbar, bevor gespeichert wird. */}
{change.error && (
<p className="rounded-lg border border-danger bg-danger-soft px-3 py-2 text-xs text-danger">{change.error}</p>
)}
{change.ok && change.neighbourId && change.delta !== 0 && (
<p className="rounded-lg border border-attention bg-attention-soft px-3 py-2 text-xs text-attention-soft-fg">
«{change.neighbourName}» wird dadurch auf <strong>{change.neighbourNewDuration} Jahre</strong>{" "}
{change.delta > 0 ? "gekürzt" : "verlängert"} die Gesamtdauer des Plans bleibt gleich.
</p>
)}
<p className="text-xs text-faint">
Die Inflationsrate gilt für das ganze Szenario und wird im Grundprofil gesetzt.
</p>
{error && <p className="text-sm text-danger">{error}</p>}
<div>
<button
type="button"
disabled={saving}
onClick={save}
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 ? "Speichern..." : "Speichern"}
</button>
<Button disabled={saving || !!change.error} onClick={save}>
{saving ? "Speichern…" : "Speichern"}
</Button>
</div>
</div>
);