Files
FPT/src/components/InfoBubble.tsx
T
admGitAICDS 0953880a80
Deploy App / deploy (push) Successful in 1m10s
Modul-Review 4: Matrix -- Kapitalverwendung, Phasendauer, Bedienung
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>
2026-07-25 23:09:03 +02:00

98 lines
3.5 KiB
TypeScript

"use client";
import { useEffect, useRef, useState } from "react";
import { createPortal } from "react-dom";
import { Info } from "lucide-react";
// Hilfetext-Bubble.
//
// Der Text wird über ein PORTAL an den <body> gehängt und `fixed` positioniert. Vorher lag er
// als `absolute` Kind im Fluss -- in einem scrollenden Dialog (Verteil-Dialoge, Assistent)
// schnitt dessen `overflow` die Box ab, teilweise nach wenigen Zeilen. Fix positioniert
// bezieht sich auf den Viewport und entkommt damit jedem Overflow-Container.
//
// Zusätzlich: Die Box klappt nach OBEN, wenn unten kein Platz mehr ist, und wird an den
// Bildschirmrändern geklemmt, damit sie nie halb ausserhalb steht.
const WIDTH = 256; // entspricht w-64
const MARGIN = 8;
export function InfoBubble({ text }: { text: string }) {
const [open, setOpen] = useState(false);
const [pos, setPos] = useState<{ top: number; left: number } | null>(null);
const btnRef = useRef<HTMLButtonElement | null>(null);
const boxRef = useRef<HTMLDivElement | null>(null);
// Position aus der Lage des Knopfes berechnen -- erst wenn geöffnet, und erneut, sobald die
// tatsächliche Höhe der Box bekannt ist (für das Hochklappen).
useEffect(() => {
if (!open) {
// eslint-disable-next-line react-hooks/set-state-in-effect -- Position gilt nur im offenen Zustand
setPos(null);
return;
}
const place = () => {
const btn = btnRef.current;
if (!btn) return;
const r = btn.getBoundingClientRect();
const height = boxRef.current?.offsetHeight ?? 0;
const spaceBelow = window.innerHeight - r.bottom;
const above = height > 0 && spaceBelow < height + MARGIN && r.top > height + MARGIN;
const left = Math.min(
Math.max(MARGIN, r.left + r.width / 2 - WIDTH / 2),
Math.max(MARGIN, window.innerWidth - WIDTH - MARGIN)
);
setPos({ top: above ? r.top - height - 6 : r.bottom + 6, left });
};
place();
// Nach dem ersten Zeichnen erneut -- dann steht die Höhe fest.
const raf = requestAnimationFrame(place);
window.addEventListener("scroll", place, true);
window.addEventListener("resize", place);
return () => {
cancelAnimationFrame(raf);
window.removeEventListener("scroll", place, true);
window.removeEventListener("resize", place);
};
}, [open]);
return (
<span className="relative inline-flex align-middle ml-1">
<button
ref={btnRef}
type="button"
aria-label="Hilfe anzeigen"
onMouseEnter={() => setOpen(true)}
onMouseLeave={() => setOpen(false)}
onClick={(e) => {
e.stopPropagation();
setOpen((o) => !o);
}}
className="flex h-4 w-4 items-center justify-center rounded-full bg-accent-soft text-accent-soft-fg hover:opacity-80"
>
<Info className="h-2.5 w-2.5" strokeWidth={2.5} />
</button>
{open &&
typeof document !== "undefined" &&
createPortal(
<div
ref={boxRef}
role="tooltip"
style={{
position: "fixed",
top: pos?.top ?? -9999,
left: pos?.left ?? -9999,
width: WIDTH,
// Vor der ersten Messung unsichtbar, damit die Box nicht kurz aufblitzt.
visibility: pos ? "visible" : "hidden",
}}
className="pointer-events-none z-[60] rounded-lg border border-border bg-surface p-2.5 text-xs leading-snug text-fg shadow-lg"
>
{text}
</div>,
document.body
)}
</span>
);
}