|
|
@@ -1,62 +1,62 @@
|
|
|
"use client";
|
|
|
|
|
|
-import React from "react";
|
|
|
-import { CalendarRange, X } from "lucide-react";
|
|
|
+import * as React from "react";
|
|
|
+import { Calendar as CalendarIcon, X } from "lucide-react";
|
|
|
|
|
|
-import {
|
|
|
- formatIsoDateDe,
|
|
|
- formatIsoDateRangeLabelDe,
|
|
|
- toDateFromIsoDateYmd,
|
|
|
- toIsoDateYmdFromDate,
|
|
|
- compareIsoDatesYmd,
|
|
|
- normalizeIsoDateYmdOrNull,
|
|
|
-} from "@/lib/frontend/search/dateRange";
|
|
|
-
|
|
|
-import { buildDatePresets } from "@/lib/frontend/search/datePresets";
|
|
|
+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 { cn } from "@/lib/utils";
|
|
|
|
|
|
-const FIELD = Object.freeze({
|
|
|
- FROM: "from",
|
|
|
- TO: "to",
|
|
|
-});
|
|
|
+import { isValidIsoDateYmd } from "@/lib/frontend/search/dateRange";
|
|
|
+import { getSearchDateRangeValidation } from "@/lib/frontend/search/searchDateValidation";
|
|
|
+import { buildDatePresets } from "@/lib/frontend/search/datePresets";
|
|
|
|
|
|
-function pickInitialActiveField(from, to) {
|
|
|
- if (!from) return FIELD.FROM;
|
|
|
- if (from && !to) return FIELD.TO;
|
|
|
- return FIELD.FROM;
|
|
|
+function pad2(n) {
|
|
|
+ return String(n).padStart(2, "0");
|
|
|
}
|
|
|
|
|
|
-function focusById(id) {
|
|
|
- if (typeof document === "undefined") return;
|
|
|
- const el = document.getElementById(id);
|
|
|
- if (el && typeof el.focus === "function") el.focus();
|
|
|
+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 buildDisplayedRange(from, to) {
|
|
|
- const fIso = normalizeIsoDateYmdOrNull(from);
|
|
|
- const tIso = normalizeIsoDateYmdOrNull(to);
|
|
|
+function isoYmdToDate(iso) {
|
|
|
+ if (typeof iso !== "string") return null;
|
|
|
+ if (!isValidIsoDateYmd(iso)) return null;
|
|
|
|
|
|
- const f = toDateFromIsoDateYmd(fIso);
|
|
|
- const t = toDateFromIsoDateYmd(tIso);
|
|
|
+ const [y, m, d] = iso.split("-").map((x) => Number(x));
|
|
|
+ return new Date(y, m - 1, d);
|
|
|
+}
|
|
|
|
|
|
- if (f && t && compareIsoDatesYmd(fIso, tIso) <= 0) {
|
|
|
- return { from: f, to: t };
|
|
|
- }
|
|
|
+const deDateFormatter = new Intl.DateTimeFormat("de-DE", {
|
|
|
+ day: "2-digit",
|
|
|
+ month: "2-digit",
|
|
|
+ year: "numeric",
|
|
|
+});
|
|
|
|
|
|
- if (f) return { from: f, to: undefined };
|
|
|
- if (t) return { from: t, to: undefined };
|
|
|
+function formatIsoToDe(iso) {
|
|
|
+ const d = isoYmdToDate(iso);
|
|
|
+ return d ? deDateFormatter.format(d) : "";
|
|
|
+}
|
|
|
|
|
|
- return undefined;
|
|
|
+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({
|
|
|
@@ -64,154 +64,189 @@ export default function SearchDateRangePicker({
|
|
|
to,
|
|
|
onDateRangeChange,
|
|
|
isSubmitting,
|
|
|
+ className,
|
|
|
}) {
|
|
|
+ const disabled = Boolean(isSubmitting);
|
|
|
+
|
|
|
const [open, setOpen] = React.useState(false);
|
|
|
- const [activeField, setActiveField] = React.useState(() =>
|
|
|
- pickInitialActiveField(from, to)
|
|
|
- );
|
|
|
+ const [activeField, setActiveField] = React.useState("from"); // "from" | "to"
|
|
|
|
|
|
- const fromBtnId = React.useId();
|
|
|
- const toBtnId = React.useId();
|
|
|
+ const fromRef = React.useRef(null);
|
|
|
+ const toRef = React.useRef(null);
|
|
|
|
|
|
- const fromDate = React.useMemo(() => toDateFromIsoDateYmd(from), [from]);
|
|
|
- const toDate = React.useMemo(() => toDateFromIsoDateYmd(to), [to]);
|
|
|
+ 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]);
|
|
|
|
|
|
- // Calendar month anchor (controlled) so we can keep UX stable when switching fields.
|
|
|
- const [month, setMonth] = React.useState(() => fromDate || toDate || 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();
|
|
|
+ });
|
|
|
|
|
|
- // 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);
|
|
|
+ if (from && !to) {
|
|
|
+ setActiveField("to");
|
|
|
+ requestAnimationFrame(() => toRef.current?.focus?.());
|
|
|
+ } else {
|
|
|
+ setActiveField("from");
|
|
|
+ requestAnimationFrame(() => fromRef.current?.focus?.());
|
|
|
+ }
|
|
|
|
|
|
- queueMicrotask(() => {
|
|
|
- focusById(nextActive === FIELD.FROM ? fromBtnId : toBtnId);
|
|
|
- });
|
|
|
+ setMonth(fromDate || toDate || new Date());
|
|
|
// 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";
|
|
|
+ function safeOnDateRangeChange(next) {
|
|
|
+ if (typeof onDateRangeChange !== "function") return;
|
|
|
|
|
|
- const fromYear = 2000;
|
|
|
- const toYear = now.getFullYear() + 1;
|
|
|
+ const hasFrom = Object.prototype.hasOwnProperty.call(next || {}, "from");
|
|
|
+ const hasTo = Object.prototype.hasOwnProperty.call(next || {}, "to");
|
|
|
|
|
|
- // Show the selected range visually (works for presets too).
|
|
|
- const displayedRange = React.useMemo(() => {
|
|
|
- return buildDisplayedRange(from, to);
|
|
|
- }, [from, to]);
|
|
|
+ onDateRangeChange({
|
|
|
+ from: hasFrom ? next.from : from ?? null,
|
|
|
+ to: hasTo ? next.to : to ?? null,
|
|
|
+ });
|
|
|
+ }
|
|
|
|
|
|
- function commit(nextFrom, nextTo) {
|
|
|
- if (typeof onDateRangeChange !== "function") return;
|
|
|
- onDateRangeChange({ from: nextFrom, to: nextTo });
|
|
|
+ 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 };
|
|
|
}
|
|
|
|
|
|
- function setActive(field) {
|
|
|
- setActiveField(field);
|
|
|
+ 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?.());
|
|
|
+ }
|
|
|
+ }
|
|
|
|
|
|
- const nextMonth =
|
|
|
- field === FIELD.FROM ? fromDate || month : toDate || month;
|
|
|
+ function handlePickDay(...args) {
|
|
|
+ if (disabled) return;
|
|
|
|
|
|
- if (nextMonth) setMonth(nextMonth);
|
|
|
+ let day = args[0];
|
|
|
+ let modifiers = args[1];
|
|
|
|
|
|
- queueMicrotask(() => {
|
|
|
- focusById(field === FIELD.FROM ? fromBtnId : toBtnId);
|
|
|
- });
|
|
|
- }
|
|
|
+ if (!(day instanceof Date) && args[1] instanceof Date) {
|
|
|
+ day = args[1];
|
|
|
+ modifiers = args[2];
|
|
|
+ }
|
|
|
|
|
|
- function clearFrom() {
|
|
|
- if (!canClearFrom) return;
|
|
|
+ if (!(day instanceof Date) || Number.isNaN(day.getTime())) return;
|
|
|
+ if (modifiers?.disabled) return;
|
|
|
|
|
|
- commit(null, to ?? null);
|
|
|
+ const iso = dateToIsoYmd(day);
|
|
|
+ if (!iso) return;
|
|
|
|
|
|
- setActiveField(FIELD.FROM);
|
|
|
- setMonth(toDate || now);
|
|
|
+ if (activeField === "from") {
|
|
|
+ safeOnDateRangeChange({ from: iso });
|
|
|
+ setActiveField("to");
|
|
|
+ focusActiveField("to");
|
|
|
+ return;
|
|
|
+ }
|
|
|
|
|
|
- queueMicrotask(() => focusById(fromBtnId));
|
|
|
+ safeOnDateRangeChange({ to: iso });
|
|
|
+ setActiveField("to");
|
|
|
+ focusActiveField("to");
|
|
|
}
|
|
|
|
|
|
- function clearTo() {
|
|
|
- if (!canClearTo) return;
|
|
|
+ function handleClearFrom(e) {
|
|
|
+ e?.preventDefault?.();
|
|
|
+ e?.stopPropagation?.();
|
|
|
+ if (disabled) return;
|
|
|
|
|
|
- commit(from ?? null, null);
|
|
|
-
|
|
|
- setActiveField(FIELD.TO);
|
|
|
- setMonth(fromDate || now);
|
|
|
-
|
|
|
- queueMicrotask(() => focusById(toBtnId));
|
|
|
+ safeOnDateRangeChange({ from: null });
|
|
|
+ setActiveField("from");
|
|
|
+ focusActiveField("from");
|
|
|
}
|
|
|
|
|
|
- function clearBoth() {
|
|
|
- if (!canClearBoth) return;
|
|
|
+ function handleClearTo(e) {
|
|
|
+ e?.preventDefault?.();
|
|
|
+ e?.stopPropagation?.();
|
|
|
+ if (disabled) return;
|
|
|
|
|
|
- commit(null, null);
|
|
|
+ safeOnDateRangeChange({ to: null });
|
|
|
+ setActiveField("to");
|
|
|
+ focusActiveField("to");
|
|
|
+ }
|
|
|
|
|
|
- setActiveField(FIELD.FROM);
|
|
|
- setMonth(now);
|
|
|
+ function handleReset() {
|
|
|
+ if (disabled) return;
|
|
|
|
|
|
- queueMicrotask(() => focusById(fromBtnId));
|
|
|
+ safeOnDateRangeChange({ from: null, to: null });
|
|
|
+ setActiveField("from");
|
|
|
+ setMonth(new Date());
|
|
|
+ focusActiveField("from");
|
|
|
}
|
|
|
|
|
|
function applyPreset(preset) {
|
|
|
+ if (disabled) return;
|
|
|
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);
|
|
|
+ safeOnDateRangeChange({ from: preset.from, to: preset.to });
|
|
|
|
|
|
- const nextMonth = toDateFromIsoDateYmd(preset.from) || now;
|
|
|
+ const nextMonth =
|
|
|
+ isoYmdToDate(preset.from) || isoYmdToDate(preset.to) || new Date();
|
|
|
setMonth(nextMonth);
|
|
|
|
|
|
- queueMicrotask(() => focusById(toBtnId));
|
|
|
+ setActiveField("to");
|
|
|
+ focusActiveField("to");
|
|
|
}
|
|
|
|
|
|
- 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;
|
|
|
- }
|
|
|
+ const summary = getSummaryLabel(from, to);
|
|
|
|
|
|
- // TO
|
|
|
- commit(from ?? null, iso);
|
|
|
- setMonth(day);
|
|
|
+ const activeInputClass =
|
|
|
+ "border-blue-600 bg-blue-50 dark:border-blue-900 dark:bg-blue-950";
|
|
|
|
|
|
- // Keep popover open; closing is user-controlled (click outside).
|
|
|
- }
|
|
|
+ const calendarKey = `${from || ""}|${to || ""}|${
|
|
|
+ isRangeInvalid ? "inv" : "ok"
|
|
|
+ }`;
|
|
|
|
|
|
return (
|
|
|
- <div className="grid gap-2">
|
|
|
+ <div className={cn("grid gap-2", className)}>
|
|
|
<Label>Zeitraum</Label>
|
|
|
|
|
|
<Popover open={open} onOpenChange={setOpen}>
|
|
|
@@ -219,69 +254,43 @@ export default function SearchDateRangePicker({
|
|
|
<Button
|
|
|
type="button"
|
|
|
variant="outline"
|
|
|
- disabled={isSubmitting}
|
|
|
+ disabled={disabled}
|
|
|
+ className={cn(
|
|
|
+ "w-[240px] justify-between font-normal",
|
|
|
+ !from && !to ? "text-muted-foreground" : ""
|
|
|
+ )}
|
|
|
title="Zeitraum auswählen"
|
|
|
- className="justify-between"
|
|
|
>
|
|
|
- <span className="truncate">{label}</span>
|
|
|
- <CalendarRange className="h-4 w-4 opacity-70" />
|
|
|
+ <span className="truncate">{summary}</span>
|
|
|
+ <CalendarIcon className="ml-2 h-4 w-4 opacity-70" />
|
|
|
</Button>
|
|
|
</PopoverTrigger>
|
|
|
|
|
|
- {/* w-fit keeps the popover as compact as the 2-month calendar */}
|
|
|
+ {/* Fit to content (2 months). DayPicker supports multiple months via numberOfMonths. */}
|
|
|
<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">
|
|
|
+ <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">
|
|
|
- <Button
|
|
|
- id={fromBtnId}
|
|
|
- type="button"
|
|
|
- variant="outline"
|
|
|
- size="sm"
|
|
|
- disabled={isSubmitting}
|
|
|
- onClick={() => setActive(FIELD.FROM)}
|
|
|
+ <Input
|
|
|
+ ref={fromRef}
|
|
|
+ readOnly
|
|
|
+ disabled={disabled}
|
|
|
+ value={from ? formatIsoToDe(from) : ""}
|
|
|
+ placeholder="TT.MM.JJJJ"
|
|
|
className={cn(
|
|
|
- "w-full justify-between pr-10",
|
|
|
- activeField === FIELD.FROM ? activeBlue : ""
|
|
|
+ "pr-8",
|
|
|
+ activeField === "from" ? activeInputClass : ""
|
|
|
)}
|
|
|
- title="Startdatum auswählen"
|
|
|
- >
|
|
|
- <span className="truncate">{fromLabel}</span>
|
|
|
- </Button>
|
|
|
-
|
|
|
- {canClearFrom ? (
|
|
|
+ onFocus={() => setActiveField("from")}
|
|
|
+ onClick={() => setActiveField("from")}
|
|
|
+ />
|
|
|
+ {from ? (
|
|
|
<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();
|
|
|
- }}
|
|
|
+ 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"
|
|
|
>
|
|
|
@@ -291,34 +300,27 @@ export default function SearchDateRangePicker({
|
|
|
</div>
|
|
|
</div>
|
|
|
|
|
|
- <div className="grid gap-2">
|
|
|
+ <div className="space-y-1">
|
|
|
<Label>Bis</Label>
|
|
|
<div className="relative">
|
|
|
- <Button
|
|
|
- id={toBtnId}
|
|
|
- type="button"
|
|
|
- variant="outline"
|
|
|
- size="sm"
|
|
|
- disabled={isSubmitting}
|
|
|
- onClick={() => setActive(FIELD.TO)}
|
|
|
+ <Input
|
|
|
+ ref={toRef}
|
|
|
+ readOnly
|
|
|
+ disabled={disabled}
|
|
|
+ value={to ? formatIsoToDe(to) : ""}
|
|
|
+ placeholder="TT.MM.JJJJ"
|
|
|
className={cn(
|
|
|
- "w-full justify-between pr-10",
|
|
|
- activeField === FIELD.TO ? activeBlue : ""
|
|
|
+ "pr-8",
|
|
|
+ activeField === "to" ? activeInputClass : ""
|
|
|
)}
|
|
|
- title="Enddatum auswählen"
|
|
|
- >
|
|
|
- <span className="truncate">{toLabel}</span>
|
|
|
- </Button>
|
|
|
-
|
|
|
- {canClearTo ? (
|
|
|
+ onFocus={() => setActiveField("to")}
|
|
|
+ onClick={() => setActiveField("to")}
|
|
|
+ />
|
|
|
+ {to ? (
|
|
|
<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();
|
|
|
- }}
|
|
|
+ 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"
|
|
|
>
|
|
|
@@ -329,38 +331,87 @@ export default function SearchDateRangePicker({
|
|
|
</div>
|
|
|
</div>
|
|
|
|
|
|
- {/* Calendar: 2 months + range highlight */}
|
|
|
<Calendar
|
|
|
+ key={calendarKey}
|
|
|
mode="range"
|
|
|
- captionLayout="dropdown"
|
|
|
- fromYear={fromYear}
|
|
|
- toYear={toYear}
|
|
|
numberOfMonths={2}
|
|
|
- pagedNavigation
|
|
|
+ captionLayout="dropdown"
|
|
|
month={month}
|
|
|
onMonthChange={setMonth}
|
|
|
- selected={displayedRange}
|
|
|
- onDayClick={(day) => handleDayClick(day)}
|
|
|
- initialFocus
|
|
|
+ selected={calendarSelected}
|
|
|
+ modifiers={calendarModifiers}
|
|
|
+ modifiersClassNames={calendarModifiersClassNames}
|
|
|
+ onDayClick={handlePickDay}
|
|
|
/>
|
|
|
|
|
|
- <div className="flex justify-end gap-2 pt-1">
|
|
|
+ {/* 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"
|
|
|
- size="sm"
|
|
|
- disabled={!canClearBoth}
|
|
|
- onClick={clearBoth}
|
|
|
- title="Zeitraum entfernen"
|
|
|
+ disabled={disabled}
|
|
|
+ onClick={handleReset}
|
|
|
>
|
|
|
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>
|