فهرست منبع

RHL-022 feat(explorer): add explorer components for years, months, days, and files

Code_Uwe 1 ماه پیش
والد
کامیت
4d601cc1f5

+ 218 - 0
components/explorer/levels/DaysExplorer.jsx

@@ -0,0 +1,218 @@
+"use client";
+
+import React from "react";
+import Link from "next/link";
+import { CalendarCheck, RefreshCw } from "lucide-react";
+
+import { getDays, getMonths, getYears } from "@/lib/frontend/apiClient";
+import { dayPath, monthPath, yearPath } from "@/lib/frontend/routes";
+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 ExplorerBreadcrumbs from "@/components/explorer/breadcrumbs/ExplorerBreadcrumbs";
+import ExplorerPageShell from "@/components/explorer/ExplorerPageShell";
+import ExplorerSectionCard from "@/components/explorer/ExplorerSectionCard";
+import ExplorerLoading from "@/components/explorer/states/ExplorerLoading";
+import ExplorerEmpty from "@/components/explorer/states/ExplorerEmpty";
+import ExplorerError from "@/components/explorer/states/ExplorerError";
+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]
+	);
+	const daysQuery = useExplorerQuery(daysLoadFn, [daysLoadFn]);
+
+	const yearsLoadFn = React.useCallback(() => getYears(branch), [branch]);
+	const yearsQuery = useExplorerQuery(yearsLoadFn, [yearsLoadFn]);
+
+	const monthsLoadFn = React.useCallback(
+		() => getMonths(branch, year),
+		[branch, year]
+	);
+	const monthsQuery = useExplorerQuery(monthsLoadFn, [monthsLoadFn]);
+
+	const mapped = React.useMemo(
+		() => mapExplorerError(daysQuery.error),
+		[daysQuery.error]
+	);
+
+	React.useEffect(() => {
+		if (mapped?.kind !== "unauthenticated") return;
+
+		const next =
+			typeof window !== "undefined"
+				? `${window.location.pathname}${window.location.search}`
+				: monthPath(branch, year, month);
+
+		window.location.replace(
+			buildLoginUrl({ reason: LOGIN_REASONS.EXPIRED, next })
+		);
+	}, [mapped?.kind, branch, year, month]);
+
+	const yearOptions =
+		yearsQuery.status === "success" && Array.isArray(yearsQuery.data?.years)
+			? yearsQuery.data.years
+			: null;
+
+	const monthOptions =
+		monthsQuery.status === "success" && Array.isArray(monthsQuery.data?.months)
+			? monthsQuery.data.months
+			: null;
+
+	const breadcrumbsNode = (
+		<ExplorerBreadcrumbs
+			branch={branch}
+			year={year}
+			month={month}
+			yearOptions={yearOptions}
+			monthOptions={monthOptions}
+		/>
+	);
+
+	const actions = (
+		<Button
+			variant="outline"
+			size="sm"
+			onClick={() => {
+				daysQuery.retry();
+				yearsQuery.retry();
+				monthsQuery.retry();
+			}}
+			title="Aktualisieren"
+		>
+			<RefreshCw className="h-4 w-4" />
+			Aktualisieren
+		</Button>
+	);
+
+	if (daysQuery.status === "loading") {
+		return (
+			<ExplorerPageShell
+				title="Tage"
+				description="Wählen Sie einen Tag aus."
+				breadcrumbs={breadcrumbsNode}
+				actions={actions}
+			>
+				<ExplorerSectionCard title="Verfügbare Tage" description="Lade Daten…">
+					<ExplorerLoading variant="grid" count={16} />
+				</ExplorerSectionCard>
+			</ExplorerPageShell>
+		);
+	}
+
+	if (daysQuery.status === "error" && mapped) {
+		if (mapped.kind === "forbidden")
+			return <ForbiddenView attemptedBranch={branch} />;
+
+		if (mapped.kind === "notfound") {
+			return (
+				<ExplorerPageShell
+					title="Tage"
+					description="Wählen Sie einen Tag aus."
+					breadcrumbs={breadcrumbsNode}
+					actions={actions}
+				>
+					<ExplorerNotFound
+						upHref={yearPath(branch, year)}
+						branchRootHref={yearPath(branch, year)}
+					/>
+				</ExplorerPageShell>
+			);
+		}
+
+		if (mapped.kind === "unauthenticated") {
+			return (
+				<ExplorerPageShell
+					title="Tage"
+					description="Sitzung abgelaufen — Weiterleitung zum Login…"
+					breadcrumbs={breadcrumbsNode}
+				>
+					<ExplorerSectionCard
+						title="Weiterleitung"
+						description="Bitte warten…"
+					>
+						<ExplorerLoading variant="grid" count={6} />
+					</ExplorerSectionCard>
+				</ExplorerPageShell>
+			);
+		}
+
+		return (
+			<ExplorerPageShell
+				title="Tage"
+				description="Wählen Sie einen Tag aus."
+				breadcrumbs={breadcrumbsNode}
+				actions={actions}
+			>
+				<ExplorerSectionCard title="Verfügbare Tage" description="Fehler">
+					<ExplorerError
+						title={mapped.title}
+						description={mapped.description}
+						onRetry={daysQuery.retry}
+					/>
+				</ExplorerSectionCard>
+			</ExplorerPageShell>
+		);
+	}
+
+	const days = Array.isArray(daysQuery.data?.days) ? daysQuery.data.days : [];
+	const sorted = sortNumericStringsDesc(days);
+
+	return (
+		<ExplorerPageShell
+			title="Tage"
+			description="Wählen Sie einen Tag aus."
+			breadcrumbs={breadcrumbsNode}
+			actions={actions}
+		>
+			<ExplorerSectionCard
+				title="Verfügbare Tage"
+				description={`Niederlassung ${branch} • ${year}/${month}`}
+				headerRight={
+					<span className="rounded-md bg-muted px-2 py-1 text-xs text-muted-foreground">
+						{sorted.length} Tag{sorted.length === 1 ? "" : "e"}
+					</span>
+				}
+			>
+				{sorted.length === 0 ? (
+					<ExplorerEmpty
+						title="Keine Tage gefunden"
+						description="Für diesen Monat wurden keine Tage gefunden."
+						upHref={monthPath(branch, year, month)}
+					/>
+				) : (
+					<div className="grid grid-cols-2 gap-2 sm:grid-cols-4 md:grid-cols-6">
+						{sorted.map((d) => (
+							<Button
+								key={d}
+								variant="outline"
+								className="w-full justify-start"
+								asChild
+							>
+								<Link href={dayPath(branch, year, month, d)}>
+									<CalendarCheck className="h-4 w-4" />
+									{d}
+								</Link>
+							</Button>
+						))}
+					</div>
+				)}
+			</ExplorerSectionCard>
+		</ExplorerPageShell>
+	);
+}

