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