FilesExplorer.jsx 8.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306
  1. "use client";
  2. import React from "react";
  3. import { Eye, FileText, RefreshCw } from "lucide-react";
  4. import {
  5. getFiles,
  6. getDays,
  7. getMonths,
  8. getYears,
  9. } from "@/lib/frontend/apiClient";
  10. import { dayPath, monthPath, branchPath } from "@/lib/frontend/routes";
  11. import { sortFilesByNameAsc } from "@/lib/frontend/explorer/sorters";
  12. import { mapExplorerError } from "@/lib/frontend/explorer/errorMapping";
  13. import { buildLoginUrl, LOGIN_REASONS } from "@/lib/frontend/authRedirect";
  14. import { useExplorerQuery } from "@/lib/frontend/hooks/useExplorerQuery";
  15. import { useDebouncedVisibility } from "@/lib/frontend/hooks/useDebouncedVisibility";
  16. import { buildPdfUrl } from "@/lib/frontend/explorer/pdfUrl";
  17. import ExplorerBreadcrumbs from "@/components/explorer/breadcrumbs/ExplorerBreadcrumbs";
  18. import ExplorerPageShell from "@/components/explorer/ExplorerPageShell";
  19. import ExplorerSectionCard from "@/components/explorer/ExplorerSectionCard";
  20. import ExplorerLoading from "@/components/explorer/states/ExplorerLoading";
  21. import ExplorerEmpty from "@/components/explorer/states/ExplorerEmpty";
  22. import ExplorerError from "@/components/explorer/states/ExplorerError";
  23. import ExplorerNotFound from "@/components/explorer/states/ExplorerNotFound";
  24. import ForbiddenView from "@/components/system/ForbiddenView";
  25. import { Button } from "@/components/ui/button";
  26. import {
  27. Table,
  28. TableHeader,
  29. TableBody,
  30. TableRow,
  31. TableHead,
  32. TableCell,
  33. TableCaption,
  34. } from "@/components/ui/table";
  35. const LOADING_DELAY_MS = 300;
  36. export default function FilesExplorer({ branch, year, month, day }) {
  37. const filesLoadFn = React.useCallback(
  38. () => getFiles(branch, year, month, day),
  39. [branch, year, month, day],
  40. );
  41. const filesQuery = useExplorerQuery(filesLoadFn, [filesLoadFn]);
  42. const yearsLoadFn = React.useCallback(() => getYears(branch), [branch]);
  43. const yearsQuery = useExplorerQuery(yearsLoadFn, [yearsLoadFn]);
  44. const monthsLoadFn = React.useCallback(
  45. () => getMonths(branch, year),
  46. [branch, year],
  47. );
  48. const monthsQuery = useExplorerQuery(monthsLoadFn, [monthsLoadFn]);
  49. const daysLoadFn = React.useCallback(
  50. () => getDays(branch, year, month),
  51. [branch, year, month],
  52. );
  53. const daysQuery = useExplorerQuery(daysLoadFn, [daysLoadFn]);
  54. const mapped = React.useMemo(
  55. () => mapExplorerError(filesQuery.error),
  56. [filesQuery.error],
  57. );
  58. const showLoadingUi = useDebouncedVisibility(
  59. filesQuery.status === "loading",
  60. {
  61. delayMs: LOADING_DELAY_MS,
  62. minVisibleMs: 0,
  63. },
  64. );
  65. React.useEffect(() => {
  66. if (mapped?.kind !== "unauthenticated") return;
  67. const next =
  68. typeof window !== "undefined"
  69. ? `${window.location.pathname}${window.location.search}`
  70. : dayPath(branch, year, month, day);
  71. window.location.replace(
  72. buildLoginUrl({ reason: LOGIN_REASONS.EXPIRED, next }),
  73. );
  74. }, [mapped?.kind, branch, year, month, day]);
  75. const yearOptions =
  76. yearsQuery.status === "success" && Array.isArray(yearsQuery.data?.years)
  77. ? yearsQuery.data.years
  78. : null;
  79. const monthOptions =
  80. monthsQuery.status === "success" && Array.isArray(monthsQuery.data?.months)
  81. ? monthsQuery.data.months
  82. : null;
  83. const dayOptions =
  84. daysQuery.status === "success" && Array.isArray(daysQuery.data?.days)
  85. ? daysQuery.data.days
  86. : null;
  87. const breadcrumbsNode = (
  88. <ExplorerBreadcrumbs
  89. branch={branch}
  90. year={year}
  91. month={month}
  92. day={day}
  93. yearOptions={yearOptions}
  94. monthOptions={monthOptions}
  95. dayOptions={dayOptions}
  96. />
  97. );
  98. const actions = (
  99. <Button
  100. variant="outline"
  101. size="sm"
  102. onClick={() => {
  103. filesQuery.retry();
  104. yearsQuery.retry();
  105. monthsQuery.retry();
  106. daysQuery.retry();
  107. }}
  108. title="Aktualisieren"
  109. >
  110. <RefreshCw className="h-4 w-4" />
  111. Aktualisieren
  112. </Button>
  113. );
  114. if (showLoadingUi) {
  115. return (
  116. <ExplorerPageShell
  117. title="Dateien"
  118. description="Lieferscheine für den ausgewählten Tag."
  119. breadcrumbs={breadcrumbsNode}
  120. actions={actions}
  121. >
  122. <ExplorerSectionCard title="Dateiliste" description="Lade Daten…">
  123. <ExplorerLoading variant="table" count={8} />
  124. </ExplorerSectionCard>
  125. </ExplorerPageShell>
  126. );
  127. }
  128. if (filesQuery.status === "loading") {
  129. return (
  130. <ExplorerPageShell
  131. title="Dateien"
  132. description="Lieferscheine für den ausgewählten Tag."
  133. breadcrumbs={breadcrumbsNode}
  134. actions={actions}
  135. >
  136. <div className="h-16" aria-hidden="true" />
  137. </ExplorerPageShell>
  138. );
  139. }
  140. if (filesQuery.status === "error" && mapped) {
  141. if (mapped.kind === "forbidden")
  142. return <ForbiddenView attemptedBranch={branch} />;
  143. if (mapped.kind === "notfound") {
  144. return (
  145. <ExplorerPageShell
  146. title="Dateien"
  147. description="Lieferscheine für den ausgewählten Tag."
  148. breadcrumbs={breadcrumbsNode}
  149. actions={actions}
  150. >
  151. <ExplorerNotFound
  152. upHref={monthPath(branch, year, month)}
  153. branchRootHref={branchPath(branch)}
  154. />
  155. </ExplorerPageShell>
  156. );
  157. }
  158. if (mapped.kind === "unauthenticated") {
  159. return (
  160. <ExplorerPageShell
  161. title="Dateien"
  162. description="Sitzung abgelaufen — Weiterleitung zum Login…"
  163. breadcrumbs={breadcrumbsNode}
  164. >
  165. <div className="h-16" aria-hidden="true" />
  166. </ExplorerPageShell>
  167. );
  168. }
  169. return (
  170. <ExplorerPageShell
  171. title="Dateien"
  172. description="Lieferscheine für den ausgewählten Tag."
  173. breadcrumbs={breadcrumbsNode}
  174. actions={actions}
  175. >
  176. <ExplorerSectionCard title="Dateiliste" description="Fehler">
  177. <ExplorerError
  178. title={mapped.title}
  179. description={mapped.description}
  180. onRetry={filesQuery.retry}
  181. />
  182. </ExplorerSectionCard>
  183. </ExplorerPageShell>
  184. );
  185. }
  186. const files = Array.isArray(filesQuery.data?.files)
  187. ? filesQuery.data.files
  188. : [];
  189. const sorted = sortFilesByNameAsc(files);
  190. return (
  191. <ExplorerPageShell
  192. title="Dateien"
  193. description="Lieferscheine für den ausgewählten Tag."
  194. breadcrumbs={breadcrumbsNode}
  195. actions={actions}
  196. >
  197. <ExplorerSectionCard
  198. title="Dateiliste"
  199. description={`Niederlassung ${branch} • ${year}/${month}/${day}`}
  200. headerRight={
  201. <span className="rounded-md bg-muted px-2 py-1 text-xs text-muted-foreground">
  202. {sorted.length} Datei{sorted.length === 1 ? "" : "en"}
  203. </span>
  204. }
  205. >
  206. {sorted.length === 0 ? (
  207. <ExplorerEmpty
  208. title="Keine Dateien gefunden"
  209. description="Für diesen Tag wurden keine Dateien gefunden."
  210. upHref={monthPath(branch, year, month)}
  211. />
  212. ) : (
  213. <Table>
  214. <TableCaption>
  215. Hinweis: PDFs werden in einem neuen Tab geöffnet.
  216. </TableCaption>
  217. <TableHeader>
  218. <TableRow>
  219. <TableHead>Datei</TableHead>
  220. <TableHead className="hidden md:table-cell">Pfad</TableHead>
  221. <TableHead className="text-right">Aktion</TableHead>
  222. </TableRow>
  223. </TableHeader>
  224. <TableBody>
  225. {sorted.map((f) => {
  226. const pdfUrl = buildPdfUrl({
  227. branch,
  228. year,
  229. month,
  230. day,
  231. filename: f.name,
  232. });
  233. return (
  234. <TableRow key={f.relativePath || f.name}>
  235. <TableCell className="min-w-0">
  236. <div className="flex min-w-0 items-start gap-2">
  237. <FileText className="mt-0.5 h-4 w-4 shrink-0 text-muted-foreground" />
  238. <div className="min-w-0">
  239. <p className="truncate font-medium">{f.name}</p>
  240. <p className="truncate text-xs text-muted-foreground md:hidden">
  241. {f.relativePath}
  242. </p>
  243. </div>
  244. </div>
  245. </TableCell>
  246. <TableCell className="hidden md:table-cell">
  247. <span className="text-xs text-muted-foreground">
  248. {f.relativePath}
  249. </span>
  250. </TableCell>
  251. <TableCell className="text-right">
  252. <Button variant="outline" size="sm" asChild>
  253. <a
  254. href={pdfUrl}
  255. target="_blank"
  256. rel="noopener noreferrer"
  257. aria-label={`PDF öffnen: ${f.name}`}
  258. title={`PDF öffnen: ${f.name}`}
  259. >
  260. <Eye className="h-4 w-4" />
  261. Öffnen
  262. </a>
  263. </Button>
  264. </TableCell>
  265. </TableRow>
  266. );
  267. })}
  268. </TableBody>
  269. </Table>
  270. )}
  271. </ExplorerSectionCard>
  272. </ExplorerPageShell>
  273. );
  274. }