| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420 |
- "use client";
- import * as React from "react";
- import { Calendar as CalendarIcon, X } from "lucide-react";
- import { cn } from "@/lib/utils";
- import { Calendar } from "@/components/ui/calendar";
- import { Badge } from "@/components/ui/badge";
- import { Button } from "@/components/ui/button";
- import { Input } from "@/components/ui/input";
- import { Label } from "@/components/ui/label";
- import {
- Popover,
- PopoverContent,
- PopoverTrigger,
- } from "@/components/ui/popover";
- import { isValidIsoDateYmd } from "@/lib/frontend/search/dateRange";
- import { getSearchDateRangeValidation } from "@/lib/frontend/search/searchDateValidation";
- import { buildDatePresets } from "@/lib/frontend/search/datePresets";
- function pad2(n) {
- return String(n).padStart(2, "0");
- }
- function dateToIsoYmd(date) {
- if (!(date instanceof Date) || Number.isNaN(date.getTime())) return null;
- const y = date.getFullYear();
- const m = pad2(date.getMonth() + 1);
- const d = pad2(date.getDate());
- return `${y}-${m}-${d}`;
- }
- function isoYmdToDate(iso) {
- if (typeof iso !== "string") return null;
- if (!isValidIsoDateYmd(iso)) return null;
- const [y, m, d] = iso.split("-").map((x) => Number(x));
- return new Date(y, m - 1, d);
- }
- const deDateFormatter = new Intl.DateTimeFormat("de-DE", {
- day: "2-digit",
- month: "2-digit",
- year: "numeric",
- });
- function formatIsoToDe(iso) {
- const d = isoYmdToDate(iso);
- return d ? deDateFormatter.format(d) : "";
- }
- function getSummaryLabel(from, to) {
- if (from && to) return `${formatIsoToDe(from)} – ${formatIsoToDe(to)}`;
- if (from) return `ab ${formatIsoToDe(from)}`;
- if (to) return `bis ${formatIsoToDe(to)}`;
- return "Zeitraum auswählen";
- }
- export default function SearchDateRangePicker({
- from,
- to,
- onDateRangeChange,
- isSubmitting,
- className,
- }) {
- const disabled = Boolean(isSubmitting);
- const [open, setOpen] = React.useState(false);
- const [activeField, setActiveField] = React.useState("from"); // "from" | "to"
- const fromRef = React.useRef(null);
- const toRef = React.useRef(null);
- const fromDate = isoYmdToDate(from);
- const toDate = isoYmdToDate(to);
- const validation = getSearchDateRangeValidation(from ?? null, to ?? null);
- const isRangeInvalid = validation?.code === "VALIDATION_SEARCH_RANGE";
- const now = React.useMemo(() => new Date(), []);
- const presets = React.useMemo(() => buildDatePresets({ now }), [now]);
- // Split into two rows (4 + 3):
- // Row 1: short ranges (today/yesterday/last 7/last 30)
- // Row 2: larger ranges (this month/last month/this year)
- const presetsRow1 = presets.slice(0, 4);
- const presetsRow2 = presets.slice(4);
- const [month, setMonth] = React.useState(() => {
- return fromDate || toDate || new Date();
- });
- React.useEffect(() => {
- if (!open) return;
- if (from && !to) {
- setActiveField("to");
- requestAnimationFrame(() => toRef.current?.focus?.());
- } else {
- setActiveField("from");
- requestAnimationFrame(() => fromRef.current?.focus?.());
- }
- setMonth(fromDate || toDate || new Date());
- // eslint-disable-next-line react-hooks/exhaustive-deps
- }, [open]);
- function safeOnDateRangeChange(next) {
- if (typeof onDateRangeChange !== "function") return;
- const hasFrom = Object.prototype.hasOwnProperty.call(next || {}, "from");
- const hasTo = Object.prototype.hasOwnProperty.call(next || {}, "to");
- onDateRangeChange({
- from: hasFrom ? next.from : from ?? null,
- to: hasTo ? next.to : to ?? null,
- });
- }
- let calendarSelected = undefined;
- let invalidInterval = null;
- if (fromDate && toDate) {
- if (isRangeInvalid) {
- const min = fromDate < toDate ? fromDate : toDate;
- const max = fromDate < toDate ? toDate : fromDate;
- calendarSelected = { from: min, to: max };
- invalidInterval = { from: min, to: max };
- } else {
- calendarSelected = { from: fromDate, to: toDate };
- }
- } else if (fromDate) {
- calendarSelected = { from: fromDate, to: undefined };
- } else if (toDate) {
- calendarSelected = { from: toDate, to: toDate };
- }
- const calendarModifiers =
- isRangeInvalid && invalidInterval
- ? {
- invalid_range: invalidInterval,
- invalid_range_edge: [fromDate, toDate].filter(Boolean),
- }
- : undefined;
- const calendarModifiersClassNames =
- isRangeInvalid && invalidInterval
- ? {
- invalid_range:
- "bg-destructive/10 text-destructive dark:bg-destructive/20 dark:text-destructive",
- invalid_range_edge:
- "!bg-destructive !text-white hover:!bg-destructive/90",
- }
- : undefined;
- function focusActiveField(nextField) {
- if (nextField === "from") {
- requestAnimationFrame(() => fromRef.current?.focus?.());
- } else {
- requestAnimationFrame(() => toRef.current?.focus?.());
- }
- }
- function handlePickDay(...args) {
- if (disabled) return;
- let day = args[0];
- let modifiers = args[1];
- if (!(day instanceof Date) && args[1] instanceof Date) {
- day = args[1];
- modifiers = args[2];
- }
- if (!(day instanceof Date) || Number.isNaN(day.getTime())) return;
- if (modifiers?.disabled) return;
- const iso = dateToIsoYmd(day);
- if (!iso) return;
- if (activeField === "from") {
- safeOnDateRangeChange({ from: iso });
- setActiveField("to");
- focusActiveField("to");
- return;
- }
- safeOnDateRangeChange({ to: iso });
- setActiveField("to");
- focusActiveField("to");
- }
- function handleClearFrom(e) {
- e?.preventDefault?.();
- e?.stopPropagation?.();
- if (disabled) return;
- safeOnDateRangeChange({ from: null });
- setActiveField("from");
- focusActiveField("from");
- }
- function handleClearTo(e) {
- e?.preventDefault?.();
- e?.stopPropagation?.();
- if (disabled) return;
- safeOnDateRangeChange({ to: null });
- setActiveField("to");
- focusActiveField("to");
- }
- function handleReset() {
- if (disabled) return;
- safeOnDateRangeChange({ from: null, to: null });
- setActiveField("from");
- setMonth(new Date());
- focusActiveField("from");
- }
- function applyPreset(preset) {
- if (disabled) return;
- if (!preset?.from || !preset?.to) return;
- safeOnDateRangeChange({ from: preset.from, to: preset.to });
- const nextMonth =
- isoYmdToDate(preset.from) || isoYmdToDate(preset.to) || new Date();
- setMonth(nextMonth);
- setActiveField("to");
- focusActiveField("to");
- }
- const summary = getSummaryLabel(from, to);
- const activeInputClass =
- "border-blue-600 bg-blue-50 dark:border-blue-900 dark:bg-blue-950";
- const calendarKey = `${from || ""}|${to || ""}|${
- isRangeInvalid ? "inv" : "ok"
- }`;
- return (
- <div className={cn("grid gap-2", className)}>
- <Label>Zeitraum</Label>
- <Popover open={open} onOpenChange={setOpen}>
- <PopoverTrigger asChild>
- <Button
- type="button"
- variant="outline"
- disabled={disabled}
- className={cn(
- "w-[240px] justify-between font-normal",
- !from && !to ? "text-muted-foreground" : ""
- )}
- title="Zeitraum auswählen"
- >
- <span className="truncate">{summary}</span>
- <CalendarIcon className="ml-2 h-4 w-4 opacity-70" />
- </Button>
- </PopoverTrigger>
- {/* Fit to content (2 months). DayPicker supports multiple months via numberOfMonths. */}
- <PopoverContent align="start" className="w-fit p-0">
- <div className="w-fit space-y-4 p-4">
- <div className="grid grid-cols-2 gap-4">
- <div className="space-y-1">
- <Label>Von</Label>
- <div className="relative">
- <Input
- ref={fromRef}
- readOnly
- disabled={disabled}
- value={from ? formatIsoToDe(from) : ""}
- placeholder="TT.MM.JJJJ"
- className={cn(
- "pr-8",
- activeField === "from" ? activeInputClass : ""
- )}
- onFocus={() => setActiveField("from")}
- onClick={() => setActiveField("from")}
- />
- {from ? (
- <button
- type="button"
- className="absolute right-2 top-1/2 -translate-y-1/2 rounded-sm p-1 opacity-70 hover:opacity-100"
- onClick={handleClearFrom}
- aria-label="Startdatum löschen"
- title="Startdatum löschen"
- >
- <X className="h-4 w-4" />
- </button>
- ) : null}
- </div>
- </div>
- <div className="space-y-1">
- <Label>Bis</Label>
- <div className="relative">
- <Input
- ref={toRef}
- readOnly
- disabled={disabled}
- value={to ? formatIsoToDe(to) : ""}
- placeholder="TT.MM.JJJJ"
- className={cn(
- "pr-8",
- activeField === "to" ? activeInputClass : ""
- )}
- onFocus={() => setActiveField("to")}
- onClick={() => setActiveField("to")}
- />
- {to ? (
- <button
- type="button"
- className="absolute right-2 top-1/2 -translate-y-1/2 rounded-sm p-1 opacity-70 hover:opacity-100"
- onClick={handleClearTo}
- aria-label="Enddatum löschen"
- title="Enddatum löschen"
- >
- <X className="h-4 w-4" />
- </button>
- ) : null}
- </div>
- </div>
- </div>
- <Calendar
- key={calendarKey}
- mode="range"
- numberOfMonths={2}
- captionLayout="dropdown"
- month={month}
- onMonthChange={setMonth}
- selected={calendarSelected}
- modifiers={calendarModifiers}
- modifiersClassNames={calendarModifiersClassNames}
- onDayClick={handlePickDay}
- />
- {/* Presets: two flex rows (no grid), always looks balanced */}
- <div className="space-y-2">
- <div className="text-sm text-muted-foreground">Schnellwahl</div>
- <div className="flex flex-wrap gap-2">
- {presetsRow1.map((p) => (
- <Badge
- key={p.key}
- asChild
- className={[
- "bg-white text-black border-border",
- "hover:bg-white/90",
- "dark:bg-white dark:text-black",
- ].join(" ")}
- >
- <button
- type="button"
- className="cursor-pointer select-none disabled:opacity-60"
- disabled={disabled}
- onClick={() => applyPreset(p)}
- title={p.label}
- >
- {p.label}
- </button>
- </Badge>
- ))}
- </div>
- <div className="flex flex-wrap gap-2">
- {presetsRow2.map((p) => (
- <Badge
- key={p.key}
- asChild
- className={[
- "bg-white text-black border-border",
- "hover:bg-white/90",
- "dark:bg-white dark:text-black",
- ].join(" ")}
- >
- <button
- type="button"
- className="cursor-pointer select-none disabled:opacity-60"
- disabled={disabled}
- onClick={() => applyPreset(p)}
- title={p.label}
- >
- {p.label}
- </button>
- </Badge>
- ))}
- </div>
- </div>
- <div className="flex items-center justify-between gap-4 pt-1">
- <p className="text-xs text-muted-foreground">
- Tipp: Für einen einzelnen Tag setzen Sie <b>Von</b> und{" "}
- <b>Bis</b> auf dasselbe Datum.
- </p>
- <Button
- type="button"
- variant="outline"
- disabled={disabled}
- onClick={handleReset}
- >
- Zurücksetzen
- </Button>
- </div>
- </div>
- </PopoverContent>
- </Popover>
- </div>
- );
- }
|