Просмотр исходного кода

RHL-046 feat(overview): add layout classes and branch target resolution logic with tests

Code_Uwe 1 месяц назад
Родитель
Сommit
27eae5cbf0

+ 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 };
+}

+ 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,
+	};
+}