SearchDateRangePicker.jsx 6.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259
  1. "use client";
  2. import * as React from "react";
  3. import dynamic from "next/dynamic";
  4. import { Calendar as CalendarIcon, X } from "lucide-react";
  5. import { cn } from "@/lib/utils";
  6. import { Badge } from "@/components/ui/badge";
  7. import { Button } from "@/components/ui/button";
  8. import { Input } from "@/components/ui/input";
  9. import { Label } from "@/components/ui/label";
  10. import {
  11. Popover,
  12. PopoverContent,
  13. PopoverTrigger,
  14. } from "@/components/ui/popover";
  15. import { Skeleton } from "@/components/ui/skeleton";
  16. import { useSearchDateRangePicker } from "@/lib/frontend/search/useSearchDateRangePicker";
  17. function CalendarLoading() {
  18. return (
  19. <div className="space-y-3">
  20. <div className="flex gap-4">
  21. <Skeleton className="h-72 w-72" />
  22. <Skeleton className="h-72 w-72" />
  23. </div>
  24. <p className="text-xs text-muted-foreground text-center">
  25. Kalender lädt…
  26. </p>
  27. </div>
  28. );
  29. }
  30. const Calendar = dynamic(
  31. () => import("@/components/ui/calendar").then((m) => m.Calendar),
  32. {
  33. ssr: false,
  34. loading: CalendarLoading,
  35. },
  36. );
  37. export default function SearchDateRangePicker({
  38. from,
  39. to,
  40. onDateRangeChange,
  41. isSubmitting,
  42. className,
  43. }) {
  44. const {
  45. disabled,
  46. open,
  47. setOpen,
  48. activeField,
  49. setActiveField,
  50. fromRef,
  51. toRef,
  52. month,
  53. setMonth,
  54. summary,
  55. fromDisplay,
  56. toDisplay,
  57. presetsRow1,
  58. presetsRow2,
  59. calendarKey,
  60. calendarSelected,
  61. calendarModifiers,
  62. calendarModifiersClassNames,
  63. isRangeInvalid,
  64. handlePickDay,
  65. handleClearFrom,
  66. handleClearTo,
  67. handleReset,
  68. applyPreset,
  69. } = useSearchDateRangePicker({
  70. from,
  71. to,
  72. onDateRangeChange,
  73. isSubmitting,
  74. });
  75. const fromInputId = React.useId();
  76. const toInputId = React.useId();
  77. const activeInputClass =
  78. "border-blue-600 bg-blue-50 dark:border-blue-900 dark:bg-blue-950";
  79. return (
  80. <div className={cn("grid gap-2", className)}>
  81. <Label>Zeitraum</Label>
  82. <Popover open={open} onOpenChange={setOpen}>
  83. <PopoverTrigger asChild>
  84. <Button
  85. type="button"
  86. variant="outline"
  87. disabled={disabled}
  88. className={cn(
  89. "w-[240px] justify-between font-normal",
  90. !from && !to ? "text-muted-foreground" : "",
  91. )}
  92. title="Zeitraum auswählen"
  93. >
  94. <span className="truncate">{summary}</span>
  95. <CalendarIcon className="ml-2 h-4 w-4 opacity-70" />
  96. </Button>
  97. </PopoverTrigger>
  98. <PopoverContent align="start" className="w-fit p-0">
  99. <div className="w-fit space-y-4 p-4">
  100. <div className="grid grid-cols-2 gap-4">
  101. <div className="space-y-1">
  102. <Label htmlFor={fromInputId}>Von</Label>
  103. <div className="relative">
  104. <Input
  105. id={fromInputId}
  106. ref={fromRef}
  107. readOnly
  108. disabled={disabled}
  109. value={fromDisplay}
  110. placeholder="TT.MM.JJJJ"
  111. className={cn(
  112. "pr-8",
  113. activeField === "from" ? activeInputClass : "",
  114. )}
  115. onFocus={() => setActiveField("from")}
  116. onClick={() => setActiveField("from")}
  117. />
  118. {from ? (
  119. <button
  120. type="button"
  121. className="absolute right-2 top-1/2 -translate-y-1/2 rounded-sm p-1 opacity-70 hover:opacity-100"
  122. onClick={handleClearFrom}
  123. aria-label="Startdatum löschen"
  124. title="Startdatum löschen"
  125. >
  126. <X className="h-4 w-4" />
  127. </button>
  128. ) : null}
  129. </div>
  130. </div>
  131. <div className="space-y-1">
  132. <Label htmlFor={toInputId}>Bis</Label>
  133. <div className="relative">
  134. <Input
  135. id={toInputId}
  136. ref={toRef}
  137. readOnly
  138. disabled={disabled}
  139. value={toDisplay}
  140. placeholder="TT.MM.JJJJ"
  141. className={cn(
  142. "pr-8",
  143. activeField === "to" ? activeInputClass : "",
  144. )}
  145. onFocus={() => setActiveField("to")}
  146. onClick={() => setActiveField("to")}
  147. />
  148. {to ? (
  149. <button
  150. type="button"
  151. className="absolute right-2 top-1/2 -translate-y-1/2 rounded-sm p-1 opacity-70 hover:opacity-100"
  152. onClick={handleClearTo}
  153. aria-label="Enddatum löschen"
  154. title="Enddatum löschen"
  155. >
  156. <X className="h-4 w-4" />
  157. </button>
  158. ) : null}
  159. </div>
  160. </div>
  161. </div>
  162. <Calendar
  163. key={calendarKey}
  164. mode="range"
  165. numberOfMonths={2}
  166. captionLayout="dropdown"
  167. month={month}
  168. onMonthChange={setMonth}
  169. selected={calendarSelected}
  170. modifiers={calendarModifiers}
  171. modifiersClassNames={calendarModifiersClassNames}
  172. onDayClick={handlePickDay}
  173. />
  174. {isRangeInvalid ? (
  175. <p className="text-xs text-destructive text-center">
  176. Das Startdatum darf nicht nach dem Enddatum liegen.
  177. </p>
  178. ) : null}
  179. <div className="space-y-2">
  180. <div className="text-sm text-muted-foreground">Schnellwahl</div>
  181. <div className="flex flex-wrap gap-2">
  182. {presetsRow1.map((p) => (
  183. <Badge
  184. key={p.key}
  185. asChild
  186. className={[
  187. "bg-white text-black border-border",
  188. "hover:bg-white/90",
  189. "dark:bg-white dark:text-black",
  190. ].join(" ")}
  191. >
  192. <button
  193. type="button"
  194. className="cursor-pointer select-none disabled:opacity-60"
  195. disabled={disabled}
  196. onClick={() => applyPreset(p)}
  197. title={p.label}
  198. >
  199. {p.label}
  200. </button>
  201. </Badge>
  202. ))}
  203. </div>
  204. <div className="flex flex-wrap gap-2">
  205. {presetsRow2.map((p) => (
  206. <Badge key={p.key} asChild>
  207. <button
  208. type="button"
  209. className="cursor-pointer select-none disabled:opacity-60"
  210. disabled={disabled}
  211. onClick={() => applyPreset(p)}
  212. title={p.label}
  213. >
  214. {p.label}
  215. </button>
  216. </Badge>
  217. ))}
  218. </div>
  219. </div>
  220. <div className="flex justify-end">
  221. <Button
  222. type="button"
  223. variant="outline"
  224. disabled={disabled}
  225. onClick={handleReset}
  226. >
  227. Zurücksetzen
  228. </Button>
  229. </div>
  230. <p className="text-xs text-muted-foreground text-center">
  231. Tipp: Für einen einzelnen Tag setzen Sie <b>Von</b> und <b>Bis</b>{" "}
  232. auf dasselbe Datum.
  233. </p>
  234. </div>
  235. </PopoverContent>
  236. </Popover>
  237. </div>
  238. );
  239. }