+ 282 - 0
components/explorer/levels/FilesExplorer.jsx

@@ -0,0 +1,282 @@
+"use client";
+
+import React from "react";
+import { Eye, FileText, RefreshCw } from "lucide-react";
+
+import {
+	getFiles,
+	getDays,
+	getMonths,
+	getYears,
+} from "@/lib/frontend/apiClient";
+import { dayPath, monthPath, branchPath } from "@/lib/frontend/routes";
+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 ExplorerBreadcrumbs from "@/components/explorer/breadcrumbs/ExplorerBreadcrumbs";
+import ExplorerPageShell from "@/components/explorer/ExplorerPageShell";
+import ExplorerSectionCard from "@/components/explorer/ExplorerSectionCard";
+import ExplorerLoading from "@/components/explorer/states/ExplorerLoading";
+import ExplorerEmpty from "@/components/explorer/states/ExplorerEmpty";
+import ExplorerError from "@/components/explorer/states/ExplorerError";
+import ExplorerNotFound from "@/components/explorer/states/ExplorerNotFound";
+
+import ForbiddenView from "@/components/system/ForbiddenView";
+import { Button } from "@/components/ui/button";
+import {
+	Table,
+	TableHeader,
+	TableBody,
+	TableRow,
+	TableHead,
+	TableCell,
+	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]
+	);
+	const filesQuery = useExplorerQuery(filesLoadFn, [filesLoadFn]);
+
+	const yearsLoadFn = React.useCallback(() => getYears(branch), [branch]);
+	const yearsQuery = useExplorerQuery(yearsLoadFn, [yearsLoadFn]);
+
+	const monthsLoadFn = React.useCallback(
+		() => getMonths(branch, year),
+		[branch, year]
+	);
+	const monthsQuery = useExplorerQuery(monthsLoadFn, [monthsLoadFn]);
+
+	const daysLoadFn = React.useCallback(
+		() => getDays(branch, year, month),
+		[branch, year, month]
+	);
+	const daysQuery = useExplorerQuery(daysLoadFn, [daysLoadFn]);
+
+	const mapped = React.useMemo(
+		() => mapExplorerError(filesQuery.error),
+		[filesQuery.error]
+	);
+
+	React.useEffect(() => {
+		if (mapped?.kind !== "unauthenticated") return;
+
+		const next =
+			typeof window !== "undefined"
+				? `${window.location.pathname}${window.location.search}`
+				: dayPath(branch, year, month, day);
+
+		window.location.replace(
+			buildLoginUrl({ reason: LOGIN_REASONS.EXPIRED, next })
+		);
+	}, [mapped?.kind, branch, year, month, day]);
+
+	const yearOptions =
+		yearsQuery.status === "success" && Array.isArray(yearsQuery.data?.years)
+			? yearsQuery.data.years
+			: null;
+
+	const monthOptions =
+		monthsQuery.status === "success" && Array.isArray(monthsQuery.data?.months)
+			? monthsQuery.data.months
+			: null;
+
+	const dayOptions =
+		daysQuery.status === "success" && Array.isArray(daysQuery.data?.days)
+			? daysQuery.data.days
+			: null;
+
+	const breadcrumbsNode = (
+		<ExplorerBreadcrumbs
+			branch={branch}
+			year={year}
+			month={month}
+			day={day}
+			yearOptions={yearOptions}
+			monthOptions={monthOptions}
+			dayOptions={dayOptions}
+		/>
+	);
+
+	const actions = (
+		<Button
+			variant="outline"
+			size="sm"
+			onClick={() => {
+				filesQuery.retry();
+				yearsQuery.retry();
+				monthsQuery.retry();
+				daysQuery.retry();
+			}}
+			title="Aktualisieren"
+		>
+			<RefreshCw className="h-4 w-4" />
+			Aktualisieren
+		</Button>
+	);
+
+	if (filesQuery.status === "loading") {
+		return (
+			<ExplorerPageShell
+				title="Dateien"
+				description="Lieferscheine für den ausgewählten Tag."
+				breadcrumbs={breadcrumbsNode}
+				actions={actions}
+			>
+				<ExplorerSectionCard title="Dateiliste" description="Lade Daten…">
+					<ExplorerLoading variant="table" count={8} />
+				</ExplorerSectionCard>
+			</ExplorerPageShell>
+		);
+	}
+
+	if (filesQuery.status === "error" && mapped) {
+		if (mapped.kind === "forbidden")
+			return <ForbiddenView attemptedBranch={branch} />;
+
+		if (mapped.kind === "notfound") {
+			return (
+				<ExplorerPageShell
+					title="Dateien"
+					description="Lieferscheine für den ausgewählten Tag."
+					breadcrumbs={breadcrumbsNode}
+					actions={actions}
+				>
+					<ExplorerNotFound
+						upHref={monthPath(branch, year, month)}
+						branchRootHref={branchPath(branch)}
+					/>
+				</ExplorerPageShell>
+			);
+		}
+
+		if (mapped.kind === "unauthenticated") {
+			return (
+				<ExplorerPageShell
+					title="Dateien"
+					description="Sitzung abgelaufen — Weiterleitung zum Login…"
+					breadcrumbs={breadcrumbsNode}
+				>
+					<ExplorerSectionCard
+						title="Weiterleitung"
+						description="Bitte warten…"
+					>
+						<ExplorerLoading variant="table" count={6} />
+					</ExplorerSectionCard>
+				</ExplorerPageShell>
+			);
+		}
+
+		return (
+			<ExplorerPageShell
+				title="Dateien"
+				description="Lieferscheine für den ausgewählten Tag."
+				breadcrumbs={breadcrumbsNode}
+				actions={actions}
+			>
+				<ExplorerSectionCard title="Dateiliste" description="Fehler">
+					<ExplorerError
+						title={mapped.title}
+						description={mapped.description}
+						onRetry={filesQuery.retry}
+					/>
+				</ExplorerSectionCard>
+			</ExplorerPageShell>
+		);
+	}
+
+	const files = Array.isArray(filesQuery.data?.files)
+		? filesQuery.data.files
+		: [];
+	const sorted = sortFilesByNameAsc(files);
+
+	return (
+		<ExplorerPageShell
+			title="Dateien"
+			description="Lieferscheine für den ausgewählten Tag."
+			breadcrumbs={breadcrumbsNode}
+			actions={actions}
+		>
+			<ExplorerSectionCard
+				title="Dateiliste"
+				description={`Niederlassung ${branch} • ${year}/${month}/${day}`}
+				headerRight={
+					<span className="rounded-md bg-muted px-2 py-1 text-xs text-muted-foreground">
+						{sorted.length} Datei{sorted.length === 1 ? "" : "en"}
+					</span>
+				}
+			>
+				{sorted.length === 0 ? (
+					<ExplorerEmpty
+						title="Keine Dateien gefunden"
+						description="Für diesen Tag wurden keine Dateien gefunden."
+						upHref={monthPath(branch, year, month)}
+					/>
+				) : (
+					<Table>
+						<TableCaption>
+							Hinweis: Die PDF-Ansicht folgt in einem späteren Ticket (RHL-023).
+						</TableCaption>
+
+						<TableHeader>
+							<TableRow>
+								<TableHead>Datei</TableHead>
+								<TableHead className="hidden md:table-cell">Pfad</TableHead>
+								<TableHead className="text-right">Aktion</TableHead>
+							</TableRow>
+						</TableHeader>
+
+						<TableBody>
+							{sorted.map((f) => (
+								<TableRow key={f.relativePath || f.name}>
+									<TableCell className="min-w-0">
+										<div className="flex min-w-0 items-start gap-2">
+											<FileText className="mt-0.5 h-4 w-4 shrink-0 text-muted-foreground" />
+											<div className="min-w-0">
+												<p className="truncate font-medium">{f.name}</p>
+												<p className="truncate text-xs text-muted-foreground md:hidden">
+													{f.relativePath}
+												</p>
+											</div>
+										</div>
+									</TableCell>
+
+									<TableCell className="hidden md:table-cell">
+										<span className="text-xs text-muted-foreground">
+											{f.relativePath}
+										</span>
+									</TableCell>
+
+									<TableCell className="text-right">
+										<Button
+											variant="outline"
+											size="sm"
+											disabled
+											aria-disabled="true"
+											title="PDF-Ansicht kommt bald"
+										>
+											<Eye className="h-4 w-4" />
+											Öffnen
+										</Button>
+									</TableCell>
+								</TableRow>
+							))}
+						</TableBody>
+					</Table>
+				)}
+			</ExplorerSectionCard>
+		</ExplorerPageShell>
+	);
+}

