0953880a80
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>
142 lines
5.3 KiB
TypeScript
142 lines
5.3 KiB
TypeScript
"use client";
|
||
|
||
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,
|
||
onSaved,
|
||
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;
|
||
onSaved: () => void;
|
||
onDeleted: () => void;
|
||
}) {
|
||
const [name, setName] = useState(phase.name);
|
||
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 {
|
||
await api.put(`/api/phases/${phase.id}`, { name, durationYears });
|
||
onSaved();
|
||
} catch (e) {
|
||
setError(e instanceof Error ? e.message : "Speichern fehlgeschlagen.");
|
||
} finally {
|
||
setSaving(false);
|
||
}
|
||
}
|
||
|
||
async function remove() {
|
||
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) {
|
||
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>
|
||
{isLast && (
|
||
<button
|
||
type="button"
|
||
onClick={remove}
|
||
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" /> Phase löschen
|
||
</button>
|
||
)}
|
||
</div>
|
||
|
||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||
{/* Eine geänderte Dauer verschiebt alle Folgephasen (Alter, Renten, Vermögen). */}
|
||
<CarryWarning laterPhaseCount={laterPhaseCount} />
|
||
<TextField label="Bezeichnung" value={name} onChange={setName} />
|
||
<NumberField
|
||
label={`Dauer (Jahre)${cap != null ? ` · max. ${cap}` : ""}`}
|
||
help={cap != null ? "Die Dauer ist ans nächste Pensionsereignis gekappt." : undefined}
|
||
value={durationYears}
|
||
min={1}
|
||
max={cap ?? undefined}
|
||
onChange={(v) => setDurationYears(cap != null ? Math.min(v, cap) : v)}
|
||
/>
|
||
</div>
|
||
|
||
{/* 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 disabled={saving || !!change.error} onClick={save}>
|
||
{saving ? "Speichern…" : "Speichern"}
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|