|
@@ -0,0 +1,273 @@
|
|
|
|
|
+"use client";
|
|
|
|
|
+
|
|
|
|
|
+import React from "react";
|
|
|
|
|
+
|
|
|
|
|
+import { buildDatePresets } from "@/lib/frontend/search/datePresets";
|
|
|
|
|
+import { getSearchDateRangeValidation } from "@/lib/frontend/search/searchDateValidation";
|
|
|
|
|
+
|
|
|
|
|
+import {
|
|
|
|
|
+ formatIsoDateDe,
|
|
|
|
|
+ formatIsoDateRangeLabelDe,
|
|
|
|
|
+ toDateFromIsoDateYmd,
|
|
|
|
|
+ toIsoDateYmdFromDate,
|
|
|
|
|
+} from "@/lib/frontend/search/dateRange";
|
|
|
|
|
+
|
|
|
|
|
+const ACTIVE_FIELD = Object.freeze({
|
|
|
|
|
+ FROM: "from",
|
|
|
|
|
+ TO: "to",
|
|
|
|
|
+});
|
|
|
|
|
+
|
|
|
|
|
+function hasOwn(obj, key) {
|
|
|
|
|
+ return Object.prototype.hasOwnProperty.call(obj || {}, key);
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function focusRef(ref) {
|
|
|
|
|
+ requestAnimationFrame(() => ref?.current?.focus?.());
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function normalizeDayClickArgs(args) {
|
|
|
|
|
+ // react-day-picker handler signatures have differed across versions.
|
|
|
|
|
+ // We normalize both of these variants:
|
|
|
|
|
+ // - onDayClick(day, modifiers, event)
|
|
|
|
|
+ // - onDayClick(event, day, modifiers)
|
|
|
|
|
+ const a0 = args?.[0];
|
|
|
|
|
+ const a1 = args?.[1];
|
|
|
|
|
+ const a2 = args?.[2];
|
|
|
|
|
+
|
|
|
|
|
+ // Common: (day, modifiers)
|
|
|
|
|
+ if (a0 instanceof Date) return { day: a0, modifiers: a1 || null };
|
|
|
|
|
+
|
|
|
|
|
+ // Event-first: (event, day, modifiers)
|
|
|
|
|
+ if (a1 instanceof Date) return { day: a1, modifiers: a2 || null };
|
|
|
|
|
+
|
|
|
|
|
+ return { day: null, modifiers: null };
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function buildCalendarState({ fromDate, toDate, isRangeInvalid }) {
|
|
|
|
|
+ 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) {
|
|
|
|
|
+ // "to only" -> visually represent as a single-day range
|
|
|
|
|
+ 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;
|
|
|
|
|
+
|
|
|
|
|
+ return { calendarSelected, calendarModifiers, calendarModifiersClassNames };
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+export function useSearchDateRangePicker({
|
|
|
|
|
+ from,
|
|
|
|
|
+ to,
|
|
|
|
|
+ onDateRangeChange,
|
|
|
|
|
+ isSubmitting,
|
|
|
|
|
+}) {
|
|
|
|
|
+ const disabled = Boolean(isSubmitting);
|
|
|
|
|
+
|
|
|
|
|
+ const [open, setOpen] = React.useState(false);
|
|
|
|
|
+ const [activeField, setActiveField] = React.useState(ACTIVE_FIELD.FROM);
|
|
|
|
|
+
|
|
|
|
|
+ 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 validation = React.useMemo(() => {
|
|
|
|
|
+ return getSearchDateRangeValidation(from ?? null, to ?? null);
|
|
|
|
|
+ }, [from, to]);
|
|
|
|
|
+
|
|
|
|
|
+ const isRangeInvalid = validation?.code === "VALIDATION_SEARCH_RANGE";
|
|
|
|
|
+
|
|
|
|
|
+ const now = React.useMemo(() => new Date(), []);
|
|
|
|
|
+ const presets = React.useMemo(() => buildDatePresets({ now }), [now]);
|
|
|
|
|
+
|
|
|
|
|
+ const presetsRow1 = React.useMemo(() => presets.slice(0, 4), [presets]);
|
|
|
|
|
+ const presetsRow2 = React.useMemo(() => presets.slice(4), [presets]);
|
|
|
|
|
+
|
|
|
|
|
+ const [month, setMonth] = React.useState(() => {
|
|
|
|
|
+ return fromDate || toDate || new Date();
|
|
|
|
|
+ });
|
|
|
|
|
+
|
|
|
|
|
+ React.useEffect(() => {
|
|
|
|
|
+ if (!open) return;
|
|
|
|
|
+
|
|
|
|
|
+ // Only run on open transition.
|
|
|
|
|
+ // We intentionally do not depend on from/to to avoid focus jumps while clicking.
|
|
|
|
|
+ if (from && !to) {
|
|
|
|
|
+ setActiveField(ACTIVE_FIELD.TO);
|
|
|
|
|
+ focusRef(toRef);
|
|
|
|
|
+ } else {
|
|
|
|
|
+ setActiveField(ACTIVE_FIELD.FROM);
|
|
|
|
|
+ focusRef(fromRef);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ setMonth(fromDate || toDate || new Date());
|
|
|
|
|
+ // eslint-disable-next-line react-hooks/exhaustive-deps
|
|
|
|
|
+ }, [open]);
|
|
|
|
|
+
|
|
|
|
|
+ const safeOnDateRangeChange = React.useCallback(
|
|
|
|
|
+ (patch) => {
|
|
|
|
|
+ if (typeof onDateRangeChange !== "function") return;
|
|
|
|
|
+
|
|
|
|
|
+ const hasFrom = hasOwn(patch, "from");
|
|
|
|
|
+ const hasTo = hasOwn(patch, "to");
|
|
|
|
|
+
|
|
|
|
|
+ onDateRangeChange({
|
|
|
|
|
+ from: hasFrom ? patch.from : from ?? null,
|
|
|
|
|
+ to: hasTo ? patch.to : to ?? null,
|
|
|
|
|
+ });
|
|
|
|
|
+ },
|
|
|
|
|
+ [onDateRangeChange, from, to]
|
|
|
|
|
+ );
|
|
|
|
|
+
|
|
|
|
|
+ const { calendarSelected, calendarModifiers, calendarModifiersClassNames } =
|
|
|
|
|
+ React.useMemo(() => {
|
|
|
|
|
+ return buildCalendarState({ fromDate, toDate, isRangeInvalid });
|
|
|
|
|
+ }, [fromDate, toDate, isRangeInvalid]);
|
|
|
|
|
+
|
|
|
|
|
+ const calendarKey = React.useMemo(() => {
|
|
|
|
|
+ return `${from || ""}|${to || ""}|${isRangeInvalid ? "inv" : "ok"}`;
|
|
|
|
|
+ }, [from, to, isRangeInvalid]);
|
|
|
|
|
+
|
|
|
|
|
+ const summary = React.useMemo(() => {
|
|
|
|
|
+ return formatIsoDateRangeLabelDe({ from, to }) || "Zeitraum auswählen";
|
|
|
|
|
+ }, [from, to]);
|
|
|
|
|
+
|
|
|
|
|
+ const fromDisplay = React.useMemo(() => formatIsoDateDe(from) || "", [from]);
|
|
|
|
|
+ const toDisplay = React.useMemo(() => formatIsoDateDe(to) || "", [to]);
|
|
|
|
|
+
|
|
|
|
|
+ const handlePickDay = React.useCallback(
|
|
|
|
|
+ (...args) => {
|
|
|
|
|
+ if (disabled) return;
|
|
|
|
|
+
|
|
|
|
|
+ const { day, modifiers } = normalizeDayClickArgs(args);
|
|
|
|
|
+ if (!(day instanceof Date) || Number.isNaN(day.getTime())) return;
|
|
|
|
|
+ if (modifiers?.disabled) return;
|
|
|
|
|
+
|
|
|
|
|
+ const iso = toIsoDateYmdFromDate(day);
|
|
|
|
|
+ if (!iso) return;
|
|
|
|
|
+
|
|
|
|
|
+ if (activeField === ACTIVE_FIELD.FROM) {
|
|
|
|
|
+ safeOnDateRangeChange({ from: iso });
|
|
|
|
|
+ setActiveField(ACTIVE_FIELD.TO);
|
|
|
|
|
+ focusRef(toRef);
|
|
|
|
|
+ return;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ safeOnDateRangeChange({ to: iso });
|
|
|
|
|
+ setActiveField(ACTIVE_FIELD.TO);
|
|
|
|
|
+ focusRef(toRef);
|
|
|
|
|
+ },
|
|
|
|
|
+ [disabled, activeField, safeOnDateRangeChange]
|
|
|
|
|
+ );
|
|
|
|
|
+
|
|
|
|
|
+ const handleClearFrom = React.useCallback(
|
|
|
|
|
+ (e) => {
|
|
|
|
|
+ e?.preventDefault?.();
|
|
|
|
|
+ e?.stopPropagation?.();
|
|
|
|
|
+ if (disabled) return;
|
|
|
|
|
+
|
|
|
|
|
+ safeOnDateRangeChange({ from: null });
|
|
|
|
|
+ setActiveField(ACTIVE_FIELD.FROM);
|
|
|
|
|
+ focusRef(fromRef);
|
|
|
|
|
+ },
|
|
|
|
|
+ [disabled, safeOnDateRangeChange]
|
|
|
|
|
+ );
|
|
|
|
|
+
|
|
|
|
|
+ const handleClearTo = React.useCallback(
|
|
|
|
|
+ (e) => {
|
|
|
|
|
+ e?.preventDefault?.();
|
|
|
|
|
+ e?.stopPropagation?.();
|
|
|
|
|
+ if (disabled) return;
|
|
|
|
|
+
|
|
|
|
|
+ safeOnDateRangeChange({ to: null });
|
|
|
|
|
+ setActiveField(ACTIVE_FIELD.TO);
|
|
|
|
|
+ focusRef(toRef);
|
|
|
|
|
+ },
|
|
|
|
|
+ [disabled, safeOnDateRangeChange]
|
|
|
|
|
+ );
|
|
|
|
|
+
|
|
|
|
|
+ const handleReset = React.useCallback(() => {
|
|
|
|
|
+ if (disabled) return;
|
|
|
|
|
+
|
|
|
|
|
+ safeOnDateRangeChange({ from: null, to: null });
|
|
|
|
|
+ setActiveField(ACTIVE_FIELD.FROM);
|
|
|
|
|
+ setMonth(new Date());
|
|
|
|
|
+ focusRef(fromRef);
|
|
|
|
|
+ }, [disabled, safeOnDateRangeChange]);
|
|
|
|
|
+
|
|
|
|
|
+ const applyPreset = React.useCallback(
|
|
|
|
|
+ (preset) => {
|
|
|
|
|
+ if (disabled) return;
|
|
|
|
|
+ if (!preset?.from || !preset?.to) return;
|
|
|
|
|
+
|
|
|
|
|
+ safeOnDateRangeChange({ from: preset.from, to: preset.to });
|
|
|
|
|
+
|
|
|
|
|
+ const nextMonth =
|
|
|
|
|
+ toDateFromIsoDateYmd(preset.from) ||
|
|
|
|
|
+ toDateFromIsoDateYmd(preset.to) ||
|
|
|
|
|
+ new Date();
|
|
|
|
|
+
|
|
|
|
|
+ setMonth(nextMonth);
|
|
|
|
|
+ setActiveField(ACTIVE_FIELD.TO);
|
|
|
|
|
+ focusRef(toRef);
|
|
|
|
|
+ },
|
|
|
|
|
+ [disabled, safeOnDateRangeChange]
|
|
|
|
|
+ );
|
|
|
|
|
+
|
|
|
|
|
+ return {
|
|
|
|
|
+ disabled,
|
|
|
|
|
+ open,
|
|
|
|
|
+ setOpen,
|
|
|
|
|
+ activeField,
|
|
|
|
|
+ setActiveField,
|
|
|
|
|
+ fromRef,
|
|
|
|
|
+ toRef,
|
|
|
|
|
+ month,
|
|
|
|
|
+ setMonth,
|
|
|
|
|
+ summary,
|
|
|
|
|
+ fromDisplay,
|
|
|
|
|
+ toDisplay,
|
|
|
|
|
+ presetsRow1,
|
|
|
|
|
+ presetsRow2,
|
|
|
|
|
+ calendarKey,
|
|
|
|
|
+ calendarSelected,
|
|
|
|
|
+ calendarModifiers,
|
|
|
|
|
+ calendarModifiersClassNames,
|
|
|
|
|
+ handlePickDay,
|
|
|
|
|
+ handleClearFrom,
|
|
|
|
|
+ handleClearTo,
|
|
|
|
|
+ handleReset,
|
|
|
|
|
+ applyPreset,
|
|
|
|
|
+ };
|
|
|
|
|
+}
|