+ 213 - 0
components/explorer/levels/MonthsExplorer.jsx

@@ -0,0 +1,213 @@
+"use client";
+
+import React from "react";
+import Link from "next/link";
+import { CalendarRange, RefreshCw } from "lucide-react";
+
+import { getMonths, getYears } from "@/lib/frontend/apiClient";
+import { branchPath, monthPath, yearPath } from "@/lib/frontend/routes";
+import { sortNumericStringsDesc } from "@/lib/frontend/explorer/sorters";
+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 ExplorerBreadcrumbs from "@/components/explorer/breadcrumbs/ExplorerBreadcrumbs";
+import ExplorerPageShell from "@/components/explorer/ExplorerPageShell";
+import ExplorerSectionCard from "@/components/explorer/ExplorerSectionCard";
+import ExplorerLoading from "@/components/explorer/states/ExplorerLoading";
+import ExplorerEmpty from "@/components/explorer/states/ExplorerEmpty";
+import ExplorerError from "@/components/explorer/states/ExplorerError";
+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]
+	);
+	const monthsQuery = useExplorerQuery(monthsLoadFn, [monthsLoadFn]);
+
+	const yearsLoadFn = React.useCallback(() => getYears(branch), [branch]);
+	const yearsQuery = useExplorerQuery(yearsLoadFn, [yearsLoadFn]);
+
+	const mapped = React.useMemo(
+		() => mapExplorerError(monthsQuery.error),
+		[monthsQuery.error]
+	);
+
+	React.useEffect(() => {
+		if (mapped?.kind !== "unauthenticated") return;
+
+		const next =
+			typeof window !== "undefined"
+				? `${window.location.pathname}${window.location.search}`
+				: yearPath(branch, year);
+
+		window.location.replace(
+			buildLoginUrl({ reason: LOGIN_REASONS.EXPIRED, next })
+		);
+	}, [mapped?.kind, branch, year]);
+
+	const yearOptions =
+		yearsQuery.status === "success" && Array.isArray(yearsQuery.data?.years)
+			? yearsQuery.data.years
+			: null;
+
+	const breadcrumbsNode = (
+		<ExplorerBreadcrumbs
+			branch={branch}
+			year={year}
+			yearOptions={yearOptions}
+		/>
+	);
+
+	const actions = (
+		<Button
+			variant="outline"
+			size="sm"
+			onClick={() => {
+				monthsQuery.retry();
+				yearsQuery.retry();
+			}}
+			title="Aktualisieren"
+		>
+			<RefreshCw className="h-4 w-4" />
+			Aktualisieren
+		</Button>
+	);
+
+	if (monthsQuery.status === "loading") {
+		return (
+			<ExplorerPageShell
+				title="Monate"
+				description="Wählen Sie einen Monat aus."
+				breadcrumbs={breadcrumbsNode}
+				actions={actions}
+			>
+				<ExplorerSectionCard
+					title="Verfügbare Monate"
+					description={`Jahr ${year}`}
+				>
+					<ExplorerLoading variant="grid" count={12} />
+				</ExplorerSectionCard>
+			</ExplorerPageShell>
+		);
+	}
+
+	if (monthsQuery.status === "error" && mapped) {
+		if (mapped.kind === "forbidden")
+			return <ForbiddenView attemptedBranch={branch} />;
+
+		if (mapped.kind === "notfound") {
+			return (
+				<ExplorerPageShell
+					title="Monate"
+					description="Wählen Sie einen Monat aus."
+					breadcrumbs={breadcrumbsNode}
+					actions={actions}
+				>
+					<ExplorerNotFound
+						upHref={branchPath(branch)}
+						branchRootHref={branchPath(branch)}
+					/>
+				</ExplorerPageShell>
+			);
+		}
+
+		if (mapped.kind === "unauthenticated") {
+			return (
+				<ExplorerPageShell
+					title="Monate"
+					description="Sitzung abgelaufen — Weiterleitung zum Login…"
+					breadcrumbs={breadcrumbsNode}
+				>
+					<ExplorerSectionCard
+						title="Weiterleitung"
+						description="Bitte warten…"
+					>
+						<ExplorerLoading variant="grid" count={6} />
+					</ExplorerSectionCard>
+				</ExplorerPageShell>
+			);
+		}
+
+		return (
+			<ExplorerPageShell
+				title="Monate"
+				description="Wählen Sie einen Monat aus."
+				breadcrumbs={breadcrumbsNode}
+				actions={actions}
+			>
+				<ExplorerSectionCard
+					title="Verfügbare Monate"
+					description={`Jahr ${year}`}
+				>
+					<ExplorerError
+						title={mapped.title}
+						description={mapped.description}
+						onRetry={monthsQuery.retry}
+					/>
+				</ExplorerSectionCard>
+			</ExplorerPageShell>
+		);
+	}
+
+	const months = Array.isArray(monthsQuery.data?.months)
+		? monthsQuery.data.months
+		: [];
+	const sorted = sortNumericStringsDesc(months);
+
+	return (
+		<ExplorerPageShell
+			title="Monate"
+			description="Wählen Sie einen Monat aus."
+			breadcrumbs={breadcrumbsNode}
+			actions={actions}
+		>
+			<ExplorerSectionCard
+				title="Verfügbare Monate"
+				description={`Niederlassung ${branch} • Jahr ${year}`}
+				headerRight={
+					<span className="rounded-md bg-muted px-2 py-1 text-xs text-muted-foreground">
+						{sorted.length} Monat{sorted.length === 1 ? "" : "e"}
+					</span>
+				}
+			>
+				{sorted.length === 0 ? (
+					<ExplorerEmpty
+						title="Keine Monate gefunden"
+						description="Für dieses Jahr wurden keine Monate gefunden."
+						upHref={branchPath(branch)}
+					/>
+				) : (
+					<div className="grid grid-cols-2 gap-2 sm:grid-cols-3 md:grid-cols-4">
+						{sorted.map((m) => (
+							<Button
+								key={m}
+								variant="outline"
+								className="w-full justify-start"
+								asChild
+							>
+								<Link href={monthPath(branch, year, m)}>
+									<CalendarRange className="h-4 w-4" />
+									<span className="truncate">{formatMonthLabel(m)}</span>
+								</Link>
+							</Button>
+						))}
+					</div>
+				)}
+			</ExplorerSectionCard>
+		</ExplorerPageShell>
+	);
+}

