c2fb82b0de
Deploy App / deploy (push) Successful in 1m1s
Rein an der Oberflaeche -- Berechnung, Datenmodell und API-Semantik unveraendert (103 Tests unveraendert gruen). Paket A (Fundament): - durchgehend Du-Form und echte Umlaute in allen sichtbaren Texten, inkl. API-Fehlermeldungen (vorher Mix aus Sie/Du und ae/oe/ue) - neue UI-Primitiven (ui.tsx): Button, Modal mit ESC/Fokus-Falle/Animation, Bestaetigungs-Dialog statt window.confirm, Toasts statt alert, Skeleton-Loader, EmptyState - eigene Attention-Farbe (Amber) fuer offene Entscheide, getrennt vom Akzent - Micro-Interactions mit prefers-reduced-motion-Fallback Paket B (Onboarding, Roadmap Nr. 10): - gefuehrter Plan-Assistent in 5 Schritten; Einkommen bewusst pro Person (raeumt die 9.9-AHV-Falle aus); reine Orchestrierung bestehender Endpunkte - Beispielplan mit einem Klick; Uebergaenge absichtlich offen - interaktive Tour ueber die Planansicht (localStorage, jederzeit neu startbar) - abgeleitete "Naechste Schritte"-Karte (offene Entscheide, fehlende Elemente, fehlende Pensionsphase, Ruin -> Einflussfaktoren) Paket C (Struktur): - Inspector-Panel rechts statt Modals fuer alle Einzel-Bearbeitungen; Matrix bleibt sichtbar, Zellklick wechselt den Inhalt - Phasenkopf auf vier Kern-Infos entschlackt (Rest in der 0.11-Detailansicht) - Matrix mit eigenem Scrollbereich, Koepfe beidachsig fixiert - Sidebar-Gruppen "Meine Plaene" / "Wissen"; "So rechnet FPT" statt SPEZIFIKATION; "Szenario-Profil" statt "Plan-Einstellungen" - Aktions-Icons ohne Hover sichtbar (Touch) Paket D (Extras): - Sparklines je Element-Zeile aus den 0.11-Verlaufswerten - Befehls-Palette (Ctrl/Cmd+K) - Ruin-Banner verlinkt auf die Einflussfaktoren Nebenbei: der ProfileMenu-Lint-Fehler und der Selection-Rest (9.17) sind behoben -- npm run lint laeuft erstmals fehlerfrei. SPEZIFIKATION auf 0.13: neue Kapitel 3.2.8, 3.7.6-3.7.9, 9.23, 9.24; 3.6.3 und 3.7.1 ueberarbeitet, 9.17 bereinigt. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
382 lines
12 KiB
TypeScript
382 lines
12 KiB
TypeScript
"use client";
|
|
|
|
// UI-Primitiven des Tools. Ein einziger Ort für Buttons, Dialoge, Bestätigungen, Toasts
|
|
// und Ladezustände -- damit Verhalten (ESC, Fokus-Falle, Animation) und Optik überall
|
|
// identisch sind, statt in jeder Komponente von Hand nachgebaut zu werden.
|
|
//
|
|
// Ersetzt insbesondere window.confirm()/alert(): Browser-Systemdialoge folgen keinem der
|
|
// drei Farbschemata und wirken wie ein Fremdkörper.
|
|
|
|
import {
|
|
createContext,
|
|
useCallback,
|
|
useContext,
|
|
useEffect,
|
|
useRef,
|
|
useState,
|
|
} from "react";
|
|
import { AlertTriangle, CheckCircle2, X } from "lucide-react";
|
|
|
|
// --- ESC-Verwaltung ----------------------------------------------------------------------
|
|
// Mehrere Schichten können gleichzeitig offen sein (Inspector + Dialog). ESC soll nur die
|
|
// OBERSTE schliessen. Jede Schicht registriert sich beim Öffnen; ESC trifft die letzte.
|
|
const escStack: (() => void)[] = [];
|
|
let escListenerAttached = false;
|
|
|
|
function ensureEscListener() {
|
|
if (escListenerAttached || typeof window === "undefined") return;
|
|
escListenerAttached = true;
|
|
window.addEventListener("keydown", (e) => {
|
|
if (e.key !== "Escape" || escStack.length === 0) return;
|
|
e.stopPropagation();
|
|
escStack[escStack.length - 1]();
|
|
});
|
|
}
|
|
|
|
function useEscClose(onClose: () => void) {
|
|
const closeRef = useRef(onClose);
|
|
useEffect(() => {
|
|
closeRef.current = onClose;
|
|
}, [onClose]);
|
|
useEffect(() => {
|
|
ensureEscListener();
|
|
const handler = () => closeRef.current();
|
|
escStack.push(handler);
|
|
return () => {
|
|
const i = escStack.indexOf(handler);
|
|
if (i >= 0) escStack.splice(i, 1);
|
|
};
|
|
}, []);
|
|
}
|
|
|
|
// Einfache Fokus-Falle: beim Öffnen den ersten fokussierbaren Inhalt fokussieren,
|
|
// Tab bleibt innerhalb der Schicht, beim Schliessen kehrt der Fokus zurück.
|
|
function useFocusTrap(ref: React.RefObject<HTMLElement | null>) {
|
|
useEffect(() => {
|
|
const el = ref.current;
|
|
if (!el) return;
|
|
const previous = document.activeElement as HTMLElement | null;
|
|
const focusables = () =>
|
|
[...el.querySelectorAll<HTMLElement>(
|
|
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
|
|
)].filter((x) => !x.hasAttribute("disabled"));
|
|
(focusables()[0] ?? el).focus({ preventScroll: true });
|
|
function onKey(e: KeyboardEvent) {
|
|
if (e.key !== "Tab") return;
|
|
const f = focusables();
|
|
if (f.length === 0) return;
|
|
const first = f[0];
|
|
const last = f[f.length - 1];
|
|
if (e.shiftKey && document.activeElement === first) {
|
|
e.preventDefault();
|
|
last.focus();
|
|
} else if (!e.shiftKey && document.activeElement === last) {
|
|
e.preventDefault();
|
|
first.focus();
|
|
}
|
|
}
|
|
el.addEventListener("keydown", onKey);
|
|
return () => {
|
|
el.removeEventListener("keydown", onKey);
|
|
previous?.focus?.({ preventScroll: true });
|
|
};
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, []);
|
|
}
|
|
|
|
// --- Button ------------------------------------------------------------------------------
|
|
|
|
type ButtonVariant = "primary" | "secondary" | "danger" | "ghost";
|
|
|
|
const BUTTON_CLASSES: Record<ButtonVariant, string> = {
|
|
primary:
|
|
"bg-accent text-accent-fg shadow-sm hover:bg-accent-hover active:scale-[0.98]",
|
|
secondary:
|
|
"border border-border text-muted hover:bg-surface-2 hover:text-fg active:scale-[0.98]",
|
|
danger:
|
|
"border border-border text-muted hover:border-danger hover:bg-danger-soft hover:text-danger active:scale-[0.98]",
|
|
ghost: "text-muted hover:bg-surface-2 hover:text-fg",
|
|
};
|
|
|
|
export function Button({
|
|
variant = "primary",
|
|
size = "md",
|
|
className = "",
|
|
children,
|
|
...rest
|
|
}: React.ButtonHTMLAttributes<HTMLButtonElement> & {
|
|
variant?: ButtonVariant;
|
|
size?: "sm" | "md";
|
|
}) {
|
|
const sizeClass = size === "sm" ? "px-2.5 py-1 text-xs" : "px-4 py-2 text-sm";
|
|
return (
|
|
<button
|
|
type="button"
|
|
{...rest}
|
|
className={`inline-flex items-center justify-center gap-1.5 rounded-lg font-medium transition-all duration-150 disabled:pointer-events-none disabled:opacity-50 ${BUTTON_CLASSES[variant]} ${sizeClass} ${className}`}
|
|
>
|
|
{children}
|
|
</button>
|
|
);
|
|
}
|
|
|
|
// --- Modal -------------------------------------------------------------------------------
|
|
|
|
export function Modal({
|
|
title,
|
|
subtitle,
|
|
onClose,
|
|
children,
|
|
wide,
|
|
}: {
|
|
title: string;
|
|
subtitle?: string;
|
|
onClose: () => void;
|
|
children: React.ReactNode;
|
|
wide?: boolean;
|
|
}) {
|
|
const panelRef = useRef<HTMLDivElement | null>(null);
|
|
useEscClose(onClose);
|
|
useFocusTrap(panelRef);
|
|
return (
|
|
<div
|
|
className="ui-fade fixed inset-0 z-40 flex items-start justify-center overflow-y-auto bg-black/40 px-4 py-8"
|
|
onClick={onClose}
|
|
>
|
|
<div
|
|
ref={panelRef}
|
|
role="dialog"
|
|
aria-modal="true"
|
|
aria-label={title}
|
|
onClick={(e) => e.stopPropagation()}
|
|
className={`ui-pop 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-start justify-between gap-3">
|
|
<div className="min-w-0">
|
|
<h2 className="text-base font-semibold text-fg">{title}</h2>
|
|
{subtitle && <p className="mt-0.5 text-xs text-muted">{subtitle}</p>}
|
|
</div>
|
|
<button
|
|
type="button"
|
|
onClick={onClose}
|
|
aria-label="Schliessen"
|
|
className="rounded-md p-1 text-faint transition-colors hover:bg-surface-2 hover:text-fg"
|
|
>
|
|
<X className="h-4 w-4" />
|
|
</button>
|
|
</div>
|
|
{children}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// --- Inspector ---------------------------------------------------------------------------
|
|
// Rechtes Seitenpanel für Einzel-Bearbeitungen (Zellen, Übergänge, Phase, Profil).
|
|
// Bewusst OHNE Backdrop: Die Matrix bleibt sichtbar und klickbar -- ein Klick auf eine
|
|
// andere Zelle wechselt einfach den Panel-Inhalt. Das hält den Kontext, den Modals nehmen.
|
|
// Auf schmalen Screens deckt das Panel die volle Breite ab.
|
|
|
|
export function InspectorShell({
|
|
title,
|
|
subtitle,
|
|
onClose,
|
|
children,
|
|
}: {
|
|
title: string;
|
|
subtitle?: string;
|
|
onClose: () => void;
|
|
children: React.ReactNode;
|
|
}) {
|
|
const panelRef = useRef<HTMLDivElement | null>(null);
|
|
useEscClose(onClose);
|
|
return (
|
|
<aside
|
|
ref={panelRef}
|
|
role="complementary"
|
|
aria-label={title}
|
|
className="ui-slide-in fixed inset-y-0 right-0 z-30 flex w-full flex-col border-l border-border bg-surface shadow-2xl sm:w-[26rem]"
|
|
>
|
|
<div className="flex items-start justify-between gap-3 border-b border-border px-5 py-4">
|
|
<div className="min-w-0">
|
|
<h2 className="truncate text-base font-semibold text-fg">{title}</h2>
|
|
{subtitle && <p className="mt-0.5 text-xs text-muted">{subtitle}</p>}
|
|
</div>
|
|
<button
|
|
type="button"
|
|
onClick={onClose}
|
|
aria-label="Panel schliessen"
|
|
className="rounded-md p-1 text-faint transition-colors hover:bg-surface-2 hover:text-fg"
|
|
>
|
|
<X className="h-4 w-4" />
|
|
</button>
|
|
</div>
|
|
<div className="flex-1 overflow-y-auto px-5 py-4">{children}</div>
|
|
</aside>
|
|
);
|
|
}
|
|
|
|
// --- Bestätigungs-Dialog (ersetzt window.confirm) ---------------------------------------
|
|
|
|
interface ConfirmOptions {
|
|
title: string;
|
|
message: string;
|
|
confirmLabel?: string;
|
|
danger?: boolean;
|
|
}
|
|
|
|
const ConfirmContext = createContext<((opts: ConfirmOptions) => Promise<boolean>) | null>(null);
|
|
|
|
export function useConfirm() {
|
|
const fn = useContext(ConfirmContext);
|
|
if (!fn) throw new Error("useConfirm ausserhalb des ConfirmProviders");
|
|
return fn;
|
|
}
|
|
|
|
export function ConfirmProvider({ children }: { children: React.ReactNode }) {
|
|
const [pending, setPending] = useState<{
|
|
opts: ConfirmOptions;
|
|
resolve: (ok: boolean) => void;
|
|
} | null>(null);
|
|
|
|
const confirm = useCallback(
|
|
(opts: ConfirmOptions) =>
|
|
new Promise<boolean>((resolve) => setPending({ opts, resolve })),
|
|
[]
|
|
);
|
|
|
|
function settle(ok: boolean) {
|
|
pending?.resolve(ok);
|
|
setPending(null);
|
|
}
|
|
|
|
return (
|
|
<ConfirmContext.Provider value={confirm}>
|
|
{children}
|
|
{pending && (
|
|
<Modal title={pending.opts.title} onClose={() => settle(false)}>
|
|
<p className="text-sm text-muted">{pending.opts.message}</p>
|
|
<div className="flex gap-2 pt-1">
|
|
<Button
|
|
variant={pending.opts.danger ? "danger" : "primary"}
|
|
autoFocus
|
|
onClick={() => settle(true)}
|
|
className={pending.opts.danger ? "border-danger bg-danger-soft text-danger" : ""}
|
|
>
|
|
{pending.opts.confirmLabel ?? "Bestätigen"}
|
|
</Button>
|
|
<Button variant="secondary" onClick={() => settle(false)}>
|
|
Abbrechen
|
|
</Button>
|
|
</div>
|
|
</Modal>
|
|
)}
|
|
</ConfirmContext.Provider>
|
|
);
|
|
}
|
|
|
|
// --- Toasts (ersetzt alert und stumme Erfolge) -------------------------------------------
|
|
|
|
interface ToastItem {
|
|
id: number;
|
|
kind: "success" | "error";
|
|
text: string;
|
|
}
|
|
|
|
const ToastContext = createContext<((kind: ToastItem["kind"], text: string) => void) | null>(null);
|
|
|
|
export function useToast() {
|
|
const fn = useContext(ToastContext);
|
|
if (!fn) throw new Error("useToast ausserhalb des ToastProviders");
|
|
return fn;
|
|
}
|
|
|
|
export function ToastProvider({ children }: { children: React.ReactNode }) {
|
|
const [toasts, setToasts] = useState<ToastItem[]>([]);
|
|
const nextId = useRef(1);
|
|
|
|
const push = useCallback((kind: ToastItem["kind"], text: string) => {
|
|
const id = nextId.current++;
|
|
setToasts((prev) => [...prev, { id, kind, text }]);
|
|
// Fehler bleiben länger stehen als Erfolge.
|
|
setTimeout(() => setToasts((prev) => prev.filter((t) => t.id !== id)), kind === "error" ? 6000 : 3200);
|
|
}, []);
|
|
|
|
return (
|
|
<ToastContext.Provider value={push}>
|
|
{children}
|
|
<div className="pointer-events-none fixed bottom-4 right-4 z-50 flex w-80 flex-col gap-2">
|
|
{toasts.map((t) => (
|
|
<div
|
|
key={t.id}
|
|
className={`ui-pop pointer-events-auto flex items-start gap-2 rounded-xl border px-3.5 py-2.5 text-sm shadow-lg ${
|
|
t.kind === "error"
|
|
? "border-danger bg-danger-soft text-danger"
|
|
: "border-border bg-surface text-fg"
|
|
}`}
|
|
>
|
|
{t.kind === "error" ? (
|
|
<AlertTriangle className="mt-0.5 h-4 w-4 shrink-0" />
|
|
) : (
|
|
<CheckCircle2 className="mt-0.5 h-4 w-4 shrink-0 text-success" />
|
|
)}
|
|
<span className="min-w-0 flex-1">{t.text}</span>
|
|
<button
|
|
type="button"
|
|
aria-label="Meldung schliessen"
|
|
onClick={() => setToasts((prev) => prev.filter((x) => x.id !== t.id))}
|
|
className="rounded p-0.5 text-faint hover:text-fg"
|
|
>
|
|
<X className="h-3.5 w-3.5" />
|
|
</button>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</ToastContext.Provider>
|
|
);
|
|
}
|
|
|
|
// --- Ladezustände und leere Zustände ---------------------------------------------------
|
|
|
|
export function Skeleton({ className = "" }: { className?: string }) {
|
|
return <div className={`animate-pulse rounded-lg bg-surface-2 ${className}`} />;
|
|
}
|
|
|
|
// Platzhalter während die Planansicht lädt -- deutet die Struktur an, statt nur
|
|
// "Lädt…" zu schreiben (wahrgenommene Geschwindigkeit).
|
|
export function PlanSkeleton() {
|
|
return (
|
|
<div className="flex flex-col gap-5" aria-busy="true" aria-label="Lädt">
|
|
<Skeleton className="h-8 w-64" />
|
|
<Skeleton className="h-24 w-full" />
|
|
<Skeleton className="h-12 w-full" />
|
|
<Skeleton className="h-72 w-full" />
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export function EmptyState({
|
|
icon,
|
|
title,
|
|
text,
|
|
children,
|
|
}: {
|
|
icon: React.ReactNode;
|
|
title: string;
|
|
text: string;
|
|
children?: React.ReactNode;
|
|
}) {
|
|
return (
|
|
<div className="flex flex-col items-center gap-3 rounded-xl border border-dashed border-border bg-surface p-10 text-center">
|
|
<div className="flex h-12 w-12 items-center justify-center rounded-2xl bg-accent-soft text-accent-soft-fg">
|
|
{icon}
|
|
</div>
|
|
<div>
|
|
<h3 className="text-sm font-semibold text-fg">{title}</h3>
|
|
<p className="mx-auto mt-1 max-w-md text-sm text-muted">{text}</p>
|
|
</div>
|
|
{children && <div className="mt-1 flex flex-wrap justify-center gap-2">{children}</div>}
|
|
</div>
|
|
);
|
|
}
|