11 Angajamente 3bf0f6403b ... 8212f33c60

Autor SHA1 Permisiunea de a trimite mesaje. Dacă este dezactivată, utilizatorul nu va putea trimite nici un fel de mesaj Data
  Code_Uwe 8212f33c60 feat(ui): integrate tooltips for improved user guidance in navigation components 1 săptămână în urmă
  Code_Uwe 713e7ff397 feat(ui): implement consistent loading UI delays across components and add uxTimings module 1 săptămână în urmă
  Code_Uwe 956d088bc4 feat(ui): add DropdownMenuItem for navigating to the last valid branch and adjust DropdownMenuContent width 1 săptămână în urmă
  Code_Uwe d8fa38db65 fix(ui): improve layout of actions group in TopNav for better separation and spacing 1 săptămână în urmă
  Code_Uwe df591ffeff feat(ui): add DebouncedSkeleton component to improve loading experience and reduce flicker 1 săptămână în urmă
  Code_Uwe 5aeebd1d6b feat(ui): enhance loading indicators with debounced visibility and improve UI polish across components 1 săptămână în urmă
  Code_Uwe 5a898e0880 feat(tooltip): add Tooltip component with provider, trigger, and content for enhanced UI interactions 1 săptămână în urmă
  Code_Uwe 4cb9a671dd feat(hooks): add useDebouncedVisibility hook for controlled visibility transitions 1 săptămână în urmă
  Code_Uwe 9e8a615900 chore(dependencies): add @radix-ui/react-tooltip for enhanced UI tooltips 1 săptămână în urmă
  Code_Uwe 28baefb28c chore(favicon): update favicon.ico to improve branding 1 săptămână în urmă
  Code_Uwe 136d508cec feat(profile): add ProfilePage component with user session details and email placeholder 1 săptămână în urmă

+ 5 - 0
app/(protected)/profile/page.jsx

@@ -0,0 +1,5 @@
+import ProfilePage from "@/components/profile/ProfilePage";
+
+export default function ProfileRoute() {
+	return <ProfilePage />;
+}

BIN
app/favicon.ico


+ 134 - 69
components/app-shell/QuickNav.jsx

@@ -3,7 +3,12 @@
 import React from "react";
 import Link from "next/link";
 import { usePathname, useRouter } from "next/navigation";
-import { FolderOpen, Search as SearchIcon } from "lucide-react";
+import {
+	FolderOpen,
+	Search as SearchIcon,
+	TriangleAlert,
+	CornerDownLeft,
+} from "lucide-react";
 
 import { useAuth } from "@/components/auth/authContext";
 import { getBranches } from "@/lib/frontend/apiClient";
@@ -24,12 +29,18 @@ import { Button } from "@/components/ui/button";
 import {
 	DropdownMenu,
 	DropdownMenuContent,
+	DropdownMenuItem,
 	DropdownMenuLabel,
 	DropdownMenuRadioGroup,
 	DropdownMenuRadioItem,
 	DropdownMenuSeparator,
 	DropdownMenuTrigger,
 } from "@/components/ui/dropdown-menu";
+import {
+	Tooltip,
+	TooltipContent,
+	TooltipTrigger,
+} from "@/components/ui/tooltip";
 
 const STORAGE_KEY_LAST_BRANCH = "rhl_last_branch";
 
@@ -40,6 +51,12 @@ const BRANCH_LIST_STATE = Object.freeze({
 	ERROR: "error",
 });
 
