1
0

7 Commits 74a6f43b6b ... 984ee961dc

Autor SHA1 Nachricht Datum
  Code_Uwe 984ee961dc RHL-046 feat(overview): implement OverviewHomePage and OverviewCard components vor 1 Monat
  Code_Uwe a6462d8fc0 RHL-046 feat(user-status): refactor buildSupportMailto function into separate module vor 1 Monat
  Code_Uwe 1d8232f37a RHL-046 feat(profile): add support contact section with email link and update layout vor 1 Monat
  Code_Uwe 27eae5cbf0 RHL-046 feat(overview): add layout classes and branch target resolution logic with tests vor 1 Monat
  Code_Uwe 00e82ca868 RHL-046 feat(support-mailto): implement buildSupportMailto function and corresponding tests vor 1 Monat
  Code_Uwe 53c3328459 RHL-046 feat(overview-cards): implement buildOverviewCards function and corresponding tests vor 1 Monat
  Code_Uwe 9ccd1f793b RHL-046 feat(overview-cards): add new image assets for explorer, profile, search, and users vor 1 Monat

+ 2 - 37
app/(protected)/page.jsx

@@ -1,40 +1,5 @@
-import PlaceholderPage from "@/components/placeholders/PlaceholderPage";
+import OverviewHomePage from "@/components/overview/OverviewHomePage";
 
-/**
- * /
- *
- * RHL-019:
- * - Placeholder entry page
- *
- * Later:
- * - If unauthenticated: redirect to /login
- * - If authenticated: redirect to a branch route (e.g. /NL01)
- */
 export default function ProtectedEntryPage() {
-	return (
-		<PlaceholderPage
-			title="Übersicht"
-			description='Dies ist der geschützte Einstieg ("/"). Später wird hier abhängig von der Sitzung weitergeleitet.'
-		>
-			<div className="space-y-2 text-sm text-muted-foreground">
-				<p>Testen Sie diese URLs manuell:</p>
-				<ul className="list-disc pl-5">
-					<li>
-						<code className="rounded bg-muted px-1 py-0.5">/login</code>
-					</li>
-					<li>
-						<code className="rounded bg-muted px-1 py-0.5">/NL01</code>
-					</li>
-					<li>
-						<code className="rounded bg-muted px-1 py-0.5">
-							/NL01/2025/12/31
-						</code>
-					</li>
-					<li>
-						<code className="rounded bg-muted px-1 py-0.5">/NL01/search</code>
-					</li>
-				</ul>
-			</div>
-		</PlaceholderPage>
-	);
+	return <OverviewHomePage />;
 }

+ 1 - 57
components/app-shell/UserStatus.jsx

@@ -11,6 +11,7 @@ import { useAuth } from "@/components/auth/authContext";
 import { logout } from "@/lib/frontend/apiClient";
 import { buildLoginUrl, LOGIN_REASONS } from "@/lib/frontend/authRedirect";
 import { canManageUsers as canManageUsersRole } from "@/lib/frontend/auth/roles";
