FilesExplorer.jsx 8.0 KB

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