+const TOPNAV_BUTTON_CLASS = "shadow-none has-[>svg]:px-3";
+
+const ACTIVE_NAV_BUTTON_CLASS =
+	"border-blue-600 bg-blue-50 text-blue-900 hover:bg-blue-50 " +
+	"dark:border-blue-900 dark:bg-blue-950 dark:text-blue-50 dark:hover:bg-blue-950";
+
 export default function QuickNav() {
 	const router = useRouter();
 	const pathname = usePathname() || "/";
@@ -53,8 +70,6 @@ export default function QuickNav() {
 
 	const canRevalidate = typeof retry === "function";
 
-	// Persisted selection for admin/dev:
-	// - Used as the "safe fallback" when the route branch is invalid/non-existent.
 	const [selectedBranch, setSelectedBranch] = React.useState(null);
 
 	const [branchList, setBranchList] = React.useState({
@@ -79,35 +94,29 @@ export default function QuickNav() {
 			? branchList.branches
 			: null;
 
-	const isKnownRouteBranch = React.useMemo(() => {
-		if (!routeBranch) return false;
-		if (!knownBranches) return false; // do not "trust" route until we know the branch list
-		return knownBranches.includes(routeBranch);
-	}, [routeBranch, knownBranches]);
+	const hasInvalidRouteBranch = Boolean(
+		isAdminDev &&
+		routeBranch &&
+		knownBranches &&
+		!knownBranches.includes(routeBranch),
+	);
 
 	React.useEffect(() => {
 		if (!isAuthenticated) return;
 
-		// Branch users: fixed branch, no persisted selection needed.
 		if (isBranchUser) {
 			const own = user.branchId;
 			setSelectedBranch(own && isValidBranchParam(own) ? own : null);
 			return;
 		}
 
-		// Admin/dev: initialize from localStorage only.
-		// IMPORTANT:
-		// We do NOT initialize from the route branch, because invalid-but-syntactically-valid
-		// branches (e.g. NL200) would pollute state and cause thrashing.
 		const fromStorage = safeReadLocalStorageBranch(STORAGE_KEY_LAST_BRANCH);
-
 		if (fromStorage && fromStorage !== selectedBranch) {
 			setSelectedBranch(fromStorage);
 		}
 	}, [isAuthenticated, isBranchUser, user?.branchId, selectedBranch]);
 
 	React.useEffect(() => {
-		// Fetch the branch list once for admin/dev users (or when the user changes).
 		if (!isAdminDev) return;
 
 		let cancelled = false;
@@ -124,7 +133,6 @@ export default function QuickNav() {
 			} catch (err) {
 				if (cancelled) return;
 
-				// Fail open: do not block navigation if validation fails.
 				console.error("[QuickNav] getBranches failed:", err);
 				setBranchList({ status: BRANCH_LIST_STATE.ERROR, branches: null });
 			}
@@ -136,7 +144,6 @@ export default function QuickNav() {
 	}, [isAdminDev, user?.userId]);
 
 	React.useEffect(() => {
-		// Once we know the branch list, keep selectedBranch valid and stable.
 		if (!isAdminDev) return;
 		if (!knownBranches || knownBranches.length === 0) return;
 
@@ -148,32 +155,28 @@ export default function QuickNav() {
 	}, [isAdminDev, knownBranches, selectedBranch]);
 
 	React.useEffect(() => {
-		// Sync selectedBranch to the current route branch ONLY when that route branch is known to exist.
-		// This prevents the "NL200 thrash" while still keeping the dropdown in sync for valid routes.
 		if (!isAdminDev) return;
-		if (!isKnownRouteBranch) return;
 		if (!routeBranch) return;
+		if (!knownBranches) return;
+
+		const isKnownRouteBranch = knownBranches.includes(routeBranch);
+		if (!isKnownRouteBranch) return;
 
 		if (routeBranch !== selectedBranch) {
 			setSelectedBranch(routeBranch);
 			safeWriteLocalStorageBranch(STORAGE_KEY_LAST_BRANCH, routeBranch);
 		}
-	}, [isAdminDev, isKnownRouteBranch, routeBranch, selectedBranch]);
+	}, [isAdminDev, routeBranch, knownBranches, selectedBranch]);
 
 	React.useEffect(() => {
 		if (!isAdminDev) return;
 		if (!selectedBranch) return;
 
-		// Keep localStorage in sync (defense-in-depth).
 		safeWriteLocalStorageBranch(STORAGE_KEY_LAST_BRANCH, selectedBranch);
 	}, [isAdminDev, selectedBranch]);
 
 	if (!isAuthenticated) return null;
 
-	// Effective branch for navigation:
-	// - Branch users: always their own branch
-	// - Admin/dev: always the persisted/validated selection (NOT the route branch)
-	//   This guarantees that nav buttons still work even when the user is on /NL200.
 	const effectiveBranch = isBranchUser ? user.branchId : selectedBranch;
 
 	const canNavigate = Boolean(
@@ -199,34 +202,72 @@ export default function QuickNav() {
 
 		if (!nextUrl) return;
 
-		// Trigger a session revalidation without causing content flicker.
 		if (canRevalidate) retry();
-
 		router.push(nextUrl);
 	}
 
-	const explorerVariant = isExplorerActive ? "secondary" : "ghost";
-	const searchVariant = isSearchActive ? "secondary" : "ghost";
+	const branchTooltipText = hasInvalidRouteBranch
+		? `Achtung: ${routeBranch} existiert nicht. Bitte eine gültige Niederlassung wählen.`
+		: "Niederlassung auswählen";
 
 	return (
 		<div className="hidden items-center gap-2 md:flex">
 			{isAdminDev ? (
 				<DropdownMenu>
-					<DropdownMenuTrigger asChild>
-						<Button
-							variant="outline"
-							size="sm"
-							type="button"
-							title="Niederlassung auswählen"
-						>
-							{canNavigate ? effectiveBranch : "Niederlassung wählen"}
-						</Button>
-					</DropdownMenuTrigger>
-
-					<DropdownMenuContent align="end" className="min-w-56">
+					<Tooltip>
+						<TooltipTrigger asChild>
+							<DropdownMenuTrigger asChild>
+								<Button
+									variant="outline"
+									size="sm"
+									type="button"
+									className={TOPNAV_BUTTON_CLASS}
+									aria-label={branchTooltipText}
+								>
+									{canNavigate ? effectiveBranch : "Niederlassung wählen"}
+									{hasInvalidRouteBranch ? (
+										<TriangleAlert
+											className="h-4 w-4 text-destructive"
+											aria-hidden="true"
+										/>
+									) : null}
+								</Button>
+							</DropdownMenuTrigger>
+						</TooltipTrigger>
+
+						<TooltipContent side="bottom">{branchTooltipText}</TooltipContent>
+					</Tooltip>
+
+					<DropdownMenuContent align="end" className="min-w-64">
 						<DropdownMenuLabel>Niederlassung</DropdownMenuLabel>
 						<DropdownMenuSeparator />
 
+						{hasInvalidRouteBranch ? (
+							<>
+								<div className="px-2 py-2 text-xs text-destructive">
+									Die URL-Niederlassung <strong>{routeBranch}</strong> existiert
+									nicht. Bitte wählen Sie eine gültige Niederlassung aus.
+								</div>
+
+								<DropdownMenuItem
+									disabled={!canNavigate}
+									onSelect={(e) => {
+										e.preventDefault();
+										if (!canNavigate) return;
+										navigateToBranchKeepingContext(effectiveBranch);
+									}}
+								>
+									<CornerDownLeft className="h-4 w-4" aria-hidden="true" />
+									Zur letzten gültigen Niederlassung
+									<span className="ml-auto text-xs text-muted-foreground">
+										{canNavigate ? effectiveBranch : ""}
+									</span>
+								</DropdownMenuItem>
+
+								<DropdownMenuSeparator />
+							</>
+						) : null}
+
 						{branchList.status === BRANCH_LIST_STATE.ERROR ? (
 							<div className="px-2 py-2 text-xs text-muted-foreground">
 								Konnte nicht geladen werden.
@@ -240,7 +281,6 @@ export default function QuickNav() {
 
 									setSelectedBranch(value);
 									safeWriteLocalStorageBranch(STORAGE_KEY_LAST_BRANCH, value);
-
 									navigateToBranchKeepingContext(value);
 								}}
 							>
@@ -258,32 +298,57 @@ export default function QuickNav() {
 				</DropdownMenu>
 			) : null}
 
-			<Button
-				variant={explorerVariant}
-				size="sm"
-				asChild
-				disabled={!canNavigate}
-			>
-				<Link
-					href={canNavigate ? branchPath(effectiveBranch) : "#"}
-					title="Explorer öffnen"
-					aria-current={isExplorerActive ? "page" : undefined}
-				>
-					<FolderOpen className="h-4 w-4" />
-					Explorer
-				</Link>
-			</Button>
-
-			<Button variant={searchVariant} size="sm" asChild disabled={!canNavigate}>
-				<Link
-					href={canNavigate ? searchPath(effectiveBranch) : "#"}
-					title="Suche öffnen"
-					aria-current={isSearchActive ? "page" : undefined}
-				>
-					<SearchIcon className="h-4 w-4" />
-					Suche
-				</Link>
-			</Button>
+			<Tooltip>
+				<TooltipTrigger asChild>
+					<Button
+						variant="outline"
+						size="sm"
+						asChild
+						disabled={!canNavigate}
+						className={[
+							TOPNAV_BUTTON_CLASS,
+							isExplorerActive ? ACTIVE_NAV_BUTTON_CLASS : "",
+						].join(" ")}
+						aria-label="Explorer öffnen"
+					>
+						<Link
+							href={canNavigate ? branchPath(effectiveBranch) : "#"}
+							aria-current={isExplorerActive ? "page" : undefined}
+						>
+							<FolderOpen className="h-4 w-4" />
+							Explorer
+						</Link>
+					</Button>
+				</TooltipTrigger>
+
+				<TooltipContent side="bottom">Explorer öffnen</TooltipContent>
+			</Tooltip>
+
+			<Tooltip>
+				<TooltipTrigger asChild>
+					<Button
+						variant="outline"
+						size="sm"
+						asChild
+						disabled={!canNavigate}
+						className={[
+							TOPNAV_BUTTON_CLASS,
+							isSearchActive ? ACTIVE_NAV_BUTTON_CLASS : "",
+						].join(" ")}
+						aria-label="Suche öffnen"
+					>
+						<Link
+							href={canNavigate ? searchPath(effectiveBranch) : "#"}
+							aria-current={isSearchActive ? "page" : undefined}
+						>
+							<SearchIcon className="h-4 w-4" />
+							Suche
+						</Link>
+					</Button>
+				</TooltipTrigger>
+
+				<TooltipContent side="bottom">Suche öffnen</TooltipContent>
+			</Tooltip>
 		</div>
 	);
 }

+ 34 - 26
components/app-shell/SessionIndicator.jsx

@@ -4,37 +4,45 @@ import React from "react";
 import { Loader2 } from "lucide-react";
 
 import { useAuth } from "@/components/auth/authContext";
+import { useDebouncedVisibility } from "@/lib/frontend/hooks/useDebouncedVisibility";
+import {
+	SESSION_INDICATOR_DELAY_MS,
+	SESSION_INDICATOR_MIN_VISIBLE_MS,
+} from "@/lib/frontend/ui/uxTimings";
+
+import {
+	Tooltip,
+	TooltipContent,
+	TooltipTrigger,
+} from "@/components/ui/tooltip";
 
-/**
- * SessionIndicator (RHL-032)
- *
- * Shows a small inline indicator when:
- * - the initial session check is running (status === "loading")
- * - a background revalidation is running (isValidating === true)
- *
- * UX:
- * - Keep it subtle and non-blocking.
- * - Text is German.
- */
 export default function SessionIndicator() {
 	const { status, isValidating } = useAuth();
 
-	const show = status === "loading" || Boolean(isValidating);
-	if (!show) return null;
+	const isActive = status === "loading" || Boolean(isValidating);
+
+	const visible = useDebouncedVisibility(isActive, {
+		delayMs: SESSION_INDICATOR_DELAY_MS,
+		minVisibleMs: SESSION_INDICATOR_MIN_VISIBLE_MS,
+	});
+
+	if (!visible) return null;
 
 	return (
-		<div
-			className="flex items-center gap-2"
-			aria-live="polite"
-			title="Sitzung wird geprüft"
-		>
-			<Loader2
-				className="h-4 w-4 animate-spin text-muted-foreground"
-				aria-hidden="true"
-			/>
-			<span className="hidden text-xs text-muted-foreground md:inline">
-				Sitzung wird geprüft…
-			</span>
-		</div>
+		<Tooltip>
+			<TooltipTrigger asChild>
+				<div className="flex items-center gap-2" aria-live="polite">
+					<Loader2
+						className="h-4 w-4 animate-spin text-muted-foreground"
+						aria-hidden="true"
+					/>
+					<span className="hidden text-xs text-muted-foreground md:inline">
+						Sitzung wird geprüft…
+					</span>
+				</div>
+			</TooltipTrigger>
+
+			<TooltipContent side="bottom">Sitzung wird geprüft…</TooltipContent>
+		</Tooltip>
 	);
 }

+ 22 - 18
components/app-shell/ThemeToggleButton.jsx

@@ -5,13 +5,16 @@ import { Moon, Sun } from "lucide-react";
 import { useTheme } from "next-themes";
 
 import { Button } from "@/components/ui/button";
+import {
+	Tooltip,
+	TooltipContent,
+	TooltipTrigger,
+} from "@/components/ui/tooltip";
 
 export default function ThemeToggleButton() {
 	const { setTheme } = useTheme();
 
 	function toggleTheme() {
-		// We intentionally read the current state from the DOM class to avoid
-		// hydration-unsafe theme reads during SSR/SSG. :contentReference[oaicite:2]{index=2}
 		const isDark =
 			typeof document !== "undefined" &&
 			document.documentElement.classList.contains("dark");
@@ -20,21 +23,22 @@ export default function ThemeToggleButton() {
 	}
 
 	return (
-		<Button
-			type="button"
-			variant="ghost"
-			size="icon-sm"
-			onClick={toggleTheme}
-			aria-label="Design umschalten"
-			title="Design umschalten"
-		>
-			{/* Light mode: show Moon (switch to dark) */}
-			<Moon className="h-4 w-4 dark:hidden" aria-hidden="true" />
-
-			{/* Dark mode: show Sun (switch to light) */}
-			<Sun className="hidden h-4 w-4 dark:block" aria-hidden="true" />
-
-			<span className="sr-only">Design umschalten</span>
-		</Button>
+		<Tooltip>
+			<TooltipTrigger asChild>
+				<Button
+					type="button"
+					variant="ghost"
+					size="icon-sm"
+					onClick={toggleTheme}
+					aria-label="Design umschalten"
+				>
+					<Moon className="h-4 w-4 dark:hidden" aria-hidden="true" />
+					<Sun className="hidden h-4 w-4 dark:block" aria-hidden="true" />
+					<span className="sr-only">Design umschalten</span>
+				</Button>
+			</TooltipTrigger>
+
+			<TooltipContent side="bottom">Design umschalten</TooltipContent>
+		</Tooltip>
 	);
 }

+ 46 - 39
components/app-shell/TopNav.jsx

@@ -7,54 +7,61 @@ import UserStatus from "@/components/app-shell/UserStatus";
 import ThemeToggleButton from "@/components/app-shell/ThemeToggleButton";
 import SessionIndicator from "@/components/app-shell/SessionIndicator";
 
+import { TooltipProvider } from "@/components/ui/tooltip";
+import { TOOLTIP_DELAY_MS } from "@/lib/frontend/ui/uxTimings";
+
 export default function TopNav() {
 	return (
-		<header className="sticky top-0 z-50 w-full border-b bg-background/80 backdrop-blur">
-			<div className="px-4">
-				<div className="mx-auto grid h-14 w-full items-center 2xl:grid-cols-[1fr_minmax(0,45%)_1fr]">
-					<div className="flex items-center justify-between gap-4 2xl:col-start-2">
-						<div className="flex items-center gap-2">
-							<Link
-								href="/"
-								title="Startseite"
-								className="hover:cursor-pointer"
-							>
-								<div className="relative h-10 w-16">
-									<Image
-										src="/brand/logo-blackNav.png"
-										alt="Firmenlogo"
-										fill
-										sizes="64px"
-										className="object-contain dark:hidden"
-										priority
-									/>
-									<Image
-										src="/brand/logo-whiteNav.png"
-										alt="Firmenlogo"
-										fill
-										sizes="64px"
-										className="hidden object-contain dark:block"
-										priority
-									/>
-								</div>
-							</Link>
+		<header className="sticky top-0 z-50 w-full border-b bg-background">
+			<TooltipProvider delayDuration={TOOLTIP_DELAY_MS}>
+				<div className="px-4">
+					<div className="mx-auto grid h-14 w-full items-center 2xl:grid-cols-[1fr_minmax(0,45%)_1fr]">
+						<div className="flex items-center justify-between gap-4 2xl:col-start-2">
+							<div className="flex items-center gap-2">
+								<Link
+									href="/"
+									aria-label="Startseite"
+									className="hover:cursor-pointer"
+								>
+									<div className="relative h-10 w-16">
+										<Image
+											src="/brand/logo-blackNav.png"
+											alt="Firmenlogo"
+											fill
+											sizes="64px"
+											className="object-contain dark:hidden"
+											priority
+										/>
+										<Image
+											src="/brand/logo-whiteNav.png"
+											alt="Firmenlogo"
+											fill
+											sizes="64px"
+											className="hidden object-contain dark:block"
+											priority
+										/>
+									</div>
+								</Link>
 
-							<nav aria-label="Hauptnavigation" className="flex items-center">
-								<QuickNav />
-							</nav>
-						</div>
+								<nav aria-label="Hauptnavigation" className="flex items-center">
+									<QuickNav />
+								</nav>
+							</div>
 
-						<div className="flex items-center gap-3">
-							{/* New: inline session validation indicator (no content flicker) */}
-							<SessionIndicator />
+							<div className="flex items-center gap-3">
+								<div className="flex items-center gap-3" aria-label="Aktionen">
+									<SessionIndicator />
+									<ThemeToggleButton />
+								</div>
 
-							<ThemeToggleButton />
+								<div className="h-6 w-px bg-border" aria-hidden="true" />
 
-							<UserStatus />
+								<UserStatus />
+							</div>
 						</div>
 					</div>
 				</div>
-			</div>
+			</TooltipProvider>
 		</header>
 	);
 }

+ 117 - 40
components/app-shell/UserStatus.jsx

@@ -2,7 +2,10 @@
 
 import React from "react";
 import Link from "next/link";
-import { KeyRound, LogOut, Settings, User } from "lucide-react";
+import { usePathname } from "next/navigation";
+import { LifeBuoy, LogOut, User } from "lucide-react";
+
+import { cn } from "@/lib/utils";
 
 import { useAuth } from "@/components/auth/authContext";
 import { logout } from "@/lib/frontend/apiClient";
@@ -17,27 +20,80 @@ import {
 	DropdownMenuSeparator,
 	DropdownMenuTrigger,
 } from "@/components/ui/dropdown-menu";
+import {
+	Tooltip,
+	TooltipContent,
+	TooltipTrigger,
+} from "@/components/ui/tooltip";
+
+function formatRole(role) {
+	if (role === "branch") return "Niederlassung";
+	if (role === "admin") return "Admin";
+	if (role === "dev") return "Entwicklung";
+	return role ? String(role) : "Unbekannt";
+}
+
+function buildSupportMailto({ user, currentUrl, pathname, userAgent }) {
+	const to = "info@attus.de";
+
+	const roleLabel = user ? formatRole(user.role) : "Unbekannt";
+	const userLabel = user?.branchId
+		? `${roleLabel} (${user.branchId})`
+		: roleLabel;
+
+	const now = new Date();
+	const tz =
+		typeof Intl !== "undefined"
+			? Intl.DateTimeFormat().resolvedOptions().timeZone
+			: "";
+
+	const timestampLocal = now.toLocaleString("de-DE");
+	const timestampIso = now.toISOString();
+
+	const routeLine = pathname ? `Route: ${pathname}` : "Route: (unbekannt)";
+	const urlLine = currentUrl ? `URL: ${currentUrl}` : "URL: (bitte einfügen)";
+	const uaLine = userAgent
+		? `User-Agent: ${userAgent}`
+		: "User-Agent: (unbekannt)";
+	const timeLine = tz
+		? `Zeitpunkt: ${timestampLocal} (${tz})`
+		: `Zeitpunkt: ${timestampLocal}`;
+	const isoLine = `ISO: ${timestampIso}`;
+
+	const subject = user?.branchId
+		? `Support – RHL Lieferscheine (${user.branchId})`
+		: "Support – RHL Lieferscheine";
+
+	const body = [
+		"Hallo attus Support,",
+		"",
+		"bitte beschreibt hier kurz das Anliegen:",
+		"",
+		"- Was wollten Sie tun?",
+		"- Was ist passiert?",
+		"- (Optional) Screenshot / Zeitpunkt",
+		"",
+		"--- Kontext (bitte drin lassen) ---",
+		`Benutzer: ${userLabel}`,
+		routeLine,
+		urlLine,
+		timeLine,
+		isoLine,
+		uaLine,
+		"----------------------------------",
+		"",
+		"Vielen Dank.",
+	].join("\r\n");
+
+	return `mailto:${to}?subject=${encodeURIComponent(subject)}&body=${encodeURIComponent(
+		body,
+	)}`;
+}
 
-/**
- * UserStatus (RHL-020)
- *
- * Updated responsibilities (RHL-032):
- * - Display minimal session info in the TopNav.
- * - Act as a user action menu trigger (settings/logout).
- *
- * UX rule:
- * - All user-facing strings must be German.
- */
 export default function UserStatus() {
+	const pathname = usePathname() || "/";
 	const { status, user } = useAuth();
 
-	function formatRole(role) {
-		if (role === "branch") return "Niederlassung";
-		if (role === "admin") return "Admin";
-		if (role === "dev") return "Entwicklung";
-		return role ? String(role) : "Unbekannt";
-	}
-
 	const isAuthenticated = status === "authenticated" && user;
 
 	let text = "Nicht geladen";
@@ -49,11 +105,23 @@ export default function UserStatus() {
 	if (status === "unauthenticated") text = "Abgemeldet";
 	if (status === "error") text = "Fehler";
 
+	const currentUrl = typeof window !== "undefined" ? window.location.href : "";
+	const userAgent = typeof navigator !== "undefined" ? navigator.userAgent : "";
+
+	const supportMailto = buildSupportMailto({
+		user,
+		currentUrl,
+		pathname,
+		userAgent,
+	});
+
+	const isProfileActive =
+		pathname === "/profile" || pathname.startsWith("/profile/");
+
 	async function handleLogout() {
 		try {
 			await logout();
 		} catch (err) {
-			// Logout is idempotent; we still redirect to login for predictable UX.
 			console.error("[UserStatus] logout failed:", err);
 		}
 
@@ -63,21 +131,28 @@ export default function UserStatus() {
 
 	return (
 		<DropdownMenu>
-			<DropdownMenuTrigger asChild>
-				<Button
-					type="button"
-					variant="ghost"
-					size="sm"
-					className="gap-2"
-					aria-label="Benutzermenü öffnen"
-					title="Benutzermenü"
-				>
-					<User className="h-4 w-4" aria-hidden="true" />
-
-					{/* Keep the label visible on md+ like before, but keep menu accessible on mobile */}
-					<span className="hidden text-xs md:inline">{text}</span>
-				</Button>
-			</DropdownMenuTrigger>
+			<Tooltip>
+				<TooltipTrigger asChild>
+					<DropdownMenuTrigger asChild>
+						<Button
+							type="button"
+							variant="ghost"
+							size="sm"
+							aria-label="Benutzermenü öffnen"
+							className={cn(
+								"gap-2",
+								"px-2 md:px-3",
+								isProfileActive ? "bg-accent" : "",
+							)}
+						>
+							<User className="h-4 w-4" aria-hidden="true" />
+							<span className="hidden text-xs md:inline">{text}</span>
+						</Button>
+					</DropdownMenuTrigger>
+				</TooltipTrigger>
+
+				<TooltipContent side="bottom">Benutzermenü</TooltipContent>
+			</Tooltip>
 
 			<DropdownMenuContent align="end" className="min-w-56">
 				<DropdownMenuLabel>Benutzer</DropdownMenuLabel>
@@ -91,15 +166,17 @@ export default function UserStatus() {
 				<DropdownMenuSeparator />
 
 				<DropdownMenuItem asChild disabled={!isAuthenticated}>
-					<Link href="/settings" className="flex w-full items-center gap-2">
-						<Settings className="h-4 w-4" aria-hidden="true" />
-						Einstellungen
+					<Link href="/profile" className="flex w-full items-center gap-2">
+						<User className="h-4 w-4" aria-hidden="true" />
+						Profil
 					</Link>
 				</DropdownMenuItem>
 
-				<DropdownMenuItem disabled title="Kommt später">
-					<KeyRound className="h-4 w-4" aria-hidden="true" />
-					Passwort ändern
+				<DropdownMenuItem asChild>
+					<a href={supportMailto} className="flex w-full items-center gap-2">
+						<LifeBuoy className="h-4 w-4" aria-hidden="true" />
+						Support
+					</a>
 				</DropdownMenuItem>
 
 				<DropdownMenuSeparator />

+ 26 - 19
components/explorer/levels/DaysExplorer.jsx

@@ -10,6 +10,8 @@ import { sortNumericStringsDesc } from "@/lib/frontend/explorer/sorters";
 import { mapExplorerError } from "@/lib/frontend/explorer/errorMapping";
 import { buildLoginUrl, LOGIN_REASONS } from "@/lib/frontend/authRedirect";
 import { useExplorerQuery } from "@/lib/frontend/hooks/useExplorerQuery";
+import { useDebouncedVisibility } from "@/lib/frontend/hooks/useDebouncedVisibility";
+import { LOADING_UI_DELAY_MS } from "@/lib/frontend/ui/uxTimings";
 
 import ExplorerBreadcrumbs from "@/components/explorer/breadcrumbs/ExplorerBreadcrumbs";
 import ExplorerPageShell from "@/components/explorer/ExplorerPageShell";
@@ -22,18 +24,10 @@ import ExplorerNotFound from "@/components/explorer/states/ExplorerNotFound";
 import ForbiddenView from "@/components/system/ForbiddenView";
 import { Button } from "@/components/ui/button";
 
-/**
- * DaysExplorer
- *
- * Explorer level: days for a given branch/year/month.
- * Loads years + months for breadcrumb dropdowns (fail-open).
- *
- * @param {{ branch: string, year: string, month: string }} props
- */
 export default function DaysExplorer({ branch, year, month }) {
 	const daysLoadFn = React.useCallback(
 		() => getDays(branch, year, month),
-		[branch, year, month]
+		[branch, year, month],
 	);
 	const daysQuery = useExplorerQuery(daysLoadFn, [daysLoadFn]);
 
@@ -42,15 +36,20 @@ export default function DaysExplorer({ branch, year, month }) {
 
 	const monthsLoadFn = React.useCallback(
 		() => getMonths(branch, year),
-		[branch, year]
+		[branch, year],
 	);
 	const monthsQuery = useExplorerQuery(monthsLoadFn, [monthsLoadFn]);
 
 	const mapped = React.useMemo(
 		() => mapExplorerError(daysQuery.error),
-		[daysQuery.error]
+		[daysQuery.error],
 	);
 
+	const showLoadingUi = useDebouncedVisibility(daysQuery.status === "loading", {
+		delayMs: LOADING_UI_DELAY_MS,
+		minVisibleMs: 0,
+	});
+
 	React.useEffect(() => {
 		if (mapped?.kind !== "unauthenticated") return;
 
@@ -60,7 +59,7 @@ export default function DaysExplorer({ branch, year, month }) {
 				: monthPath(branch, year, month);
 
 		window.location.replace(
-			buildLoginUrl({ reason: LOGIN_REASONS.EXPIRED, next })
+			buildLoginUrl({ reason: LOGIN_REASONS.EXPIRED, next }),
 		);
 	}, [mapped?.kind, branch, year, month]);
 
@@ -100,7 +99,7 @@ export default function DaysExplorer({ branch, year, month }) {
 		</Button>
 	);
 
-	if (daysQuery.status === "loading") {
+	if (showLoadingUi) {
 		return (
 			<ExplorerPageShell
 				title="Tage"
@@ -115,6 +114,19 @@ export default function DaysExplorer({ branch, year, month }) {
 		);
 	}
 
+	if (daysQuery.status === "loading") {
+		return (
+			<ExplorerPageShell
+				title="Tage"
+				description="Wählen Sie einen Tag aus."
+				breadcrumbs={breadcrumbsNode}
+				actions={actions}
+			>
+				<div className="h-16" aria-hidden="true" />
+			</ExplorerPageShell>
+		);
+	}
+
 	if (daysQuery.status === "error" && mapped) {
 		if (mapped.kind === "forbidden")
 			return <ForbiddenView attemptedBranch={branch} />;
@@ -142,12 +154,7 @@ export default function DaysExplorer({ branch, year, month }) {
 					description="Sitzung abgelaufen — Weiterleitung zum Login…"
 					breadcrumbs={breadcrumbsNode}
 				>
-					<ExplorerSectionCard
-						title="Weiterleitung"
-						description="Bitte warten…"
-					>
-						<ExplorerLoading variant="grid" count={6} />
-					</ExplorerSectionCard>
+					<div className="h-16" aria-hidden="true" />
 				</ExplorerPageShell>
 			);
 		}

+ 30 - 20
components/explorer/levels/FilesExplorer.jsx

@@ -14,6 +14,8 @@ import { sortFilesByNameAsc } from "@/lib/frontend/explorer/sorters";
 import { mapExplorerError } from "@/lib/frontend/explorer/errorMapping";
 import { buildLoginUrl, LOGIN_REASONS } from "@/lib/frontend/authRedirect";
 import { useExplorerQuery } from "@/lib/frontend/hooks/useExplorerQuery";
+import { useDebouncedVisibility } from "@/lib/frontend/hooks/useDebouncedVisibility";
+import { LOADING_UI_DELAY_MS } from "@/lib/frontend/ui/uxTimings";
 import { buildPdfUrl } from "@/lib/frontend/explorer/pdfUrl";
 
 import ExplorerBreadcrumbs from "@/components/explorer/breadcrumbs/ExplorerBreadcrumbs";
@@ -36,18 +38,10 @@ import {
 	TableCaption,
 } from "@/components/ui/table";
 
-/**
- * FilesExplorer
- *
- * Explorer leaf level: lists files (PDFs) for a day.
- * Loads years/months/days for breadcrumb dropdowns (fail-open).
- *
- * @param {{ branch: string, year: string, month: string, day: string }} props
- */
 export default function FilesExplorer({ branch, year, month, day }) {
 	const filesLoadFn = React.useCallback(
 		() => getFiles(branch, year, month, day),
-		[branch, year, month, day]
+		[branch, year, month, day],
 	);
 	const filesQuery = useExplorerQuery(filesLoadFn, [filesLoadFn]);
 
@@ -56,19 +50,27 @@ export default function FilesExplorer({ branch, year, month, day }) {
 
 	const monthsLoadFn = React.useCallback(
 		() => getMonths(branch, year),
-		[branch, year]
+		[branch, year],
 	);
 	const monthsQuery = useExplorerQuery(monthsLoadFn, [monthsLoadFn]);
 
 	const daysLoadFn = React.useCallback(
 		() => getDays(branch, year, month),
-		[branch, year, month]
+		[branch, year, month],
 	);
 	const daysQuery = useExplorerQuery(daysLoadFn, [daysLoadFn]);
 
 	const mapped = React.useMemo(
 		() => mapExplorerError(filesQuery.error),
-		[filesQuery.error]
+		[filesQuery.error],
+	);
+
+	const showLoadingUi = useDebouncedVisibility(
+		filesQuery.status === "loading",
+		{
+			delayMs: LOADING_UI_DELAY_MS,
+			minVisibleMs: 0,
+		},
 	);
 
 	React.useEffect(() => {
@@ -80,7 +82,7 @@ export default function FilesExplorer({ branch, year, month, day }) {
 				: dayPath(branch, year, month, day);
 
 		window.location.replace(
-			buildLoginUrl({ reason: LOGIN_REASONS.EXPIRED, next })
+			buildLoginUrl({ reason: LOGIN_REASONS.EXPIRED, next }),
 		);
 	}, [mapped?.kind, branch, year, month, day]);
 
@@ -128,7 +130,7 @@ export default function FilesExplorer({ branch, year, month, day }) {
 		</Button>
 	);
 
-	if (filesQuery.status === "loading") {
+	if (showLoadingUi) {
 		return (
 			<ExplorerPageShell
 				title="Dateien"
@@ -143,6 +145,19 @@ export default function FilesExplorer({ branch, year, month, day }) {
 		);
 	}
 
+	if (filesQuery.status === "loading") {
+		return (
+			<ExplorerPageShell
+				title="Dateien"
+				description="Lieferscheine für den ausgewählten Tag."
+				breadcrumbs={breadcrumbsNode}
+				actions={actions}
+			>
+				<div className="h-16" aria-hidden="true" />
+			</ExplorerPageShell>
+		);
+	}
+
 	if (filesQuery.status === "error" && mapped) {
 		if (mapped.kind === "forbidden")
 			return <ForbiddenView attemptedBranch={branch} />;
@@ -170,12 +185,7 @@ export default function FilesExplorer({ branch, year, month, day }) {
 					description="Sitzung abgelaufen — Weiterleitung zum Login…"
 					breadcrumbs={breadcrumbsNode}
 				>
-					<ExplorerSectionCard
-						title="Weiterleitung"
-						description="Bitte warten…"
-					>
-						<ExplorerLoading variant="table" count={6} />
-					</ExplorerSectionCard>
+					<div className="h-16" aria-hidden="true" />
 				</ExplorerPageShell>
 			);
 		}

+ 28 - 18
components/explorer/levels/MonthsExplorer.jsx

@@ -11,6 +11,8 @@ import { formatMonthLabel } from "@/lib/frontend/explorer/formatters";
 import { mapExplorerError } from "@/lib/frontend/explorer/errorMapping";
 import { buildLoginUrl, LOGIN_REASONS } from "@/lib/frontend/authRedirect";
 import { useExplorerQuery } from "@/lib/frontend/hooks/useExplorerQuery";
+import { useDebouncedVisibility } from "@/lib/frontend/hooks/useDebouncedVisibility";
+import { LOADING_UI_DELAY_MS } from "@/lib/frontend/ui/uxTimings";
 
 import ExplorerBreadcrumbs from "@/components/explorer/breadcrumbs/ExplorerBreadcrumbs";
 import ExplorerPageShell from "@/components/explorer/ExplorerPageShell";
@@ -23,18 +25,10 @@ import ExplorerNotFound from "@/components/explorer/states/ExplorerNotFound";
 import ForbiddenView from "@/components/system/ForbiddenView";
 import { Button } from "@/components/ui/button";
 
-/**
- * MonthsExplorer
- *
- * Explorer level: months for a given branch/year.
- * Loads available years for the breadcrumb dropdown (fail-open).
- *
- * @param {{ branch: string, year: string }} props
- */
 export default function MonthsExplorer({ branch, year }) {
 	const monthsLoadFn = React.useCallback(
 		() => getMonths(branch, year),
-		[branch, year]
+		[branch, year],
 	);
 	const monthsQuery = useExplorerQuery(monthsLoadFn, [monthsLoadFn]);
 
@@ -43,7 +37,15 @@ export default function MonthsExplorer({ branch, year }) {
 
 	const mapped = React.useMemo(
 		() => mapExplorerError(monthsQuery.error),
-		[monthsQuery.error]
+		[monthsQuery.error],
+	);
+
+	const showLoadingUi = useDebouncedVisibility(
+		monthsQuery.status === "loading",
+		{
+			delayMs: LOADING_UI_DELAY_MS,
+			minVisibleMs: 0,
+		},
 	);
 
 	React.useEffect(() => {
@@ -55,7 +57,7 @@ export default function MonthsExplorer({ branch, year }) {
 				: yearPath(branch, year);
 
 		window.location.replace(
-			buildLoginUrl({ reason: LOGIN_REASONS.EXPIRED, next })
+			buildLoginUrl({ reason: LOGIN_REASONS.EXPIRED, next }),
 		);
 	}, [mapped?.kind, branch, year]);
 
@@ -87,7 +89,7 @@ export default function MonthsExplorer({ branch, year }) {
 		</Button>
 	);
 
-	if (monthsQuery.status === "loading") {
+	if (showLoadingUi) {
 		return (
 			<ExplorerPageShell
 				title="Monate"
@@ -105,6 +107,19 @@ export default function MonthsExplorer({ branch, year }) {
 		);
 	}
 
+	if (monthsQuery.status === "loading") {
+		return (
+			<ExplorerPageShell
+				title="Monate"
+				description="Wählen Sie einen Monat aus."
+				breadcrumbs={breadcrumbsNode}
+				actions={actions}
+			>
+				<div className="h-16" aria-hidden="true" />
+			</ExplorerPageShell>
+		);
+	}
+
 	if (monthsQuery.status === "error" && mapped) {
 		if (mapped.kind === "forbidden")
 			return <ForbiddenView attemptedBranch={branch} />;
@@ -132,12 +147,7 @@ export default function MonthsExplorer({ branch, year }) {
 					description="Sitzung abgelaufen — Weiterleitung zum Login…"
 					breadcrumbs={breadcrumbsNode}
 				>
-					<ExplorerSectionCard
-						title="Weiterleitung"
-						description="Bitte warten…"
-					>
-						<ExplorerLoading variant="grid" count={6} />
-					</ExplorerSectionCard>
+					<div className="h-16" aria-hidden="true" />
 				</ExplorerPageShell>
 			);
 		}

+ 25 - 16
components/explorer/levels/YearsExplorer.jsx

@@ -10,6 +10,8 @@ import { sortNumericStringsDesc } from "@/lib/frontend/explorer/sorters";
 import { mapExplorerError } from "@/lib/frontend/explorer/errorMapping";
 import { buildLoginUrl, LOGIN_REASONS } from "@/lib/frontend/authRedirect";
 import { useExplorerQuery } from "@/lib/frontend/hooks/useExplorerQuery";
+import { useDebouncedVisibility } from "@/lib/frontend/hooks/useDebouncedVisibility";
+import { LOADING_UI_DELAY_MS } from "@/lib/frontend/ui/uxTimings";
 
 import ExplorerBreadcrumbs from "@/components/explorer/breadcrumbs/ExplorerBreadcrumbs";
 import ExplorerPageShell from "@/components/explorer/ExplorerPageShell";
@@ -22,19 +24,17 @@ import ExplorerNotFound from "@/components/explorer/states/ExplorerNotFound";
 import ForbiddenView from "@/components/system/ForbiddenView";
 import { Button } from "@/components/ui/button";
 
-/**
- * YearsExplorer
- *
- * Explorer entry level for a branch: shows available years.
- *
- * @param {{ branch: string }} props
- */
 export default function YearsExplorer({ branch }) {
 	const loadFn = React.useCallback(() => getYears(branch), [branch]);
 	const { status, data, error, retry } = useExplorerQuery(loadFn, [loadFn]);
 
 	const mapped = React.useMemo(() => mapExplorerError(error), [error]);
 
+	const showLoadingUi = useDebouncedVisibility(status === "loading", {
+		delayMs: LOADING_UI_DELAY_MS,
+		minVisibleMs: 0,
+	});
+
 	React.useEffect(() => {
 		if (mapped?.kind !== "unauthenticated") return;
 
@@ -44,7 +44,7 @@ export default function YearsExplorer({ branch }) {
 				: branchPath(branch);
 
 		window.location.replace(
-			buildLoginUrl({ reason: LOGIN_REASONS.EXPIRED, next })
+			buildLoginUrl({ reason: LOGIN_REASONS.EXPIRED, next }),
 		);
 	}, [mapped?.kind, branch]);
 
@@ -57,7 +57,7 @@ export default function YearsExplorer({ branch }) {
 		</Button>
 	);
 
-	if (status === "loading") {
+	if (showLoadingUi) {
 		return (
 			<ExplorerPageShell
 				title="Jahre"
@@ -72,9 +72,23 @@ export default function YearsExplorer({ branch }) {
 		);
 	}
 
+	if (status === "loading") {
+		return (
+			<ExplorerPageShell
+				title="Jahre"
+				description="Wählen Sie ein Jahr, um die Lieferscheine anzuzeigen."
+				breadcrumbs={breadcrumbsNode}
+				actions={actions}
+			>
+				<div className="h-16" aria-hidden="true" />
+			</ExplorerPageShell>
+		);
+	}
+
 	if (status === "error" && mapped) {
-		if (mapped.kind === "forbidden")
+		if (mapped.kind === "forbidden") {
 			return <ForbiddenView attemptedBranch={branch} />;
+		}
 
 		if (mapped.kind === "notfound") {
 			return (
@@ -96,12 +110,7 @@ export default function YearsExplorer({ branch }) {
 					description="Sitzung abgelaufen — Weiterleitung zum Login…"
 					breadcrumbs={breadcrumbsNode}
 				>
-					<ExplorerSectionCard
-						title="Weiterleitung"
-						description="Bitte warten…"
-					>
-						<ExplorerLoading variant="grid" count={6} />
-					</ExplorerSectionCard>
+					<div className="h-16" aria-hidden="true" />
 				</ExplorerPageShell>
 			);
 		}

+ 2 - 2
components/explorer/states/ExplorerLoading.jsx

@@ -4,8 +4,8 @@ import { Skeleton } from "@/components/ui/skeleton";
 /**
  * ExplorerLoading
  *
- * Loading placeholder for Explorer lists.
- * Supports a grid and a table-like variant.
+ * Pure skeleton layout for Explorer lists.
+ * We debounce the decision to render loading UI in the parent components.
  *
  * @param {{ variant?: "grid"|"table", count?: number }} props
  */

+ 127 - 0
components/profile/ProfilePage.jsx

@@ -0,0 +1,127 @@
+"use client";
+
+import React from "react";
+import { useAuth } from "@/components/auth/authContext";
+
+import { Button } from "@/components/ui/button";
+import { Input } from "@/components/ui/input";
+import { Label } from "@/components/ui/label";
+import {
+	Card,
+	CardHeader,
+	CardTitle,
+	CardDescription,
+	CardContent,
+	CardFooter,
+} from "@/components/ui/card";
+
+function formatRole(role) {
+	if (role === "branch") return "Niederlassung";
+	if (role === "admin") return "Admin";
+	if (role === "dev") return "Entwicklung";
+	return role ? String(role) : "Unbekannt";
+}
+
+export default function ProfilePage() {
+	const { status, user } = useAuth();
+
+	const isAuthenticated = status === "authenticated" && user;
+
+	const roleLabel = isAuthenticated ? formatRole(user.role) : "—";
+	const branchLabel = isAuthenticated ? user.branchId || "—" : "—";
+	const userIdLabel = isAuthenticated ? user.userId || "—" : "—";
+
+	return (
+		<div className="space-y-4">
+			<div className="space-y-1">
+				<h1 className="text-2xl font-semibold tracking-tight">Profil</h1>
+				<p className="text-sm text-muted-foreground">
+					Konto- und Zugangseinstellungen.
+				</p>
+			</div>
+
+			<Card>
+				<CardHeader>
+					<CardTitle>Konto</CardTitle>
+					<CardDescription>Aktuelle Sitzungsinformationen.</CardDescription>
+				</CardHeader>
+
+				<CardContent className="grid gap-3 text-sm">
+					<div className="flex items-center justify-between gap-4">
+						<span className="text-muted-foreground">Rolle</span>
+						<span>{roleLabel}</span>
+					</div>
+
+					<div className="flex items-center justify-between gap-4">
+						<span className="text-muted-foreground">Niederlassung</span>
+						<span>{branchLabel}</span>
+					</div>
+
+					<div className="flex items-center justify-between gap-4">
+						<span className="text-muted-foreground">User ID</span>
+						<span className="truncate">{userIdLabel}</span>
+					</div>
+				</CardContent>
+			</Card>
+
+			<Card>
+				<CardHeader>
+					<CardTitle>E-Mail</CardTitle>
+					<CardDescription>
+						Die Änderung der E-Mail-Adresse wird in einem späteren Ticket
+						aktiviert.
+					</CardDescription>
+				</CardHeader>
+
+				<CardContent className="grid gap-2">
+					<Label htmlFor="email">E-Mail-Adresse</Label>
+					<Input
+						id="email"
+						type="email"
+						placeholder="name@firma.de"
+						disabled
+						aria-disabled="true"
+					/>
+				</CardContent>
+
+				<CardFooter className="flex justify-end">
+					<Button
+						type="button"
+						disabled
+						aria-disabled="true"
+						title="Kommt später"
+					>
+						Speichern
+					</Button>
+				</CardFooter>
+			</Card>
+
+			<Card>
+				<CardHeader>
+					<CardTitle>Passwort</CardTitle>
+					<CardDescription>
+						Die Passwort-Änderung wird in einem separaten Ticket umgesetzt.
+					</CardDescription>
+				</CardHeader>
+
+				<CardFooter className="flex justify-end">
+					<Button
+						type="button"
+						disabled
+						aria-disabled="true"
+						title="Kommt später"
+					>
+						Passwort ändern
+					</Button>
+				</CardFooter>
+			</Card>
+
+			{!isAuthenticated ? (
+				<p className="text-xs text-muted-foreground">
+					Hinweis: Profilfunktionen sind nur verfügbar, wenn Sie angemeldet
+					sind.
+				</p>
+			) : null}
+		</div>
+	);
+}

+ 14 - 5
components/search/SearchResults.jsx

@@ -14,6 +14,8 @@ import {
 	sortSearchItems,
 	SEARCH_RESULTS_SORT,
 } from "@/lib/frontend/search/resultsSorting";
+import { useDebouncedVisibility } from "@/lib/frontend/hooks/useDebouncedVisibility";
+import { LOADING_UI_DELAY_MS } from "@/lib/frontend/ui/uxTimings";
 
 import SearchResultsToolbar from "@/components/search/SearchResultsToolbar";
 import SearchResultsTable from "@/components/search/SearchResultsTable";
@@ -33,6 +35,11 @@ export default function SearchResults({
 }) {
 	const [sortMode, setSortMode] = React.useState(SEARCH_RESULTS_SORT.RELEVANCE);
 
+	const showLoadingUi = useDebouncedVisibility(status === "loading", {
+		delayMs: LOADING_UI_DELAY_MS,
+		minVisibleMs: 0,
+	});
+
 	const sortedItems = React.useMemo(() => {
 		return sortSearchItems(items, sortMode);
 	}, [items, sortMode]);
@@ -57,13 +64,8 @@ export default function SearchResults({
 		);
 	}
 
-	if (status === "loading") {
-		return <ExplorerLoading variant="table" count={8} />;
-	}
-
 	if (status === "error" && error) {
 		// Validation errors are rendered in the SearchForm (near the inputs).
-		// We avoid showing a second alert in the results.
 		if (error.kind === "validation") {
 			return (
 				<ExplorerEmpty
@@ -83,6 +85,13 @@ export default function SearchResults({
 		);
 	}
 
+	if (showLoadingUi) {
+		return <ExplorerLoading variant="table" count={8} />;
+	}
+	if (status === "loading") {
+		return <div className="h-16" aria-hidden="true" />;
+	}
+
 	const list = Array.isArray(sortedItems) ? sortedItems : [];
 
 	if (list.length === 0) {

+ 3 - 3
components/search/form/SearchDateRangePicker.jsx

@@ -15,16 +15,16 @@ import {
 	PopoverContent,
 	PopoverTrigger,
 } from "@/components/ui/popover";
-import { Skeleton } from "@/components/ui/skeleton";
 
 import { useSearchDateRangePicker } from "@/lib/frontend/search/useSearchDateRangePicker";
+import { DebouncedSkeleton } from "@/components/ui/debounced-skeleton";
 
 function CalendarLoading() {
 	return (
 		<div className="space-y-3">
 			<div className="flex gap-4">
-				<Skeleton className="h-72 w-72" />
-				<Skeleton className="h-72 w-72" />
+				<DebouncedSkeleton className="h-72 w-72 rounded-md" />
+				<DebouncedSkeleton className="h-72 w-72 rounded-md" />
 			</div>
 			<p className="text-xs text-muted-foreground text-center">
 				Kalender lädt…

+ 5 - 4
components/search/form/SearchMultiBranchPicker.jsx

@@ -10,7 +10,8 @@ import { Button } from "@/components/ui/button";
 import { Checkbox } from "@/components/ui/checkbox";
 import { Input } from "@/components/ui/input";
 import { Label } from "@/components/ui/label";
-import { Skeleton } from "@/components/ui/skeleton";
+
+import { DebouncedSkeleton } from "@/components/ui/debounced-skeleton";
 
 function normalizeTypedBranch(value) {
 	if (typeof value !== "string") return null;
@@ -37,7 +38,7 @@ export default function SearchMultiBranchPicker({
 	const selected = Array.isArray(selectedBranches) ? selectedBranches : [];
 	const selectedSet = React.useMemo(
 		() => new Set(selected.map(String)),
-		[selected]
+		[selected],
 	);
 
 	const canClearAll =
@@ -83,8 +84,8 @@ export default function SearchMultiBranchPicker({
 					<div className="grid grid-cols-2 gap-2 sm:grid-cols-3 md:grid-cols-5">
 						{Array.from({ length: 15 }).map((_, i) => (
 							<div key={i} className="flex items-center gap-2">
-								<Skeleton className="h-4 w-4" />
-								<Skeleton className="h-4 w-14" />
+								<DebouncedSkeleton className="h-4 w-4 rounded-md" />
+								<DebouncedSkeleton className="h-4 w-14 rounded-md" />
 							</div>
 						))}
 					</div>

+ 5 - 4
components/search/form/SearchSingleBranchCombobox.jsx

@@ -9,7 +9,7 @@ import { cn } from "@/lib/utils";
 import { Button } from "@/components/ui/button";
 import { Input } from "@/components/ui/input";
 import { Label } from "@/components/ui/label";
-import { Skeleton } from "@/components/ui/skeleton";
+
 import {
 	Popover,
 	PopoverContent,
@@ -23,6 +23,7 @@ import {
 	CommandItem,
 	CommandList,
 } from "@/components/ui/command";
+import { DebouncedSkeleton } from "@/components/ui/debounced-skeleton";
 
 function normalizeTypedBranch(value) {
 	if (typeof value !== "string") return null;
@@ -100,7 +101,7 @@ export default function SearchSingleBranchCombobox({
 												<Check
 													className={cn(
 														"mr-2 h-4 w-4",
-														isActive ? "opacity-100" : "opacity-0"
+														isActive ? "opacity-100" : "opacity-0",
 													)}
 												/>
 												{id}
@@ -114,8 +115,8 @@ export default function SearchSingleBranchCombobox({
 						<div className="space-y-2 p-3">
 							{Array.from({ length: 6 }).map((_, i) => (
 								<div key={i} className="flex items-center gap-2">
-									<Skeleton className="h-4 w-4" />
-									<Skeleton className="h-4 w-16" />
+									<DebouncedSkeleton className="h-4 w-4 rounded-md" />
+									<DebouncedSkeleton className="h-4 w-16 rounded-md" />
 								</div>
 							))}
 						</div>

+ 27 - 0
components/ui/debounced-skeleton.jsx

@@ -0,0 +1,27 @@
+"use client";
+
+import React from "react";
+
+import { cn } from "@/lib/utils";
+import { Skeleton } from "@/components/ui/skeleton";
+import { useDebouncedVisibility } from "@/lib/frontend/hooks/useDebouncedVisibility";
+
+/**
+ * DebouncedSkeleton
+ *
+ * Behavior:
+ * - Reserves layout space immediately (renders a plain div with sizing classes).
+ * - Shows the animated Skeleton only after delayMs.
+ *
+ * This avoids "skeleton flicker" on fast loads.
+ */
+export function DebouncedSkeleton({ className, delayMs = 200, ...props }) {
+	const visible = useDebouncedVisibility(true, { delayMs, minVisibleMs: 0 });
+
+	if (!visible) {
+		// Reserve space, but do not show animation/background yet.
+		return <div aria-hidden="true" className={cn(className)} />;
+	}
+
+	return <Skeleton className={className} {...props} />;
+}

+ 55 - 0
components/ui/tooltip.jsx

@@ -0,0 +1,55 @@
+"use client"
+
+import * as React from "react"
+import * as TooltipPrimitive from "@radix-ui/react-tooltip"
+
+import { cn } from "@/lib/utils"
+
+function TooltipProvider({
+  delayDuration = 0,
+  ...props
+}) {
+  return (<TooltipPrimitive.Provider data-slot="tooltip-provider" delayDuration={delayDuration} {...props} />);
+}
+
+function Tooltip({
+  ...props
+}) {
+  return (
+    <TooltipProvider>
+      <TooltipPrimitive.Root data-slot="tooltip" {...props} />
+    </TooltipProvider>
+  );
+}
+
+function TooltipTrigger({
+  ...props
+}) {
+  return <TooltipPrimitive.Trigger data-slot="tooltip-trigger" {...props} />;
+}
+
+function TooltipContent({
+  className,
+  sideOffset = 0,
+  children,
+  ...props
+}) {
+  return (
+    <TooltipPrimitive.Portal>
+      <TooltipPrimitive.Content
+        data-slot="tooltip-content"
+        sideOffset={sideOffset}
+        className={cn(
+          "bg-foreground text-background animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-fit origin-(--radix-tooltip-content-transform-origin) rounded-md px-3 py-1.5 text-xs text-balance",
+          className
+        )}
+        {...props}>
+        {children}
+        <TooltipPrimitive.Arrow
+          className="bg-foreground fill-foreground z-50 size-2.5 translate-y-[calc(-50%_-_2px)] rotate-45 rounded-[2px]" />
+      </TooltipPrimitive.Content>
+    </TooltipPrimitive.Portal>
+  );
+}
+
+export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }

+ 75 - 0
lib/frontend/hooks/useDebouncedVisibility.js

@@ -0,0 +1,75 @@
+"use client";
+
+import React from "react";
+
+/**
+ * useDebouncedVisibility
+ *
+ * Delays the "show" transition and optionally enforces a minimum visible time.
+ *
+ * Typical use cases:
+ * - Avoid flicker for fast loading indicators (e.g. show only after 300ms).
+ * - Keep the indicator visible for at least X ms once shown (avoid blink).
+ *
+ * @param {boolean} isActive
+ * @param {{ delayMs?: number, minVisibleMs?: number }} options
+ * @returns {boolean}
+ */
+export function useDebouncedVisibility(
+	isActive,
+	{ delayMs = 200, minVisibleMs = 0 } = {},
+) {
+	const [visible, setVisible] = React.useState(false);
+
+	const showTimerRef = React.useRef(null);
+	const hideTimerRef = React.useRef(null);
+	const visibleSinceRef = React.useRef(0);
+
+	React.useEffect(() => {
+		if (showTimerRef.current) {
+			clearTimeout(showTimerRef.current);
+			showTimerRef.current = null;
+		}
+		if (hideTimerRef.current) {
+			clearTimeout(hideTimerRef.current);
+			hideTimerRef.current = null;
+		}
+
+		if (isActive) {
+			if (!visible) {
+				showTimerRef.current = setTimeout(
+					() => {
+						visibleSinceRef.current = Date.now();
+						setVisible(true);
+					},
+					Math.max(0, Number(delayMs) || 0),
+				);
+			}
+			return;
+		}
+
+		if (!visible) return;
+
+		const now = Date.now();
+		const elapsed = now - (visibleSinceRef.current || now);
+		const minMs = Math.max(0, Number(minVisibleMs) || 0);
+
+		if (elapsed >= minMs) {
+			setVisible(false);
+			return;
+		}
+
+		hideTimerRef.current = setTimeout(() => {
+			setVisible(false);
+		}, minMs - elapsed);
+	}, [isActive, delayMs, minVisibleMs, visible]);
+
+	React.useEffect(() => {
+		return () => {
+			if (showTimerRef.current) clearTimeout(showTimerRef.current);
+			if (hideTimerRef.current) clearTimeout(hideTimerRef.current);
+		};
+	}, []);
+
+	return visible;
+}

+ 6 - 0
lib/frontend/ui/uxTimings.js

@@ -0,0 +1,6 @@
+export const SESSION_INDICATOR_DELAY_MS = 200;
+export const SESSION_INDICATOR_MIN_VISIBLE_MS = 250;
+
+export const LOADING_UI_DELAY_MS = 300;
+
+export const TOOLTIP_DELAY_MS = 200;

+ 76 - 0
package-lock.json

@@ -14,6 +14,7 @@
 				"@radix-ui/react-label": "^2.1.8",
 				"@radix-ui/react-popover": "^1.1.15",
 				"@radix-ui/react-slot": "^1.2.4",
+				"@radix-ui/react-tooltip": "^1.2.8",
 				"bcryptjs": "^3.0.3",
 				"class-variance-authority": "^0.7.1",
 				"clsx": "^2.1.1",
@@ -2382,6 +2383,58 @@
 				}
 			}
 		},
+		"node_modules/@radix-ui/react-tooltip": {
+			"version": "1.2.8",
+			"resolved": "https://registry.npmjs.org/@radix-ui/react-tooltip/-/react-tooltip-1.2.8.tgz",
+			"integrity": "sha512-tY7sVt1yL9ozIxvmbtN5qtmH2krXcBCfjEiCgKGLqunJHvgvZG2Pcl2oQ3kbcZARb1BGEHdkLzcYGO8ynVlieg==",
+			"license": "MIT",
+			"dependencies": {
+				"@radix-ui/primitive": "1.1.3",
+				"@radix-ui/react-compose-refs": "1.1.2",
+				"@radix-ui/react-context": "1.1.2",
+				"@radix-ui/react-dismissable-layer": "1.1.11",
+				"@radix-ui/react-id": "1.1.1",
+				"@radix-ui/react-popper": "1.2.8",
+				"@radix-ui/react-portal": "1.1.9",
+				"@radix-ui/react-presence": "1.1.5",
+				"@radix-ui/react-primitive": "2.1.3",
+				"@radix-ui/react-slot": "1.2.3",
+				"@radix-ui/react-use-controllable-state": "1.2.2",
+				"@radix-ui/react-visually-hidden": "1.2.3"
+			},
+			"peerDependencies": {
+				"@types/react": "*",
+				"@types/react-dom": "*",
+				"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
+				"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+			},
+			"peerDependenciesMeta": {
+				"@types/react": {
+					"optional": true
+				},
+				"@types/react-dom": {
+					"optional": true
+				}
+			}
+		},
+		"node_modules/@radix-ui/react-tooltip/node_modules/@radix-ui/react-slot": {
+			"version": "1.2.3",
+			"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz",
+			"integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==",
+			"license": "MIT",
+			"dependencies": {
+				"@radix-ui/react-compose-refs": "1.1.2"
+			},
+			"peerDependencies": {
+				"@types/react": "*",
+				"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+			},
+			"peerDependenciesMeta": {
+				"@types/react": {
+					"optional": true
+				}
+			}
+		},
 		"node_modules/@radix-ui/react-use-callback-ref": {
 			"version": "1.1.1",
 			"resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.1.tgz",
@@ -2518,6 +2571,29 @@
 				}
 			}
 		},
+		"node_modules/@radix-ui/react-visually-hidden": {
+			"version": "1.2.3",
+			"resolved": "https://registry.npmjs.org/@radix-ui/react-visually-hidden/-/react-visually-hidden-1.2.3.tgz",
+			"integrity": "sha512-pzJq12tEaaIhqjbzpCuv/OypJY/BPavOofm+dbab+MHLajy277+1lLm6JFcGgF5eskJ6mquGirhXY2GD/8u8Ug==",
+			"license": "MIT",
+			"dependencies": {
+				"@radix-ui/react-primitive": "2.1.3"
+			},
+			"peerDependencies": {
+				"@types/react": "*",
+				"@types/react-dom": "*",
+				"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
+				"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+			},
+			"peerDependenciesMeta": {
+				"@types/react": {
+					"optional": true
+				},
+				"@types/react-dom": {
+					"optional": true
+				}
+			}
+		},
 		"node_modules/@radix-ui/rect": {
 			"version": "1.1.1",
 			"resolved": "https://registry.npmjs.org/@radix-ui/rect/-/rect-1.1.1.tgz",

+ 1 - 0
package.json

@@ -17,6 +17,7 @@
 		"@radix-ui/react-label": "^2.1.8",
 		"@radix-ui/react-popover": "^1.1.15",
 		"@radix-ui/react-slot": "^1.2.4",
+		"@radix-ui/react-tooltip": "^1.2.8",
 		"bcryptjs": "^3.0.3",
 		"class-variance-authority": "^0.7.1",
 		"clsx": "^2.1.1",