SearchDateRangePicker.jsx 9.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369
  1. "use client";
  2. import React from "react";
  3. import { CalendarRange, X } from "lucide-react";
  4. import {
  5. formatIsoDateDe,
  6. formatIsoDateRangeLabelDe,
  7. toDateFromIsoDateYmd,
  8. toIsoDateYmdFromDate,
  9. compareIsoDatesYmd,
  10. normalizeIsoDateYmdOrNull,
  11. } from "@/lib/frontend/search/dateRange";
  12. import { buildDatePresets } from "@/lib/frontend/search/datePresets";
  13. import { Calendar } from "@/components/ui/calendar";
  14. import { Badge } from "@/components/ui/badge";
  15. import { Button } from "@/components/ui/button";
  16. import { Label } from "@/components/ui/label";
  17. import {
  18. Popover,
  19. PopoverContent,
  20. PopoverTrigger,
  21. } from "@/components/ui/popover";
  22. import { cn } from "@/lib/utils";
  23. const FIELD = Object.freeze({
  24. FROM: "from",
  25. TO: "to",
  26. });
  27. function pickInitialActiveField(from, to) {
  28. if (!from) return FIELD.FROM;
  29. if (from && !to) return FIELD.TO;
  30. return FIELD.FROM;
  31. }
  32. function focusById(id) {
  33. if (typeof document === "undefined") return;
  34. const el = document.getElementById(id);
  35. if (el && typeof el.focus === "function") el.focus();
  36. }
  37. function buildDisplayedRange(from, to) {
  38. const fIso = normalizeIsoDateYmdOrNull(from);
  39. const tIso = normalizeIsoDateYmdOrNull(to);
  40. const f = toDateFromIsoDateYmd(fIso);
  41. const t = toDateFromIsoDateYmd(tIso);
  42. if (f && t && compareIsoDatesYmd(fIso, tIso) <= 0) {
  43. return { from: f, to: t };
  44. }
  45. if (f) return { from: f, to: undefined };
  46. if (t) return { from: t, to: undefined };
  47. return undefined;
  48. }
  49. export default function SearchDateRangePicker({
  50. from,
  51. to,
  52. onDateRangeChange,
  53. isSubmitting,
  54. }) {
  55. const [open, setOpen] = React.useState(false);
  56. const [activeField, setActiveField] = React.useState(() =>
  57. pickInitialActiveField(from, to)
  58. );
  59. const fromBtnId = React.useId();
  60. const toBtnId = React.useId();
  61. const fromDate = React.useMemo(() => toDateFromIsoDateYmd(from), [from]);
  62. const toDate = React.useMemo(() => toDateFromIsoDateYmd(to), [to]);
  63. const now = React.useMemo(() => new Date(), []);
  64. const presets = React.useMemo(() => buildDatePresets({ now }), [now]);
  65. // Calendar month anchor (controlled) so we can keep UX stable when switching fields.
  66. const [month, setMonth] = React.useState(() => fromDate || toDate || now);
  67. // When popover opens: pick a sensible active field and month anchor.
  68. React.useEffect(() => {
  69. if (!open) return;
  70. const nextActive = pickInitialActiveField(from, to);
  71. setActiveField(nextActive);
  72. const nextMonth =
  73. nextActive === FIELD.FROM
  74. ? fromDate || toDate || now
  75. : toDate || fromDate || now;
  76. setMonth(nextMonth);
  77. queueMicrotask(() => {
  78. focusById(nextActive === FIELD.FROM ? fromBtnId : toBtnId);
  79. });
  80. // eslint-disable-next-line react-hooks/exhaustive-deps
  81. }, [open]);
  82. const label = formatIsoDateRangeLabelDe({ from, to }) || "Zeitraum";
  83. const fromLabel = formatIsoDateDe(from) || "Startdatum";
  84. const toLabel = formatIsoDateDe(to) || "Enddatum";
  85. const canClearBoth = Boolean((from || to) && !isSubmitting);
  86. const canClearFrom = Boolean(from && !isSubmitting);
  87. const canClearTo = Boolean(to && !isSubmitting);
  88. const activeBlue =
  89. "border-blue-600 bg-blue-50 dark:border-blue-900 dark:bg-blue-950";
  90. const fromYear = 2000;
  91. const toYear = now.getFullYear() + 1;
  92. // Show the selected range visually (works for presets too).
  93. const displayedRange = React.useMemo(() => {
  94. return buildDisplayedRange(from, to);
  95. }, [from, to]);
  96. function commit(nextFrom, nextTo) {
  97. if (typeof onDateRangeChange !== "function") return;
  98. onDateRangeChange({ from: nextFrom, to: nextTo });
  99. }
  100. function setActive(field) {
  101. setActiveField(field);
  102. const nextMonth =
  103. field === FIELD.FROM ? fromDate || month : toDate || month;
  104. if (nextMonth) setMonth(nextMonth);
  105. queueMicrotask(() => {
  106. focusById(field === FIELD.FROM ? fromBtnId : toBtnId);
  107. });
  108. }
  109. function clearFrom() {
  110. if (!canClearFrom) return;
  111. commit(null, to ?? null);
  112. setActiveField(FIELD.FROM);
  113. setMonth(toDate || now);
  114. queueMicrotask(() => focusById(fromBtnId));
  115. }
  116. function clearTo() {
  117. if (!canClearTo) return;
  118. commit(from ?? null, null);
  119. setActiveField(FIELD.TO);
  120. setMonth(fromDate || now);
  121. queueMicrotask(() => focusById(toBtnId));
  122. }
  123. function clearBoth() {
  124. if (!canClearBoth) return;
  125. commit(null, null);
  126. setActiveField(FIELD.FROM);
  127. setMonth(now);
  128. queueMicrotask(() => focusById(fromBtnId));
  129. }
  130. function applyPreset(preset) {
  131. if (!preset?.from || !preset?.to) return;
  132. commit(preset.from, preset.to);
  133. // Keep popover open so the user immediately sees the range highlighted.
  134. setActiveField(FIELD.TO);
  135. const nextMonth = toDateFromIsoDateYmd(preset.from) || now;
  136. setMonth(nextMonth);
  137. queueMicrotask(() => focusById(toBtnId));
  138. }
  139. function handleDayClick(day) {
  140. if (!(day instanceof Date) || Number.isNaN(day.getTime())) return;
  141. const iso = toIsoDateYmdFromDate(day);
  142. if (!iso) return;
  143. if (activeField === FIELD.FROM) {
  144. commit(iso, to ?? null);
  145. // After selecting FROM, auto-switch to TO (fast range building).
  146. setActiveField(FIELD.TO);
  147. setMonth(day);
  148. queueMicrotask(() => focusById(toBtnId));
  149. return;
  150. }
  151. // TO
  152. commit(from ?? null, iso);
  153. setMonth(day);
  154. // Keep popover open; closing is user-controlled (click outside).
  155. }
  156. return (
  157. <div className="grid gap-2">
  158. <Label>Zeitraum</Label>
  159. <Popover open={open} onOpenChange={setOpen}>
  160. <PopoverTrigger asChild>
  161. <Button
  162. type="button"
  163. variant="outline"
  164. disabled={isSubmitting}
  165. title="Zeitraum auswählen"
  166. className="justify-between"
  167. >
  168. <span className="truncate">{label}</span>
  169. <CalendarRange className="h-4 w-4 opacity-70" />
  170. </Button>
  171. </PopoverTrigger>
  172. {/* w-fit keeps the popover as compact as the 2-month calendar */}
  173. <PopoverContent align="start" className="w-fit p-0">
  174. <div className="p-3 space-y-3">
  175. {/* Presets (wrap + compact) */}
  176. <div className="space-y-2">
  177. <p className="text-xs text-muted-foreground">Schnellwahl</p>
  178. <div className="flex flex-wrap gap-2">
  179. {presets.map((p) => (
  180. <Badge key={p.key} variant="secondary" asChild>
  181. <button
  182. type="button"
  183. className="cursor-pointer select-none hover:opacity-90"
  184. disabled={isSubmitting}
  185. onClick={() => applyPreset(p)}
  186. title={`Schnellwahl: ${p.label}`}
  187. >
  188. {p.label}
  189. </button>
  190. </Badge>
  191. ))}
  192. </div>
  193. </div>
  194. {/* Von / Bis controls */}
  195. <div className="grid grid-cols-2 gap-3">
  196. <div className="grid gap-2">
  197. <Label>Von</Label>
  198. <div className="relative">
  199. <Button
  200. id={fromBtnId}
  201. type="button"
  202. variant="outline"
  203. size="sm"
  204. disabled={isSubmitting}
  205. onClick={() => setActive(FIELD.FROM)}
  206. className={cn(
  207. "w-full justify-between pr-10",
  208. activeField === FIELD.FROM ? activeBlue : ""
  209. )}
  210. title="Startdatum auswählen"
  211. >
  212. <span className="truncate">{fromLabel}</span>
  213. </Button>
  214. {canClearFrom ? (
  215. <button
  216. type="button"
  217. className="absolute right-2 top-1/2 -translate-y-1/2 inline-flex h-6 w-6 items-center justify-center rounded-sm text-muted-foreground hover:bg-muted/60 hover:text-foreground"
  218. onClick={(e) => {
  219. e.preventDefault();
  220. e.stopPropagation();
  221. clearFrom();
  222. }}
  223. aria-label="Startdatum löschen"
  224. title="Startdatum löschen"
  225. >
  226. <X className="h-4 w-4" />
  227. </button>
  228. ) : null}
  229. </div>
  230. </div>
  231. <div className="grid gap-2">
  232. <Label>Bis</Label>
  233. <div className="relative">
  234. <Button
  235. id={toBtnId}
  236. type="button"
  237. variant="outline"
  238. size="sm"
  239. disabled={isSubmitting}
  240. onClick={() => setActive(FIELD.TO)}
  241. className={cn(
  242. "w-full justify-between pr-10",
  243. activeField === FIELD.TO ? activeBlue : ""
  244. )}
  245. title="Enddatum auswählen"
  246. >
  247. <span className="truncate">{toLabel}</span>
  248. </Button>
  249. {canClearTo ? (
  250. <button
  251. type="button"
  252. className="absolute right-2 top-1/2 -translate-y-1/2 inline-flex h-6 w-6 items-center justify-center rounded-sm text-muted-foreground hover:bg-muted/60 hover:text-foreground"
  253. onClick={(e) => {
  254. e.preventDefault();
  255. e.stopPropagation();
  256. clearTo();
  257. }}
  258. aria-label="Enddatum löschen"
  259. title="Enddatum löschen"
  260. >
  261. <X className="h-4 w-4" />
  262. </button>
  263. ) : null}
  264. </div>
  265. </div>
  266. </div>
  267. {/* Calendar: 2 months + range highlight */}
  268. <Calendar
  269. mode="range"
  270. captionLayout="dropdown"
  271. fromYear={fromYear}
  272. toYear={toYear}
  273. numberOfMonths={2}
  274. pagedNavigation
  275. month={month}
  276. onMonthChange={setMonth}
  277. selected={displayedRange}
  278. onDayClick={(day) => handleDayClick(day)}
  279. initialFocus
  280. />
  281. <div className="flex justify-end gap-2 pt-1">
  282. <Button
  283. type="button"
  284. variant="outline"
  285. size="sm"
  286. disabled={!canClearBoth}
  287. onClick={clearBoth}
  288. title="Zeitraum entfernen"
  289. >
  290. Zurücksetzen
  291. </Button>
  292. </div>
  293. <div className="text-xs text-muted-foreground">
  294. Tipp: Für einen einzelnen Tag setzen Sie <strong>Von</strong> und{" "}
  295. <strong>Bis</strong> auf dasselbe Datum.
  296. </div>
  297. </div>
  298. </PopoverContent>
  299. </Popover>
  300. </div>
  301. );
  302. }