SearchDateRangePicker.jsx 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420
  1. "use client";
  2. import * as React from "react";
  3. import { Calendar as CalendarIcon, X } from "lucide-react";
  4. import { cn } from "@/lib/utils";
  5. import { Calendar } from "@/components/ui/calendar";
  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 { isValidIsoDateYmd } from "@/lib/frontend/search/dateRange";
  16. import { getSearchDateRangeValidation } from "@/lib/frontend/search/searchDateValidation";
  17. import { buildDatePresets } from "@/lib/frontend/search/datePresets";
  18. function pad2(n) {
  19. return String(n).padStart(2, "0");
  20. }
  21. function dateToIsoYmd(date) {
  22. if (!(date instanceof Date) || Number.isNaN(date.getTime())) return null;
  23. const y = date.getFullYear();
  24. const m = pad2(date.getMonth() + 1);
  25. const d = pad2(date.getDate());
  26. return `${y}-${m}-${d}`;
  27. }
  28. function isoYmdToDate(iso) {
  29. if (typeof iso !== "string") return null;
  30. if (!isValidIsoDateYmd(iso)) return null;
  31. const [y, m, d] = iso.split("-").map((x) => Number(x));
  32. return new Date(y, m - 1, d);
  33. }
  34. const deDateFormatter = new Intl.DateTimeFormat("de-DE", {
  35. day: "2-digit",
  36. month: "2-digit",
  37. year: "numeric",
  38. });
  39. function formatIsoToDe(iso) {
  40. const d = isoYmdToDate(iso);
  41. return d ? deDateFormatter.format(d) : "";
  42. }
  43. function getSummaryLabel(from, to) {
  44. if (from && to) return `${formatIsoToDe(from)} – ${formatIsoToDe(to)}`;
  45. if (from) return `ab ${formatIsoToDe(from)}`;
  46. if (to) return `bis ${formatIsoToDe(to)}`;
  47. return "Zeitraum auswählen";
  48. }
  49. export default function SearchDateRangePicker({
  50. from,
  51. to,
  52. onDateRangeChange,
  53. isSubmitting,
  54. className,
  55. }) {
  56. const disabled = Boolean(isSubmitting);
  57. const [open, setOpen] = React.useState(false);
  58. const [activeField, setActiveField] = React.useState("from"); // "from" | "to"
  59. const fromRef = React.useRef(null);
  60. const toRef = React.useRef(null);
  61. const fromDate = isoYmdToDate(from);
  62. const toDate = isoYmdToDate(to);
  63. const validation = getSearchDateRangeValidation(from ?? null, to ?? null);
  64. const isRangeInvalid = validation?.code === "VALIDATION_SEARCH_RANGE";
  65. const now = React.useMemo(() => new Date(), []);
  66. const presets = React.useMemo(() => buildDatePresets({ now }), [now]);
  67. // Split into two rows (4 + 3):
  68. // Row 1: short ranges (today/yesterday/last 7/last 30)
  69. // Row 2: larger ranges (this month/last month/this year)
  70. const presetsRow1 = presets.slice(0, 4);
  71. const presetsRow2 = presets.slice(4);
  72. const [month, setMonth] = React.useState(() => {
  73. return fromDate || toDate || new Date();
  74. });
  75. React.useEffect(() => {
  76. if (!open) return;
  77. if (from && !to) {
  78. setActiveField("to");
  79. requestAnimationFrame(() => toRef.current?.focus?.());
  80. } else {
  81. setActiveField("from");
  82. requestAnimationFrame(() => fromRef.current?.focus?.());
  83. }
  84. setMonth(fromDate || toDate || new Date());
  85. // eslint-disable-next-line react-hooks/exhaustive-deps
  86. }, [open]);
  87. function safeOnDateRangeChange(next) {
  88. if (typeof onDateRangeChange !== "function") return;
  89. const hasFrom = Object.prototype.hasOwnProperty.call(next || {}, "from");
  90. const hasTo = Object.prototype.hasOwnProperty.call(next || {}, "to");
  91. onDateRangeChange({
  92. from: hasFrom ? next.from : from ?? null,
  93. to: hasTo ? next.to : to ?? null,
  94. });
  95. }
  96. let calendarSelected = undefined;
  97. let invalidInterval = null;
  98. if (fromDate && toDate) {
  99. if (isRangeInvalid) {
  100. const min = fromDate < toDate ? fromDate : toDate;
  101. const max = fromDate < toDate ? toDate : fromDate;
  102. calendarSelected = { from: min, to: max };
  103. invalidInterval = { from: min, to: max };
  104. } else {
  105. calendarSelected = { from: fromDate, to: toDate };
  106. }
  107. } else if (fromDate) {
  108. calendarSelected = { from: fromDate, to: undefined };
  109. } else if (toDate) {
  110. calendarSelected = { from: toDate, to: toDate };
  111. }
  112. const calendarModifiers =
  113. isRangeInvalid && invalidInterval
  114. ? {
  115. invalid_range: invalidInterval,
  116. invalid_range_edge: [fromDate, toDate].filter(Boolean),
  117. }
  118. : undefined;
  119. const calendarModifiersClassNames =
  120. isRangeInvalid && invalidInterval
  121. ? {
  122. invalid_range:
  123. "bg-destructive/10 text-destructive dark:bg-destructive/20 dark:text-destructive",
  124. invalid_range_edge:
  125. "!bg-destructive !text-white hover:!bg-destructive/90",
  126. }
  127. : undefined;
  128. function focusActiveField(nextField) {
  129. if (nextField === "from") {
  130. requestAnimationFrame(() => fromRef.current?.focus?.());
  131. } else {
  132. requestAnimationFrame(() => toRef.current?.focus?.());
  133. }
  134. }
  135. function handlePickDay(...args) {
  136. if (disabled) return;
  137. let day = args[0];
  138. let modifiers = args[1];
  139. if (!(day instanceof Date) && args[1] instanceof Date) {
  140. day = args[1];
  141. modifiers = args[2];
  142. }
  143. if (!(day instanceof Date) || Number.isNaN(day.getTime())) return;
  144. if (modifiers?.disabled) return;
  145. const iso = dateToIsoYmd(day);
  146. if (!iso) return;
  147. if (activeField === "from") {
  148. safeOnDateRangeChange({ from: iso });
  149. setActiveField("to");
  150. focusActiveField("to");
  151. return;
  152. }
  153. safeOnDateRangeChange({ to: iso });
  154. setActiveField("to");
  155. focusActiveField("to");
  156. }
  157. function handleClearFrom(e) {
  158. e?.preventDefault?.();
  159. e?.stopPropagation?.();
  160. if (disabled) return;
  161. safeOnDateRangeChange({ from: null });
  162. setActiveField("from");
  163. focusActiveField("from");
  164. }
  165. function handleClearTo(e) {
  166. e?.preventDefault?.();
  167. e?.stopPropagation?.();
  168. if (disabled) return;
  169. safeOnDateRangeChange({ to: null });
  170. setActiveField("to");
  171. focusActiveField("to");
  172. }
  173. function handleReset() {
  174. if (disabled) return;
  175. safeOnDateRangeChange({ from: null, to: null });
  176. setActiveField("from");
  177. setMonth(new Date());
  178. focusActiveField("from");
  179. }
  180. function applyPreset(preset) {
  181. if (disabled) return;
  182. if (!preset?.from || !preset?.to) return;
  183. safeOnDateRangeChange({ from: preset.from, to: preset.to });
  184. const nextMonth =
  185. isoYmdToDate(preset.from) || isoYmdToDate(preset.to) || new Date();
  186. setMonth(nextMonth);
  187. setActiveField("to");
  188. focusActiveField("to");
  189. }
  190. const summary = getSummaryLabel(from, to);
  191. const activeInputClass =
  192. "border-blue-600 bg-blue-50 dark:border-blue-900 dark:bg-blue-950";
  193. const calendarKey = `${from || ""}|${to || ""}|${
  194. isRangeInvalid ? "inv" : "ok"
  195. }`;
  196. return (
  197. <div className={cn("grid gap-2", className)}>
  198. <Label>Zeitraum</Label>
  199. <Popover open={open} onOpenChange={setOpen}>
  200. <PopoverTrigger asChild>
  201. <Button
  202. type="button"
  203. variant="outline"
  204. disabled={disabled}
  205. className={cn(
  206. "w-[240px] justify-between font-normal",
  207. !from && !to ? "text-muted-foreground" : ""
  208. )}
  209. title="Zeitraum auswählen"
  210. >
  211. <span className="truncate">{summary}</span>
  212. <CalendarIcon className="ml-2 h-4 w-4 opacity-70" />
  213. </Button>
  214. </PopoverTrigger>
  215. {/* Fit to content (2 months). DayPicker supports multiple months via numberOfMonths. */}
  216. <PopoverContent align="start" className="w-fit p-0">
  217. <div className="w-fit space-y-4 p-4">
  218. <div className="grid grid-cols-2 gap-4">
  219. <div className="space-y-1">
  220. <Label>Von</Label>
  221. <div className="relative">
  222. <Input
  223. ref={fromRef}
  224. readOnly
  225. disabled={disabled}
  226. value={from ? formatIsoToDe(from) : ""}
  227. placeholder="TT.MM.JJJJ"
  228. className={cn(
  229. "pr-8",
  230. activeField === "from" ? activeInputClass : ""
  231. )}
  232. onFocus={() => setActiveField("from")}
  233. onClick={() => setActiveField("from")}
  234. />
  235. {from ? (
  236. <button
  237. type="button"
  238. className="absolute right-2 top-1/2 -translate-y-1/2 rounded-sm p-1 opacity-70 hover:opacity-100"
  239. onClick={handleClearFrom}
  240. aria-label="Startdatum löschen"
  241. title="Startdatum löschen"
  242. >
  243. <X className="h-4 w-4" />
  244. </button>
  245. ) : null}
  246. </div>
  247. </div>
  248. <div className="space-y-1">
  249. <Label>Bis</Label>
  250. <div className="relative">
  251. <Input
  252. ref={toRef}
  253. readOnly
  254. disabled={disabled}
  255. value={to ? formatIsoToDe(to) : ""}
  256. placeholder="TT.MM.JJJJ"
  257. className={cn(
  258. "pr-8",
  259. activeField === "to" ? activeInputClass : ""
  260. )}
  261. onFocus={() => setActiveField("to")}
  262. onClick={() => setActiveField("to")}
  263. />
  264. {to ? (
  265. <button
  266. type="button"
  267. className="absolute right-2 top-1/2 -translate-y-1/2 rounded-sm p-1 opacity-70 hover:opacity-100"
  268. onClick={handleClearTo}
  269. aria-label="Enddatum löschen"
  270. title="Enddatum löschen"
  271. >
  272. <X className="h-4 w-4" />
  273. </button>
  274. ) : null}
  275. </div>
  276. </div>
  277. </div>
  278. <Calendar
  279. key={calendarKey}
  280. mode="range"
  281. numberOfMonths={2}
  282. captionLayout="dropdown"
  283. month={month}
  284. onMonthChange={setMonth}
  285. selected={calendarSelected}
  286. modifiers={calendarModifiers}
  287. modifiersClassNames={calendarModifiersClassNames}
  288. onDayClick={handlePickDay}
  289. />
  290. {/* Presets: two flex rows (no grid), always looks balanced */}
  291. <div className="space-y-2">
  292. <div className="text-sm text-muted-foreground">Schnellwahl</div>
  293. <div className="flex flex-wrap gap-2">
  294. {presetsRow1.map((p) => (
  295. <Badge
  296. key={p.key}
  297. asChild
  298. className={[
  299. "bg-white text-black border-border",
  300. "hover:bg-white/90",
  301. "dark:bg-white dark:text-black",
  302. ].join(" ")}
  303. >
  304. <button
  305. type="button"
  306. className="cursor-pointer select-none disabled:opacity-60"
  307. disabled={disabled}
  308. onClick={() => applyPreset(p)}
  309. title={p.label}
  310. >
  311. {p.label}
  312. </button>
  313. </Badge>
  314. ))}
  315. </div>
  316. <div className="flex flex-wrap gap-2">
  317. {presetsRow2.map((p) => (
  318. <Badge
  319. key={p.key}
  320. asChild
  321. className={[
  322. "bg-white text-black border-border",
  323. "hover:bg-white/90",
  324. "dark:bg-white dark:text-black",
  325. ].join(" ")}
  326. >
  327. <button
  328. type="button"
  329. className="cursor-pointer select-none disabled:opacity-60"
  330. disabled={disabled}
  331. onClick={() => applyPreset(p)}
  332. title={p.label}
  333. >
  334. {p.label}
  335. </button>
  336. </Badge>
  337. ))}
  338. </div>
  339. </div>
  340. <div className="flex items-center justify-between gap-4 pt-1">
  341. <p className="text-xs text-muted-foreground">
  342. Tipp: Für einen einzelnen Tag setzen Sie <b>Von</b> und{" "}
  343. <b>Bis</b> auf dasselbe Datum.
  344. </p>
  345. <Button
  346. type="button"
  347. variant="outline"
  348. disabled={disabled}
  349. onClick={handleReset}
  350. >
  351. Zurücksetzen
  352. </Button>
  353. </div>
  354. </div>
  355. </PopoverContent>
  356. </Popover>
  357. </div>
  358. );
  359. }