+ 172 - 0
components/explorer/levels/YearsExplorer.jsx

@@ -0,0 +1,172 @@
+"use client";
+
+import React from "react";
+import Link from "next/link";
+import { CalendarDays, RefreshCw } from "lucide-react";
+
+import { getYears } from "@/lib/frontend/apiClient";
+import { branchPath, yearPath } from "@/lib/frontend/routes";
+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 ExplorerBreadcrumbs from "@/components/explorer/breadcrumbs/ExplorerBreadcrumbs";
+import ExplorerPageShell from "@/components/explorer/ExplorerPageShell";
+import ExplorerSectionCard from "@/components/explorer/ExplorerSectionCard";
+import ExplorerLoading from "@/components/explorer/states/ExplorerLoading";
+import ExplorerEmpty from "@/components/explorer/states/ExplorerEmpty";
+import ExplorerError from "@/components/explorer/states/ExplorerError";
+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]);
+
+	React.useEffect(() => {
+		if (mapped?.kind !== "unauthenticated") return;
+
+		const next =
+			typeof window !== "undefined"
+				? `${window.location.pathname}${window.location.search}`
+				: branchPath(branch);
+
+		window.location.replace(
+			buildLoginUrl({ reason: LOGIN_REASONS.EXPIRED, next })
+		);
+	}, [mapped?.kind, branch]);
+
+	const breadcrumbsNode = <ExplorerBreadcrumbs branch={branch} />;
+
+	const actions = (
+		<Button variant="outline" size="sm" onClick={retry} title="Aktualisieren">
+			<RefreshCw className="h-4 w-4" />
+			Aktualisieren
+		</Button>
+	);
+
+	if (status === "loading") {
+		return (
+			<ExplorerPageShell
+				title="Jahre"
+				description="Wählen Sie ein Jahr, um die Lieferscheine anzuzeigen."
+				breadcrumbs={breadcrumbsNode}
+				actions={actions}
+			>
+				<ExplorerSectionCard title="Verfügbare Jahre" description="Lade Daten…">
+					<ExplorerLoading variant="grid" count={8} />
+				</ExplorerSectionCard>
+			</ExplorerPageShell>
+		);
+	}
+
+	if (status === "error" && mapped) {
+		if (mapped.kind === "forbidden")
+			return <ForbiddenView attemptedBranch={branch} />;
+
+		if (mapped.kind === "notfound") {
+			return (
+				<ExplorerPageShell
+					title="Jahre"
+					description="Wählen Sie ein Jahr, um die Lieferscheine anzuzeigen."
+					breadcrumbs={breadcrumbsNode}
+					actions={actions}
+				>
+					<ExplorerNotFound branchRootHref={branchPath(branch)} />
+				</ExplorerPageShell>
+			);
+		}
+
+		if (mapped.kind === "unauthenticated") {
+			return (
+				<ExplorerPageShell
+					title="Jahre"
+					description="Sitzung abgelaufen — Weiterleitung zum Login…"
+					breadcrumbs={breadcrumbsNode}
+				>
+					<ExplorerSectionCard
+						title="Weiterleitung"
+						description="Bitte warten…"
+					>
+						<ExplorerLoading variant="grid" count={6} />
+					</ExplorerSectionCard>
+				</ExplorerPageShell>
+			);
+		}
+
+		return (
+			<ExplorerPageShell
+				title="Jahre"
+				description="Wählen Sie ein Jahr, um die Lieferscheine anzuzeigen."
+				breadcrumbs={breadcrumbsNode}
+				actions={actions}
+			>
+				<ExplorerSectionCard title="Verfügbare Jahre" description="Fehler">
+					<ExplorerError
+						title={mapped.title}
+						description={mapped.description}
+						onRetry={retry}
+					/>
+				</ExplorerSectionCard>
+			</ExplorerPageShell>
+		);
+	}
+
+	const years = Array.isArray(data?.years) ? data.years : [];
+	const sorted = sortNumericStringsDesc(years);
+
+	return (
+		<ExplorerPageShell
+			title="Jahre"
+			description="Wählen Sie ein Jahr, um die Lieferscheine anzuzeigen."
+			breadcrumbs={breadcrumbsNode}
+			actions={actions}
+		>
+			<ExplorerSectionCard
+				title="Verfügbare Jahre"
+				description={`Niederlassung ${branch}`}
+				headerRight={
+					<span className="rounded-md bg-muted px-2 py-1 text-xs text-muted-foreground">
+						{sorted.length} Jahr{sorted.length === 1 ? "" : "e"}
+					</span>
+				}
+			>
+				{sorted.length === 0 ? (
+					<ExplorerEmpty
+						title="Keine Jahre gefunden"
+						description="Für diese Niederlassung wurden keine Jahre gefunden."
+						upHref={null}
+					/>
+				) : (
+					<div className="grid grid-cols-2 gap-2 sm:grid-cols-3 md:grid-cols-4">
+						{sorted.map((y) => (
+							<Button
+								key={y}
+								variant="outline"
+								className="w-full justify-start"
+								asChild
+							>
+								<Link href={yearPath(branch, y)}>
+									<CalendarDays className="h-4 w-4" />
+									{y}
+								</Link>
+							</Button>
+						))}
+					</div>
+				)}
+			</ExplorerSectionCard>
+		</ExplorerPageShell>
+	);
+}