| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369 |
- "use client";
- import React from "react";
- import { CalendarRange, X } from "lucide-react";
- import {
- formatIsoDateDe,
- formatIsoDateRangeLabelDe,
- toDateFromIsoDateYmd,
- toIsoDateYmdFromDate,
- compareIsoDatesYmd,
- normalizeIsoDateYmdOrNull,
- } from "@/lib/frontend/search/dateRange";
- import { buildDatePresets } from "@/lib/frontend/search/datePresets";
- import { Calendar } from "@/components/ui/calendar";
- import { Badge } from "@/components/ui/badge";
- import { Button } from "@/components/ui/button";
- import { Label } from "@/components/ui/label";
- import {
- Popover,
- PopoverContent,
- PopoverTrigger,
- } from "@/components/ui/popover";
- import { cn } from "@/lib/utils";
- const FIELD = Object.freeze({
- FROM: "from",
- TO: "to",
- });
- function pickInitialActiveField(from, to) {
- if (!from) return FIELD.FROM;
- if (from && !to) return FIELD.TO;
- return FIELD.FROM;
- }
- function focusById(id) {
- if (typeof document === "undefined") return;
- const el = document.getElementById(id);
- if (el && typeof el.focus === "function") el.focus();
- }
- function buildDisplayedRange(from, to) {
- const fIso = normalizeIsoDateYmdOrNull(from);
- const tIso = normalizeIsoDateYmdOrNull(to);
- const f = toDateFromIsoDateYmd(fIso);
- const t = toDateFromIsoDateYmd(tIso);
- if (f && t && compareIsoDatesYmd(fIso, tIso) <= 0) {
- return { from: f, to: t };
- }
- if (f) return { from: f, to: undefined };
- if (t) return { from: t, to: undefined };
- return undefined;
- }
- export default function SearchDateRangePicker({
- from,
- to,
- onDateRangeChange,
- isSubmitting,
- }) {
- const [open, setOpen] = React.useState(false);
- const [activeField, setActiveField] = React.useState(() =>
- pickInitialActiveField(from, to)
- );
- const fromBtnId = React.useId();
- const toBtnId = React.useId();
- const fromDate = React.useMemo(() => toDateFromIsoDateYmd(from), [from]);
- const toDate = React.useMemo(() => toDateFromIsoDateYmd(to), [to]);
- const now = React.useMemo(() => new Date(), []);
- const presets = React.useMemo(() => buildDatePresets({ now }), [now]);
- // Calendar month anchor (controlled) so we can keep UX stable when switching fields.
- const [month, setMonth] = React.useState(() => fromDate || toDate || now);
- // When popover opens: pick a sensible active field and month anchor.
- React.useEffect(() => {
- if (!open) return;
- const nextActive = pickInitialActiveField(from, to);
- setActiveField(nextActive);
- const nextMonth =
- nextActive === FIELD.FROM
- ? fromDate || toDate || now
- : toDate || fromDate || now;
- setMonth(nextMonth);
- queueMicrotask(() => {
- focusById(nextActive === FIELD.FROM ? fromBtnId : toBtnId);
- });
- // eslint-disable-next-line react-hooks/exhaustive-deps
- }, [open]);
- const label = formatIsoDateRangeLabelDe({ from, to }) || "Zeitraum";
- const fromLabel = formatIsoDateDe(from) || "Startdatum";
- const toLabel = formatIsoDateDe(to) || "Enddatum";
- const canClearBoth = Boolean((from || to) && !isSubmitting);
- const canClearFrom = Boolean(from && !isSubmitting);
- const canClearTo = Boolean(to && !isSubmitting);
- const activeBlue =
- "border-blue-600 bg-blue-50 dark:border-blue-900 dark:bg-blue-950";
- const fromYear = 2000;
- const toYear = now.getFullYear() + 1;
- // Show the selected range visually (works for presets too).
- const displayedRange = React.useMemo(() => {
- return buildDisplayedRange(from, to);
- }, [from, to]);
- function commit(nextFrom, nextTo) {
- if (typeof onDateRangeChange !== "function") return;
- onDateRangeChange({ from: nextFrom, to: nextTo });
- }
- function setActive(field) {
- setActiveField(field);
- const nextMonth =
- field === FIELD.FROM ? fromDate || month : toDate || month;
- if (nextMonth) setMonth(nextMonth);
- queueMicrotask(() => {
- focusById(field === FIELD.FROM ? fromBtnId : toBtnId);
- });
- }
- function clearFrom() {
- if (!canClearFrom) return;
- commit(null, to ?? null);
- setActiveField(FIELD.FROM);
- setMonth(toDate || now);
- queueMicrotask(() => focusById(fromBtnId));
- }
- function clearTo() {
- if (!canClearTo) return;
- commit(from ?? null, null);
- setActiveField(FIELD.TO);
- setMonth(fromDate || now);
- queueMicrotask(() => focusById(toBtnId));
- }
- function clearBoth() {
- if (!canClearBoth) return;
- commit(null, null);
- setActiveField(FIELD.FROM);
- setMonth(now);
- queueMicrotask(() => focusById(fromBtnId));
- }
- function applyPreset(preset) {
- if (!preset?.from || !preset?.to) return;
- commit(preset.from, preset.to);
- // Keep popover open so the user immediately sees the range highlighted.
- setActiveField(FIELD.TO);
- const nextMonth = toDateFromIsoDateYmd(preset.from) || now;
- setMonth(nextMonth);
- queueMicrotask(() => focusById(toBtnId));
- }
- function handleDayClick(day) {
- if (!(day instanceof Date) || Number.isNaN(day.getTime())) return;
- const iso = toIsoDateYmdFromDate(day);
- if (!iso) return;
- if (activeField === FIELD.FROM) {
- commit(iso, to ?? null);
- // After selecting FROM, auto-switch to TO (fast range building).
- setActiveField(FIELD.TO);
- setMonth(day);
- queueMicrotask(() => focusById(toBtnId));
- return;
- }
- // TO
- commit(from ?? null, iso);
- setMonth(day);
- // Keep popover open; closing is user-controlled (click outside).
- }
- return (
- <div className="grid gap-2">
- <Label>Zeitraum</Label>
- <Popover open={open} onOpenChange={setOpen}>
- <PopoverTrigger asChild>
- <Button
- type="button"
- variant="outline"
- disabled={isSubmitting}
- title="Zeitraum auswählen"
- className="justify-between"
- >
- <span className="truncate">{label}</span>
- <CalendarRange className="h-4 w-4 opacity-70" />
- </Button>
- </PopoverTrigger>
- {/* w-fit keeps the popover as compact as the 2-month calendar */}
- <PopoverContent align="start" className="w-fit p-0">
- <div className="p-3 space-y-3">
- {/* Presets (wrap + compact) */}
- <div className="space-y-2">
- <p className="text-xs text-muted-foreground">Schnellwahl</p>
- <div className="flex flex-wrap gap-2">
- {presets.map((p) => (
- <Badge key={p.key} variant="secondary" asChild>
- <button
- type="button"
- className="cursor-pointer select-none hover:opacity-90"
- disabled={isSubmitting}
- onClick={() => applyPreset(p)}
- title={`Schnellwahl: ${p.label}`}
- >
- {p.label}
- </button>
- </Badge>
- ))}
- </div>
- </div>
- {/* Von / Bis controls */}
- <div className="grid grid-cols-2 gap-3">
- <div className="grid gap-2">
- <Label>Von</Label>
- <div className="relative">
- <Button
- id={fromBtnId}
- type="button"
- variant="outline"
- size="sm"
- disabled={isSubmitting}
- onClick={() => setActive(FIELD.FROM)}
- className={cn(
- "w-full justify-between pr-10",
- activeField === FIELD.FROM ? activeBlue : ""
- )}
- title="Startdatum auswählen"
- >
- <span className="truncate">{fromLabel}</span>
- </Button>
- {canClearFrom ? (
- <button
- type="button"
- 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"
- onClick={(e) => {
- e.preventDefault();
- e.stopPropagation();
- clearFrom();
- }}
- aria-label="Startdatum löschen"
- title="Startdatum löschen"
- >
- <X className="h-4 w-4" />
- </button>
- ) : null}
- </div>
- </div>
- <div className="grid gap-2">
- <Label>Bis</Label>
- <div className="relative">
- <Button
- id={toBtnId}
- type="button"
- variant="outline"
- size="sm"
- disabled={isSubmitting}
- onClick={() => setActive(FIELD.TO)}
- className={cn(
- "w-full justify-between pr-10",
- activeField === FIELD.TO ? activeBlue : ""
- )}
- title="Enddatum auswählen"
- >
- <span className="truncate">{toLabel}</span>
- </Button>
- {canClearTo ? (
- <button
- type="button"
- 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"
- onClick={(e) => {
- e.preventDefault();
- e.stopPropagation();
- clearTo();
- }}
- aria-label="Enddatum löschen"
- title="Enddatum löschen"
- >
- <X className="h-4 w-4" />
- </button>
- ) : null}
- </div>
- </div>
- </div>
- {/* Calendar: 2 months + range highlight */}
- <Calendar
- mode="range"
- captionLayout="dropdown"
- fromYear={fromYear}
- toYear={toYear}
- numberOfMonths={2}
- pagedNavigation
- month={month}
- onMonthChange={setMonth}
- selected={displayedRange}
- onDayClick={(day) => handleDayClick(day)}
- initialFocus
- />
- <div className="flex justify-end gap-2 pt-1">
- <Button
- type="button"
- variant="outline"
- size="sm"
- disabled={!canClearBoth}
- onClick={clearBoth}
- title="Zeitraum entfernen"
- >
- Zurücksetzen
- </Button>
- </div>
- <div className="text-xs text-muted-foreground">
- Tipp: Für einen einzelnen Tag setzen Sie <strong>Von</strong> und{" "}
- <strong>Bis</strong> auf dasselbe Datum.
- </div>
- </div>
- </PopoverContent>
- </Popover>
- </div>
- );
- }
|