Browse Source

feat(ui): enhance loading indicators with debounced visibility and improve UI polish across components

Code_Uwe 1 week ago
parent
commit
5aeebd1d6b

+ 50 - 12
components/app-shell/QuickNav.jsx

@@ -3,7 +3,7 @@
 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 } from "lucide-react";
 
 import { useAuth } from "@/components/auth/authContext";
 import { getBranches } from "@/lib/frontend/apiClient";
@@ -40,8 +40,15 @@ const BRANCH_LIST_STATE = Object.freeze({
 	ERROR: "error",
 });
 
+// Header polish:
+// - outline buttons normally have a subtle shadow; remove it for crisp header UI
+// - normalize padding when an icon is present (avoid "uneven" look)
+const TOPNAV_BUTTON_CLASS = "shadow-none has-[>svg]:px-3";
+
+// Active nav style (blue like multi-branch selection)
 const ACTIVE_NAV_BUTTON_CLASS =
-	"border-blue-600 bg-blue-50 hover:bg-blue-50 dark:border-blue-900 dark:bg-blue-950 dark:hover:bg-blue-950";
+	"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();
@@ -80,11 +87,12 @@ export default function QuickNav() {
 			? branchList.branches
 			: null;
 
-	const isKnownRouteBranch = React.useMemo(() => {
-		if (!routeBranch) return false;
-		if (!knownBranches) return false;
-		return knownBranches.includes(routeBranch);
-	}, [routeBranch, knownBranches]);
+	const hasInvalidRouteBranch = Boolean(
+		isAdminDev &&
+		routeBranch &&
+		knownBranches &&
+		!knownBranches.includes(routeBranch),
+	);
 
 	React.useEffect(() => {
 		if (!isAuthenticated) return;
@@ -141,14 +149,17 @@ export default function QuickNav() {
 
 	React.useEffect(() => {
 		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;
@@ -188,6 +199,10 @@ export default function QuickNav() {
 		router.push(nextUrl);
 	}
 
+	const branchButtonTitle = hasInvalidRouteBranch
+		? `Achtung: Die URL-Niederlassung ${routeBranch} existiert nicht. Bitte eine gültige Niederlassung wählen.`
+		: "Niederlassung auswählen";
+
 	return (
 		<div className="hidden items-center gap-2 md:flex">
 			{isAdminDev ? (
@@ -197,9 +212,16 @@ export default function QuickNav() {
 							variant="outline"
 							size="sm"
 							type="button"
-							title="Niederlassung auswählen"
+							title={branchButtonTitle}
+							className={TOPNAV_BUTTON_CLASS}
 						>
 							{canNavigate ? effectiveBranch : "Niederlassung wählen"}
+							{hasInvalidRouteBranch ? (
+								<TriangleAlert
+									className="h-4 w-4 text-destructive"
+									aria-hidden="true"
+								/>
+							) : null}
 						</Button>
 					</DropdownMenuTrigger>
 
@@ -207,6 +229,16 @@ export default function QuickNav() {
 						<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>
+								<DropdownMenuSeparator />
+							</>
+						) : null}
+
 						{branchList.status === BRANCH_LIST_STATE.ERROR ? (
 							<div className="px-2 py-2 text-xs text-muted-foreground">
 								Konnte nicht geladen werden.
@@ -242,7 +274,10 @@ export default function QuickNav() {
 				size="sm"
 				asChild
 				disabled={!canNavigate}
-				className={isExplorerActive ? ACTIVE_NAV_BUTTON_CLASS : ""}
+				className={[
+					TOPNAV_BUTTON_CLASS,
+					isExplorerActive ? ACTIVE_NAV_BUTTON_CLASS : "",
+				].join(" ")}
 			>
 				<Link
 					href={canNavigate ? branchPath(effectiveBranch) : "#"}
@@ -259,7 +294,10 @@ export default function QuickNav() {
 				size="sm"
 				asChild
 				disabled={!canNavigate}
-				className={isSearchActive ? ACTIVE_NAV_BUTTON_CLASS : ""}
+				className={[
+					TOPNAV_BUTTON_CLASS,
+					isSearchActive ? ACTIVE_NAV_BUTTON_CLASS : "",
+				].join(" ")}
 			>
 				<Link
 					href={canNavigate ? searchPath(effectiveBranch) : "#"}

+ 12 - 13
components/app-shell/SessionIndicator.jsx

@@ -4,23 +4,22 @@ import React from "react";
 import { Loader2 } from "lucide-react";
 
 import { useAuth } from "@/components/auth/authContext";
+import { useDebouncedVisibility } from "@/lib/frontend/hooks/useDebouncedVisibility";
 
-/**
- * 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);
+
+	// Debounce policy:
+	// - Show only if it lasts longer than 200ms.
+	// - Once shown, keep it visible for at least 250ms to avoid blinking.
+	const visible = useDebouncedVisibility(isActive, {
+		delayMs: 200,
+		minVisibleMs: 250,
+	});
+
+	if (!visible) return null;
 
 	return (
 		<div

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

@@ -5,13 +5,18 @@ import { Moon, Sun } from "lucide-react";
 import { useTheme } from "next-themes";
 
 import { Button } from "@/components/ui/button";
+import {
+	Tooltip,
+	TooltipContent,
+	TooltipProvider,
+	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}
+		// Read current theme from the DOM class to avoid hydration-unsafe theme reads.
 		const isDark =
 			typeof document !== "undefined" &&
 			document.documentElement.classList.contains("dark");
@@ -20,21 +25,28 @@ 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>
+		<TooltipProvider delayDuration={200}>
+			<Tooltip>
+				<TooltipTrigger asChild>
+					<Button
+						type="button"
+						variant="ghost"
+						size="icon-sm"
+						onClick={toggleTheme}
+						aria-label="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>
+				</TooltipTrigger>
+
+				<TooltipContent side="bottom">Design umschalten</TooltipContent>
+			</Tooltip>
+		</TooltipProvider>
 	);
 }

+ 2 - 5
components/app-shell/TopNav.jsx

@@ -9,7 +9,7 @@ import SessionIndicator from "@/components/app-shell/SessionIndicator";
 
 export default function TopNav() {
 	return (
-		<header className="sticky top-0 z-50 w-full border-b bg-background/80 backdrop-blur">
+		<header className="sticky top-0 z-50 w-full border-b bg-background">
 			<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">
@@ -45,11 +45,8 @@ export default function TopNav() {
 						</div>
 
 						<div className="flex items-center gap-3">
-							{/* New: inline session validation indicator (no content flicker) */}
-							<SessionIndicator />
-
 							<ThemeToggleButton />
-
+							<SessionIndicator />
 							<UserStatus />
 						</div>
 					</div>

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

@@ -10,6 +10,7 @@ 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 ExplorerBreadcrumbs from "@/components/explorer/breadcrumbs/ExplorerBreadcrumbs";
 import ExplorerPageShell from "@/components/explorer/ExplorerPageShell";
@@ -22,18 +23,12 @@ 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
- */
+const LOADING_DELAY_MS = 300;
+
 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 +37,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_DELAY_MS,
+		minVisibleMs: 0,
+	});
+
 	React.useEffect(() => {
 		if (mapped?.kind !== "unauthenticated") return;
 
@@ -60,7 +60,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 +100,7 @@ export default function DaysExplorer({ branch, year, month }) {
 		</Button>
 	);
 
-	if (daysQuery.status === "loading") {
+	if (showLoadingUi) {
 		return (
 			<ExplorerPageShell
 				title="Tage"
@@ -115,6 +115,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 +155,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>
 			);
 		}

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

@@ -14,6 +14,7 @@ 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 { buildPdfUrl } from "@/lib/frontend/explorer/pdfUrl";
 
 import ExplorerBreadcrumbs from "@/components/explorer/breadcrumbs/ExplorerBreadcrumbs";
@@ -36,18 +37,12 @@ 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
- */
+const LOADING_DELAY_MS = 300;
+
 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 +51,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_DELAY_MS,
+			minVisibleMs: 0,
+		},
 	);
 
 	React.useEffect(() => {
@@ -80,7 +83,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 +131,7 @@ export default function FilesExplorer({ branch, year, month, day }) {
 		</Button>
 	);
 
-	if (filesQuery.status === "loading") {
+	if (showLoadingUi) {
 		return (
 			<ExplorerPageShell
 				title="Dateien"
@@ -143,6 +146,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 +186,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>
 			);
 		}

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

@@ -11,6 +11,7 @@ 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 ExplorerBreadcrumbs from "@/components/explorer/breadcrumbs/ExplorerBreadcrumbs";
 import ExplorerPageShell from "@/components/explorer/ExplorerPageShell";
@@ -23,18 +24,12 @@ 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
- */
+const LOADING_DELAY_MS = 300;
+
 export default function MonthsExplorer({ branch, year }) {
 	const monthsLoadFn = React.useCallback(
 		() => getMonths(branch, year),
-		[branch, year]
+		[branch, year],
 	);
 	const monthsQuery = useExplorerQuery(monthsLoadFn, [monthsLoadFn]);
 
@@ -43,7 +38,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_DELAY_MS,
+			minVisibleMs: 0,
+		},
 	);
 
 	React.useEffect(() => {
@@ -55,7 +58,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 +90,7 @@ export default function MonthsExplorer({ branch, year }) {
 		</Button>
 	);
 
-	if (monthsQuery.status === "loading") {
+	if (showLoadingUi) {
 		return (
 			<ExplorerPageShell
 				title="Monate"
@@ -105,6 +108,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 +148,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>
 			);
 		}

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

@@ -10,6 +10,7 @@ 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 ExplorerBreadcrumbs from "@/components/explorer/breadcrumbs/ExplorerBreadcrumbs";
 import ExplorerPageShell from "@/components/explorer/ExplorerPageShell";
@@ -22,19 +23,19 @@ 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
- */
+const LOADING_DELAY_MS = 300;
+
 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_DELAY_MS,
+		minVisibleMs: 0,
+	});
+
 	React.useEffect(() => {
 		if (mapped?.kind !== "unauthenticated") return;
 
@@ -44,7 +45,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 +58,7 @@ export default function YearsExplorer({ branch }) {
 		</Button>
 	);
 
-	if (status === "loading") {
+	if (showLoadingUi) {
 		return (
 			<ExplorerPageShell
 				title="Jahre"
@@ -72,9 +73,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 +111,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
  */

+ 17 - 5
components/search/SearchResults.jsx

@@ -14,10 +14,13 @@ import {
 	sortSearchItems,
 	SEARCH_RESULTS_SORT,
 } from "@/lib/frontend/search/resultsSorting";
+import { useDebouncedVisibility } from "@/lib/frontend/hooks/useDebouncedVisibility";
 
 import SearchResultsToolbar from "@/components/search/SearchResultsToolbar";
 import SearchResultsTable from "@/components/search/SearchResultsTable";
 
+const LOADING_DELAY_MS = 300;
+
 export default function SearchResults({
 	branch,
 	status,
@@ -33,6 +36,11 @@ export default function SearchResults({
 }) {
 	const [sortMode, setSortMode] = React.useState(SEARCH_RESULTS_SORT.RELEVANCE);
 
+	const showLoadingUi = useDebouncedVisibility(status === "loading", {
+		delayMs: LOADING_DELAY_MS,
+		minVisibleMs: 0,
+	});
+
 	const sortedItems = React.useMemo(() => {
 		return sortSearchItems(items, sortMode);
 	}, [items, sortMode]);
@@ -57,13 +65,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 +86,15 @@ export default function SearchResults({
 		);
 	}
 
+	// Debounced loading UI:
+	// - If loading is very fast, do not show skeletons (prevents flicker).
+	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>