|
|
@@ -1,23 +1,62 @@
|
|
|
"use client";
|
|
|
|
|
|
import React from "react";
|
|
|
-import { CalendarRange } from "lucide-react";
|
|
|
+import { CalendarRange, X } from "lucide-react";
|
|
|
|
|
|
-import { formatIsoDateRangeLabelDe } from "@/lib/frontend/search/dateRange";
|
|
|
+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 { Input } from "@/components/ui/input";
|
|
|
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 toNullableYmd(value) {
|
|
|
- if (typeof value !== "string") return null;
|
|
|
- const s = value.trim();
|
|
|
- return s ? s : null;
|
|
|
+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({
|
|
|
@@ -27,13 +66,149 @@ export default function SearchDateRangePicker({
|
|
|
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);
|
|
|
|
|
|
- const fromId = React.useId();
|
|
|
- const toId = React.useId();
|
|
|
+ queueMicrotask(() => {
|
|
|
+ focusById(field === FIELD.FROM ? fromBtnId : toBtnId);
|
|
|
+ });
|
|
|
+ }
|
|
|
|
|
|
- const canClear = Boolean((from || to) && !isSubmitting);
|
|
|
+ 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">
|
|
|
@@ -53,54 +228,129 @@ export default function SearchDateRangePicker({
|
|
|
</Button>
|
|
|
</PopoverTrigger>
|
|
|
|
|
|
- <PopoverContent align="start" className="w-80">
|
|
|
- <div className="space-y-4">
|
|
|
- <div className="grid gap-2">
|
|
|
- <Label htmlFor={fromId}>Von</Label>
|
|
|
- <Input
|
|
|
- id={fromId}
|
|
|
- type="date"
|
|
|
- value={from || ""}
|
|
|
- disabled={isSubmitting}
|
|
|
- onChange={(e) => {
|
|
|
- if (typeof onDateRangeChange !== "function") return;
|
|
|
- onDateRangeChange({
|
|
|
- from: toNullableYmd(e.target.value),
|
|
|
- to: to ?? null,
|
|
|
- });
|
|
|
- }}
|
|
|
- />
|
|
|
+ {/* 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>
|
|
|
|
|
|
- <div className="grid gap-2">
|
|
|
- <Label htmlFor={toId}>Bis</Label>
|
|
|
- <Input
|
|
|
- id={toId}
|
|
|
- type="date"
|
|
|
- value={to || ""}
|
|
|
- disabled={isSubmitting}
|
|
|
- onChange={(e) => {
|
|
|
- if (typeof onDateRangeChange !== "function") return;
|
|
|
- onDateRangeChange({
|
|
|
- from: from ?? null,
|
|
|
- to: toNullableYmd(e.target.value),
|
|
|
- });
|
|
|
- }}
|
|
|
- />
|
|
|
+ {/* 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>
|
|
|
|
|
|
- <div className="flex justify-end gap-2">
|
|
|
+ {/* 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={!canClear}
|
|
|
- onClick={() => {
|
|
|
- if (!canClear) return;
|
|
|
- if (typeof onDateRangeChange !== "function") return;
|
|
|
-
|
|
|
- onDateRangeChange({ from: null, to: null });
|
|
|
- }}
|
|
|
+ disabled={!canClearBoth}
|
|
|
+ onClick={clearBoth}
|
|
|
title="Zeitraum entfernen"
|
|
|
>
|
|
|
Zurücksetzen
|