+import { buildSupportMailto } from "@/lib/frontend/support/supportMailto";
 
 import { Button } from "@/components/ui/button";
 import {
@@ -35,63 +36,6 @@ function formatRole(role) {
 	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,
-	)}`;
-}
-
 export default function UserStatus() {
 	const pathname = usePathname() || "/";
 	const { status, user } = useAuth();

+ 79 - 0
components/overview/OverviewCard.jsx

@@ -0,0 +1,79 @@
+"use client";
+
+import Link from "next/link";
+
+import { cn } from "@/lib/utils";
+import {
+	Card,
+	CardContent,
+	CardDescription,
+	CardTitle,
+} from "@/components/ui/card";
+
+export default function OverviewCard({
+	title,
+	description,
+	imageSrc,
+	href = null,
+	disabledHint = null,
+	containerClassName = "",
+}) {
+	const isDisabled = !href;
+
+	const card = (
+		<Card
+			className={[
+				"h-[420px] w-full overflow-hidden gap-0 py-0 border",
+				"bg-card/90",
+				"transition-all duration-200",
+				isDisabled
+					? "opacity-85"
+					: "group-hover:-translate-y-0.5 group-hover:shadow-lg group-hover:border-primary/30 group-active:translate-y-0",
+			].join(" ")}
+		>
+			<div className="h-56 w-full overflow-hidden border-b bg-muted/30">
+				<img
+					src={imageSrc}
+					alt={`${title} Karte`}
+					className="h-full w-full object-cover"
+					loading="lazy"
+				/>
+			</div>
+
+			<CardContent className="flex flex-1 flex-col space-y-2 px-4 py-3">
+				<CardTitle className="text-base">{title}</CardTitle>
+				<CardDescription className="text-sm leading-relaxed">
+					{description}
+				</CardDescription>
+				{isDisabled && disabledHint ? (
+					<p className="mt-auto text-xs text-muted-foreground">
+						{disabledHint}
+					</p>
+				) : null}
+			</CardContent>
+		</Card>
+	);
+
+	if (isDisabled) {
+		return (
+			<div
+				className={cn("group block w-full", containerClassName)}
+				aria-disabled="true"
+			>
+				{card}
+			</div>
+		);
+	}
+
+	return (
+		<Link
+			href={href}
+			className={cn(
+				"group block w-full rounded-xl focus:outline-none",
+				containerClassName,
+			)}
+		>
+			{card}
+		</Link>
+	);
+}

+ 60 - 0
components/overview/OverviewHomePage.jsx

@@ -0,0 +1,60 @@
+"use client";
+
+import React from "react";
+import { useOverviewBranchTarget } from "@/lib/frontend/overview/useOverviewBranchTarget";
+import { buildOverviewCards } from "@/lib/frontend/overview/cardsConfig";
+import OverviewCard from "@/components/overview/OverviewCard";
+import { getOverviewCardsLayoutClasses } from "@/components/overview/layoutClasses";
+
+export default function OverviewHomePage() {
+	const {
+		isAuthenticated,
+		canManageUsers,
+		explorerHref,
+		searchHref,
+		disabledHint,
+	} = useOverviewBranchTarget();
+
+	if (!isAuthenticated) return null;
+
+	const cards = buildOverviewCards({
+		explorerHref,
+		searchHref,
+		canManageUsers,
+		disabledHint,
+	});
+
+	const { cardsRowClassName, cardItemClassName } =
+		getOverviewCardsLayoutClasses({
+			cardCount: cards.length,
+		});
+
+	return (
+		<div className="min-h-[calc(100dvh-9rem)] py-4">
+			<div className="mx-auto flex h-full w-full flex-col">
+				<div className="space-y-1 text-left">
+					<h1 className="text-2xl font-semibold tracking-tight">Übersicht</h1>
+					<p className="text-sm text-muted-foreground">
+						Schnellzugriff auf die wichtigsten Bereiche.
+					</p>
+				</div>
+
+				<div className="flex flex-1 items-center justify-center pt-20 pb-10">
+					<div className={cardsRowClassName}>
+						{cards.map((card) => (
+							<OverviewCard
+								key={card.key}
+								title={card.title}
+								description={card.description}
+								imageSrc={card.imageSrc}
+								href={card.href}
+								disabledHint={card.disabledHint}
+								containerClassName={cardItemClassName}
+							/>
+						))}
+					</div>
+				</div>
+			</div>
+		</div>
+	);
+}

+ 11 - 0
components/overview/layoutClasses.js

@@ -0,0 +1,11 @@
+export function getOverviewCardsLayoutClasses({ cardCount }) {
+	const cardsRowClassName =
+		"flex w-full flex-wrap items-stretch justify-center gap-4 lg:flex-nowrap";
+
+	const cardItemClassName =
+		cardCount === 4
+			? "max-w-xs sm:w-[17rem] lg:w-[15.5rem]"
+			: "max-w-xs sm:w-[18rem] lg:w-[17.5rem]";
+
+	return { cardsRowClassName, cardItemClassName };
+}

+ 1 - 1
components/profile/ChangePasswordCard.jsx

@@ -292,7 +292,7 @@ export default function ChangePasswordCard({ onPasswordChangeSuccess }) {
 						/>
 					</div>
 
-					<CardFooter className="p-0 flex justify-end">
+					<CardFooter className="p-0 flex justify-start">
 						<Button
 							type="submit"
 							disabled={!isAuthenticated || isSubmitting}

+ 42 - 3
components/profile/ProfilePage.jsx

@@ -1,11 +1,15 @@
 "use client";
 
 import React from "react";
+import { usePathname } from "next/navigation";
+import { LifeBuoy } from "lucide-react";
 import { useAuth } from "@/components/auth/authContext";
 
 import ChangePasswordCard from "@/components/profile/ChangePasswordCard";
+import { buildSupportMailto } from "@/lib/frontend/support/supportMailto";
 
 import { Alert, AlertTitle, AlertDescription } from "@/components/ui/alert";
+import { Button } from "@/components/ui/button";
 import {
 	Card,
 	CardHeader,
@@ -23,8 +27,10 @@ function formatRole(role) {
 }
 
 export default function ProfilePage() {
+	const pathname = usePathname() || "/profile";
 	const { status, user } = useAuth();
-	const [passwordChangedSuccess, setPasswordChangedSuccess] = React.useState(false);
+	const [passwordChangedSuccess, setPasswordChangedSuccess] =
+		React.useState(false);
 
 	const isAuthenticated = status === "authenticated" && user;
 
@@ -32,7 +38,19 @@ export default function ProfilePage() {
 	const branchLabel = isAuthenticated ? user.branchId || "—" : "—";
 	const emailLabel = isAuthenticated ? user.email || "—" : "—";
 	const userIdLabel = isAuthenticated ? user.userId || "—" : "—";
-	const mustChangePassword = isAuthenticated && user.mustChangePassword === true;
+	const mustChangePassword =
+		isAuthenticated && user.mustChangePassword === true;
+	const currentUrl = typeof window !== "undefined" ? window.location.href : "";
+	const userAgent = typeof navigator !== "undefined" ? navigator.userAgent : "";
+
+	const supportMailto = React.useMemo(() => {
+		return buildSupportMailto({
+			user: isAuthenticated ? user : null,
+			pathname,
+			currentUrl,
+			userAgent,
+		});
+	}, [isAuthenticated, user, pathname, currentUrl, userAgent]);
 
 	React.useEffect(() => {
 		if (mustChangePassword) {
@@ -41,7 +59,7 @@ export default function ProfilePage() {
 	}, [mustChangePassword]);
 
 	return (
-		<div className="space-y-4">
+		<div className="space-y-4 w-1/2 mx-auto">
 			<div className="space-y-1">
 				<h1 className="text-2xl font-semibold tracking-tight">Profil</h1>
 				<p className="text-sm text-muted-foreground">
@@ -106,6 +124,27 @@ export default function ProfilePage() {
 				onPasswordChangeSuccess={() => setPasswordChangedSuccess(true)}
 			/>
 
+			<Card>
+				<CardHeader>
+					<CardTitle>Support</CardTitle>
+					<CardDescription>
+						Bei Fragen oder Problemen können Sie direkt den Support
+						kontaktieren.
+					</CardDescription>
+				</CardHeader>
+
+				<CardContent>
+					<div className="flex justify-start">
+						<Button variant="outline" asChild>
+							<a href={supportMailto}>
+								<LifeBuoy className="h-4 w-4" aria-hidden="true" />
+								Support kontaktieren
+							</a>
+						</Button>
+					</div>
+				</CardContent>
+			</Card>
+
 			{!isAuthenticated ? (
 				<p className="text-xs text-muted-foreground">
 					Hinweis: Profilfunktionen sind nur verfügbar, wenn Sie angemeldet

+ 46 - 0
lib/frontend/overview/cardsConfig.js

@@ -0,0 +1,46 @@
+export function buildOverviewCards({
+	explorerHref,
+	searchHref,
+	canManageUsers,
+	disabledHint,
+}) {
+	const cards = [
+		{
+			key: "explorer",
+			title: "Explorer",
+			description: "Lieferscheine nach Datum durchsuchen (Jahr -> Monat -> Tag).",
+			imageSrc: "/overview-cards/explorer.png",
+			href: explorerHref || null,
+			disabledHint: disabledHint || null,
+		},
+		{
+			key: "search",
+			title: "Suche",
+			description: "Schnell finden mit Volltext, Zeitraum und Filtern.",
+			imageSrc: "/overview-cards/search.png",
+			href: searchHref || null,
+			disabledHint: disabledHint || null,
+		},
+		{
+			key: "profile",
+			title: "Profil",
+			description: "Passwort ändern und Support kontaktieren.",
+			imageSrc: "/overview-cards/profile.png",
+			href: "/profile",
+			disabledHint: null,
+		},
+	];
+
+	if (canManageUsers) {
+		cards.push({
+			key: "users",
+			title: "Benutzerverwaltung",
+			description: "Benutzer anlegen, bearbeiten und verwalten.",
+			imageSrc: "/overview-cards/users.png",
+			href: "/admin/users",
+			disabledHint: null,
+		});
+	}
+
+	return cards;
+}

+ 60 - 0
lib/frontend/overview/cardsConfig.test.js

@@ -0,0 +1,60 @@
+/* @vitest-environment node */
+
+import { describe, it, expect } from "vitest";
+import { buildOverviewCards } from "./cardsConfig.js";
+
+describe("lib/frontend/overview/cardsConfig", () => {
+	it("returns 3 cards when user management is not allowed", () => {
+		const cards = buildOverviewCards({
+			explorerHref: "/NL01",
+			searchHref: "/NL01/search",
+			canManageUsers: false,
+			disabledHint: "Bitte zuerst eine gültige Niederlassung wählen.",
+		});
+
+		expect(cards).toHaveLength(3);
+		expect(cards.map((x) => x.key)).toEqual(["explorer", "search", "profile"]);
+	});
+
+	it("returns 4 cards when user management is allowed", () => {
+		const cards = buildOverviewCards({
+			explorerHref: "/NL01",
+			searchHref: "/NL01/search",
+			canManageUsers: true,
+			disabledHint: "Bitte zuerst eine gültige Niederlassung wählen.",
+		});
+
+		expect(cards).toHaveLength(4);
+		expect(cards.map((x) => x.key)).toEqual([
+			"explorer",
+			"search",
+			"profile",
+			"users",
+		]);
+	});
+
+	it("uses explorer/search hrefs and assigns disabledHint only to branch cards", () => {
+		const cards = buildOverviewCards({
+			explorerHref: null,
+			searchHref: null,
+			canManageUsers: true,
+			disabledHint: "Bitte zuerst eine gültige Niederlassung wählen.",
+		});
+
+		const byKey = Object.fromEntries(cards.map((card) => [card.key, card]));
+
+		expect(byKey.explorer.href).toBe(null);
+		expect(byKey.search.href).toBe(null);
+		expect(byKey.profile.href).toBe("/profile");
+		expect(byKey.users.href).toBe("/admin/users");
+
+		expect(byKey.explorer.disabledHint).toBe(
+			"Bitte zuerst eine gültige Niederlassung wählen.",
+		);
+		expect(byKey.search.disabledHint).toBe(
+			"Bitte zuerst eine gültige Niederlassung wählen.",
+		);
+		expect(byKey.profile.disabledHint).toBe(null);
+		expect(byKey.users.disabledHint).toBe(null);
+	});
+});

+ 109 - 0
lib/frontend/overview/homeBranchTarget.js

@@ -0,0 +1,109 @@
+import { isAdminLike as isAdminLikeRole } from "@/lib/frontend/auth/roles";
+import { isValidBranchParam } from "@/lib/frontend/params";
+
+export const OVERVIEW_BRANCH_FALLBACK = "NL01";
+
+export const OVERVIEW_BRANCH_SOURCE = Object.freeze({
+	NONE: "none",
+	USER: "user",
+	ROUTE: "route",
+	STORED: "stored",
+	FALLBACK: "fallback",
+	API_FIRST: "api-first",
+});
+
+function normalizeBranch(value) {
+	if (typeof value !== "string") return null;
+	const trimmed = value.trim();
+	return isValidBranchParam(trimmed) ? trimmed : null;
+}
+
+function normalizeAvailableBranches(value) {
+	if (!Array.isArray(value)) return null;
+
+	const deduped = [];
+	for (const item of value) {
+		const normalized = normalizeBranch(item);
+		if (!normalized) continue;
+		if (!deduped.includes(normalized)) deduped.push(normalized);
+	}
+
+	return deduped;
+}
+
+export function resolveOverviewBranchTarget(input = {}) {
+	const role = input?.role ?? null;
+	const routeBranch = normalizeBranch(input?.routeBranch);
+	const storedBranch = normalizeBranch(input?.storedBranch);
+	const ownBranch = normalizeBranch(input?.userBranchId);
+	const fallbackBranch =
+		normalizeBranch(input?.fallbackBranch) || OVERVIEW_BRANCH_FALLBACK;
+	const availableBranches = normalizeAvailableBranches(input?.availableBranches);
+
+	if (role === "branch") {
+		return {
+			branch: ownBranch,
+			source: ownBranch
+				? OVERVIEW_BRANCH_SOURCE.USER
+				: OVERVIEW_BRANCH_SOURCE.NONE,
+			shouldFetchBranches: false,
+		};
+	}
+
+	if (!isAdminLikeRole(role)) {
+		return {
+			branch: null,
+			source: OVERVIEW_BRANCH_SOURCE.NONE,
+			shouldFetchBranches: false,
+		};
+	}
+
+	const candidates = [
+		{ source: OVERVIEW_BRANCH_SOURCE.ROUTE, branch: routeBranch },
+		{ source: OVERVIEW_BRANCH_SOURCE.STORED, branch: storedBranch },
+		{ source: OVERVIEW_BRANCH_SOURCE.FALLBACK, branch: fallbackBranch },
+	];
+
+	if (Array.isArray(availableBranches)) {
+		if (availableBranches.length === 0) {
+			return {
+				branch: null,
+				source: OVERVIEW_BRANCH_SOURCE.NONE,
+				shouldFetchBranches: false,
+			};
+		}
+
+		for (const candidate of candidates) {
+			if (!candidate.branch) continue;
+			if (availableBranches.includes(candidate.branch)) {
+				return {
+					branch: candidate.branch,
+					source: candidate.source,
+					shouldFetchBranches: false,
+				};
+			}
+		}
+
+		return {
+			branch: availableBranches[0],
+			source: OVERVIEW_BRANCH_SOURCE.API_FIRST,
+			shouldFetchBranches: false,
+		};
+	}
+
+	const firstLocal = candidates.find((candidate) => Boolean(candidate.branch));
+	if (!firstLocal) {
+		return {
+			branch: null,
+			source: OVERVIEW_BRANCH_SOURCE.NONE,
+			shouldFetchBranches: true,
+		};
+	}
+
+	return {
+		branch: firstLocal.branch,
+		source: firstLocal.source,
+		shouldFetchBranches:
+			firstLocal.source === OVERVIEW_BRANCH_SOURCE.FALLBACK,
+	};
+}

+ 109 - 0
lib/frontend/overview/homeBranchTarget.test.js

@@ -0,0 +1,109 @@
+/* @vitest-environment node */
+
+import { describe, it, expect } from "vitest";
+import {
+	resolveOverviewBranchTarget,
+	OVERVIEW_BRANCH_SOURCE,
+} from "./homeBranchTarget.js";
+
+describe("lib/frontend/overview/homeBranchTarget", () => {
+	it("uses own branch for role=branch", () => {
+		const out = resolveOverviewBranchTarget({
+			role: "branch",
+			userBranchId: "NL20",
+			routeBranch: "NL01",
+			storedBranch: "NL02",
+		});
+
+		expect(out).toEqual({
+			branch: "NL20",
+			source: OVERVIEW_BRANCH_SOURCE.USER,
+			shouldFetchBranches: false,
+		});
+	});
+
+	it("prefers route branch for admin-like users", () => {
+		const out = resolveOverviewBranchTarget({
+			role: "admin",
+			routeBranch: "NL11",
+			storedBranch: "NL22",
+		});
+
+		expect(out).toEqual({
+			branch: "NL11",
+			source: OVERVIEW_BRANCH_SOURCE.ROUTE,
+			shouldFetchBranches: false,
+		});
+	});
+
+	it("uses stored branch when no route branch exists", () => {
+		const out = resolveOverviewBranchTarget({
+			role: "superadmin",
+			routeBranch: null,
+			storedBranch: "NL22",
+		});
+
+		expect(out).toEqual({
+			branch: "NL22",
+			source: OVERVIEW_BRANCH_SOURCE.STORED,
+			shouldFetchBranches: false,
+		});
+	});
+
+	it("falls back to NL01 and signals optional API refinement", () => {
+		const out = resolveOverviewBranchTarget({
+			role: "dev",
+			routeBranch: null,
+			storedBranch: null,
+		});
+
+		expect(out).toEqual({
+			branch: "NL01",
+			source: OVERVIEW_BRANCH_SOURCE.FALLBACK,
+			shouldFetchBranches: true,
+		});
+	});
+
+	it("falls back to first API branch when NL01 is not available", () => {
+		const out = resolveOverviewBranchTarget({
+			role: "admin",
+			routeBranch: null,
+			storedBranch: null,
+			availableBranches: ["NL20", "NL06"],
+		});
+
+		expect(out).toEqual({
+			branch: "NL20",
+			source: OVERVIEW_BRANCH_SOURCE.API_FIRST,
+			shouldFetchBranches: false,
+		});
+	});
+
+	it("behaves deterministically for empty and invalid branch lists", () => {
+		const emptyListOut = resolveOverviewBranchTarget({
+			role: "admin",
+			routeBranch: null,
+			storedBranch: null,
+			availableBranches: [],
+		});
+
+		expect(emptyListOut).toEqual({
+			branch: null,
+			source: OVERVIEW_BRANCH_SOURCE.NONE,
+			shouldFetchBranches: false,
+		});
+
+		const invalidListOut = resolveOverviewBranchTarget({
+			role: "admin",
+			routeBranch: null,
+			storedBranch: null,
+			availableBranches: ["bad", ""],
+		});
+
+		expect(invalidListOut).toEqual({
+			branch: null,
+			source: OVERVIEW_BRANCH_SOURCE.NONE,
+			shouldFetchBranches: false,
+		});
+	});
+});

+ 137 - 0
lib/frontend/overview/useOverviewBranchTarget.js

@@ -0,0 +1,137 @@
+"use client";
+
+import React from "react";
+import { usePathname } from "next/navigation";
+
+import { useAuth } from "@/components/auth/authContext";
+import { getBranches } from "@/lib/frontend/apiClient";
+import {
+	canManageUsers as canManageUsersRole,
+	isAdminLike as isAdminLikeRole,
+} from "@/lib/frontend/auth/roles";
+import { isValidBranchParam } from "@/lib/frontend/params";
+import { branchPath, searchPath } from "@/lib/frontend/routes";
+import {
+	readRouteBranchFromPathname,
+	safeReadLocalStorageBranch,
+} from "@/lib/frontend/quickNav/branchSwitch";
+import { resolveOverviewBranchTarget } from "@/lib/frontend/overview/homeBranchTarget";
+
+const STORAGE_KEY_LAST_BRANCH = "rhl_last_branch";
+
+const BRANCH_LIST_STATE = Object.freeze({
+	IDLE: "idle",
+	LOADING: "loading",
+	READY: "ready",
+	ERROR: "error",
+});
+
+export function useOverviewBranchTarget() {
+	const pathname = usePathname() || "/";
+	const { status, user } = useAuth();
+
+	const isAuthenticated = status === "authenticated" && user;
+	const isAdminLike = isAuthenticated && isAdminLikeRole(user.role);
+	const canManageUsers = isAuthenticated && canManageUsersRole(user.role);
+
+	const routeBranch = React.useMemo(
+		() => readRouteBranchFromPathname(pathname),
+		[pathname],
+	);
+
+	const [storedBranch, setStoredBranch] = React.useState(null);
+	const [branchList, setBranchList] = React.useState({
+		status: BRANCH_LIST_STATE.IDLE,
+		branches: null,
+	});
+
+	React.useEffect(() => {
+		if (!isAuthenticated || !isAdminLike) {
+			setStoredBranch(null);
+			return;
+		}
+
+		setStoredBranch(safeReadLocalStorageBranch(STORAGE_KEY_LAST_BRANCH));
+	}, [isAuthenticated, isAdminLike, user?.userId]);
+
+	const localResolution = React.useMemo(() => {
+		return resolveOverviewBranchTarget({
+			role: user?.role,
+			userBranchId: user?.branchId,
+			routeBranch,
+			storedBranch,
+		});
+	}, [user?.role, user?.branchId, routeBranch, storedBranch]);
+
+	React.useEffect(() => {
+		if (
+			!isAuthenticated ||
+			!isAdminLike ||
+			!localResolution.shouldFetchBranches
+		) {
+			setBranchList({ status: BRANCH_LIST_STATE.IDLE, branches: null });
+			return;
+		}
+
+		let cancelled = false;
+		setBranchList({ status: BRANCH_LIST_STATE.LOADING, branches: null });
+
+		(async () => {
+			try {
+				const res = await getBranches();
+				if (cancelled) return;
+
+				const branches = Array.isArray(res?.branches) ? res.branches : [];
+				setBranchList({ status: BRANCH_LIST_STATE.READY, branches });
+			} catch (err) {
+				if (cancelled) return;
+				console.error("[useOverviewBranchTarget] getBranches failed:", err);
+				setBranchList({ status: BRANCH_LIST_STATE.ERROR, branches: null });
+			}
+		})();
+
+		return () => {
+			cancelled = true;
+		};
+	}, [
+		isAuthenticated,
+		isAdminLike,
+		localResolution.shouldFetchBranches,
+		user?.userId,
+	]);
+
+	const availableBranches =
+		branchList.status === BRANCH_LIST_STATE.READY ? branchList.branches : null;
+
+	const resolvedBranchTarget = React.useMemo(() => {
+		return resolveOverviewBranchTarget({
+			role: user?.role,
+			userBranchId: user?.branchId,
+			routeBranch,
+			storedBranch,
+			availableBranches,
+		});
+	}, [
+		user?.role,
+		user?.branchId,
+		routeBranch,
+		storedBranch,
+		availableBranches,
+	]);
+
+	const targetBranch = isValidBranchParam(resolvedBranchTarget.branch)
+		? resolvedBranchTarget.branch
+		: null;
+
+	const explorerHref = targetBranch ? branchPath(targetBranch) : null;
+	const searchHref = targetBranch ? searchPath(targetBranch) : null;
+	const disabledHint = "Bitte zuerst eine gültige Niederlassung wählen.";
+
+	return {
+		isAuthenticated,
+		canManageUsers,
+		explorerHref,
+		searchHref,
+		disabledHint,
+	};
+}

+ 64 - 0
lib/frontend/support/supportMailto.js

@@ -0,0 +1,64 @@
+function formatRole(role) {
+	if (role === "branch") return "Niederlassung";
+	if (role === "admin") return "Admin";
+	if (role === "superadmin") return "Superadmin";
+	if (role === "dev") return "Entwicklung";
+	return role ? String(role) : "Unbekannt";
+}
+
+export 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,
+	)}`;
+}

+ 53 - 0
lib/frontend/support/supportMailto.test.js

@@ -0,0 +1,53 @@
+/* @vitest-environment node */
+
+import { describe, it, expect } from "vitest";
+import { buildSupportMailto } from "./supportMailto.js";
+
+function decodeMailto(mailto) {
+	const url = new URL(mailto);
+	return {
+		to: url.pathname,
+		subject: url.searchParams.get("subject") || "",
+		body: url.searchParams.get("body") || "",
+	};
+}
+
+describe("lib/frontend/support/supportMailto", () => {
+	it("builds a branch-specific subject and includes context lines", () => {
+		const mailto = buildSupportMailto({
+			user: { role: "branch", branchId: "NL20" },
+			pathname: "/profile",
+			currentUrl: "https://example.local/profile",
+			userAgent: "Vitest-UA",
+		});
+
+		const parsed = decodeMailto(mailto);
+
+		expect(parsed.to).toBe("info@attus.de");
+		expect(parsed.subject).toBe("Support – RHL Lieferscheine (NL20)");
+		expect(parsed.body).toContain("Benutzer: Niederlassung (NL20)");
+		expect(parsed.body).toContain("Route: /profile");
+		expect(parsed.body).toContain("URL: https://example.local/profile");
+		expect(parsed.body).toContain("User-Agent: Vitest-UA");
+		expect(parsed.body).toContain("Zeitpunkt:");
+		expect(parsed.body).toContain("ISO:");
+	});
+
+	it("builds a generic subject without branch", () => {
+		const mailto = buildSupportMailto({
+			user: { role: "admin", branchId: null },
+			pathname: "/",
+			currentUrl: "",
+			userAgent: "",
+		});
+
+		const parsed = decodeMailto(mailto);
+
+		expect(parsed.to).toBe("info@attus.de");
+		expect(parsed.subject).toBe("Support – RHL Lieferscheine");
+		expect(parsed.body).toContain("Benutzer: Admin");
+		expect(parsed.body).toContain("Route: /");
+		expect(parsed.body).toContain("URL: (bitte einfügen)");
+		expect(parsed.body).toContain("User-Agent: (unbekannt)");
+	});
+});

BIN
public/overview-cards/explorer.png


BIN
public/overview-cards/profile.png


BIN
public/overview-cards/search.png


BIN
public/overview-cards/users.png