import React, { useLayoutEffect, useEffect, useState, useRef, useMemo, useCallback } from 'react'; import { getMonth, getYear, isAfter, isEqual, isWithinInterval, startOfDay, endOfDay, addYears, addDays, startOfToday } from 'date-fns'; import { useSelector, useDispatch } from 'react-redux'; import { QSMRootState } from '../../store/qsm-store'; import { nl } from 'date-fns/locale'; import Calendar from './calendar'; import useMediaQuery from '../../../shared/utils/use-media-query-util'; import { setSelectedFlexRange } from '../../store/qsm-slice'; interface DateRangePickerProps { fromDate?: Date; toDate?: Date; onSelectionChange?: (fromDate?: Date, toDate?: Date) => void; isSingleDate?: boolean; onRequestClose?: () => void; } const DateRangePicker: React.FC = ({ fromDate: initialFromDate, toDate: initialToDate, onSelectionChange, onRequestClose, isSingleDate = false }) => { // --------------------------------------------------------------------------- // Local state // --------------------------------------------------------------------------- const [fromDate, setFromDate] = useState(initialFromDate); const [toDate, setToDate] = useState(initialToDate); const [waitingForToDate, setWaitingForToDate] = useState(false); const [flexibleRangeDatePicker, setFlexibleRangeDatePicker] = useState<{ start: Date; end: Date; } | null>(null); const [focusMonth, setFocusMonth] = useState<{ month: number; year: number; }>({ month: getMonth(initialFromDate || new Date()), year: getYear(initialFromDate || new Date()) }); // --------------------------------------------------------------------------- // Redux & context // --------------------------------------------------------------------------- const dispatch = useDispatch(); const { minDate, maxDate, dateFlexibility, selectedFlexRange } = useSelector((state: QSMRootState) => ({ minDate: state.qsm.minDate ? new Date(state.qsm.minDate) : undefined, maxDate: state.qsm.maxDate ? new Date(state.qsm.maxDate) : undefined, dateFlexibility: state.qsm.dateFlexibility ?? [], selectedFlexRange: state.qsm.selectedFlexRange })); // --------------------------------------------------------------------------- // Constants & helpers // --------------------------------------------------------------------------- const today = startOfToday(); const subtractOneMonth = (d: Date) => new Date(d.getFullYear(), d.getMonth() - 1, d.getDate()); const safeMinDate = minDate ? subtractOneMonth(minDate) : today; const safeMaxDate = maxDate ? subtractOneMonth(maxDate) : addYears(today, 1); const isMobile = useMediaQuery('(max-width: 768px)'); const rootRef = useRef(null); const [alignRight, setAlignRight] = useState(false); // Default flex option (exact date) const DEFAULT_FLEX_OPTION = useMemo(() => ({ name: 'exacte datum', before: 0, after: 0 }), []); const emitSelection = useCallback( (start: Date | undefined, end?: Date) => { onSelectionChange?.(start, isSingleDate ? undefined : end); }, [onSelectionChange, isSingleDate] ); // Merge default + server‑side flex options const combinedFlexibility = useMemo(() => { const withoutZeroZero = dateFlexibility.filter((opt) => opt.before !== 0 || opt.after !== 0); return [DEFAULT_FLEX_OPTION, ...withoutZeroZero]; }, [dateFlexibility, DEFAULT_FLEX_OPTION]); const isSameFlexRange = useCallback( (a: { before: number; after: number } | undefined, b: { before: number; after: number }) => a?.before === b.before && a?.after === b.after, [] ); // --------------------------------------------------------------------------- // Flex option auto‑select (only once) // --------------------------------------------------------------------------- useEffect(() => { if (combinedFlexibility.length > 0 && !selectedFlexRange) { dispatch(setSelectedFlexRange(DEFAULT_FLEX_OPTION)); } }, [combinedFlexibility, selectedFlexRange]); // --------------------------------------------------------------------------- // Keep internal state in sync with parent props // --------------------------------------------------------------------------- useEffect(() => { setFromDate(initialFromDate); setToDate(initialToDate); }, [initialFromDate, initialToDate]); // --------------------------------------------------------------------------- // Build flexible window (works for single & range) // --------------------------------------------------------------------------- useEffect(() => { if (!selectedFlexRange || !fromDate) { setFlexibleRangeDatePicker(null); return; } // Determine chronological first & last selected dates const firstSelected = toDate && isAfter(fromDate, toDate) ? toDate : fromDate; const lastSelected = toDate && isAfter(fromDate, toDate) ? fromDate : toDate ?? fromDate; // Expand with flex option const flexStart = addDays(firstSelected, -selectedFlexRange.before); const flexEnd = addDays(lastSelected, selectedFlexRange.after); setFlexibleRangeDatePicker({ start: flexStart, end: flexEnd }); }, [fromDate, toDate, selectedFlexRange]); // --------------------------------------------------------------------------- // Calendar navigation handlers // --------------------------------------------------------------------------- const handlePreviousClick = useCallback(() => { setFocusMonth((prev) => { const month = (prev.month - 1 + 12) % 12; const year = month > prev.month ? prev.year - 1 : prev.year; return { month, year }; }); }, []); const handleNextClick = useCallback(() => { setFocusMonth((prev) => { const month = (prev.month + 1) % 12; const year = month < prev.month ? prev.year + 1 : prev.year; return { month, year }; }); }, []); // --------------------------------------------------------------------------- // Date selection logic // --------------------------------------------------------------------------- const isOutOfBounds = useCallback( (date: Date) => { const start = safeMinDate ? startOfDay(safeMinDate) : undefined; const end = safeMaxDate ? startOfDay(safeMaxDate) : undefined; return (start && date < start) || (end && date > end); }, [safeMinDate, safeMaxDate] ); const handleDayClick = useCallback( (day: Date) => { if (isOutOfBounds(day)) return; if (isSingleDate) { setFromDate(day); setToDate(undefined); setWaitingForToDate(false); emitSelection(day); return; } if (waitingForToDate && fromDate && isAfter(day, fromDate)) { setToDate(day); setWaitingForToDate(false); emitSelection(fromDate, day); } else { setFromDate(day); setToDate(undefined); setWaitingForToDate(true); emitSelection(day, undefined); } }, [fromDate, waitingForToDate, isSingleDate, isOutOfBounds] ); const handleDayMouseOver = useCallback( (day: Date) => { if (!isSingleDate && waitingForToDate && fromDate && (isEqual(day, fromDate) || isAfter(day, fromDate))) { setToDate(day); } }, [isSingleDate, waitingForToDate, fromDate] ); // --------------------------------------------------------------------------- // Flex option click // --------------------------------------------------------------------------- const handleFlexSearchClick = useCallback( (index: number) => { const option = combinedFlexibility[index]; if (!option) return; dispatch( setSelectedFlexRange({ before: option.before, after: option.after }) ); }, [combinedFlexibility] ); // --------------------------------------------------------------------------- // Confirm / clear // --------------------------------------------------------------------------- const handleClear = useCallback(() => { setFromDate(undefined); setToDate(undefined); setWaitingForToDate(false); dispatch(setSelectedFlexRange(DEFAULT_FLEX_OPTION)); onSelectionChange?.(undefined, undefined); onRequestClose?.(); }, [onSelectionChange, onRequestClose]); const handleConfirm = useCallback(() => { if (!fromDate) { return; } onSelectionChange?.(fromDate, isSingleDate ? undefined : toDate); onRequestClose?.(); }, [fromDate, toDate, onSelectionChange, isSingleDate]); // --------------------------------------------------------------------------- // Utility helpers for Calendar rendering // --------------------------------------------------------------------------- const checkIfDateIsInRange = useCallback( (date: Date) => { if (isSingleDate || !fromDate || !toDate) { return false; } return isWithinInterval(date, { start: startOfDay(fromDate), end: endOfDay(toDate) }); }, [isSingleDate, fromDate, toDate] ); const checkIfDateIsInFlexibleRange = useCallback( (date: Date) => { if (!flexibleRangeDatePicker) { return false; } const [flexStart, flexEnd] = flexibleRangeDatePicker.start < flexibleRangeDatePicker.end ? [startOfDay(flexibleRangeDatePicker.start), endOfDay(flexibleRangeDatePicker.end)] : [startOfDay(flexibleRangeDatePicker.end), endOfDay(flexibleRangeDatePicker.start)]; const isInFlexRange = isWithinInterval(date, { start: flexStart, end: flexEnd }); if (isSingleDate) { return isInFlexRange; } const isInSelectedRange = fromDate && toDate && isWithinInterval(date, { start: startOfDay(fromDate), end: endOfDay(toDate) }); return !isInSelectedRange && isInFlexRange; }, [flexibleRangeDatePicker, isSingleDate, fromDate, toDate] ); const isStart = useCallback((date: Date) => !!fromDate && isEqual(startOfDay(date), startOfDay(fromDate)), [fromDate]); const isEnd = useCallback((date: Date) => !isSingleDate && !!toDate && isEqual(startOfDay(date), startOfDay(toDate)), [isSingleDate, toDate]); const extraClassNamesFunction = useCallback( (date: Date) => { const classes: string[] = []; if (checkIfDateIsInFlexibleRange(date)) { classes.push('date-range-picker__flexible-range'); } return classes; }, [checkIfDateIsInFlexibleRange] ); // --------------------------------------------------------------------------- // Layout alignment (desktop popover) // --------------------------------------------------------------------------- useLayoutEffect(() => { const el = rootRef.current; if (!el) { return; } setAlignRight(false); // Delay until browser paints so we have up‑to‑date bounding box const id = window.requestAnimationFrame(() => { const rect = el.getBoundingClientRect(); if (rect.right > window.innerWidth) { setAlignRight(true); } }); return () => window.cancelAnimationFrame(id); }, [focusMonth, isMobile]); // --------------------------------------------------------------------------- // Derived calendar months (memoised) // --------------------------------------------------------------------------- const firstMonth = focusMonth.month; const firstYear = focusMonth.year; const secondMonth = (firstMonth + 1) % 12; const secondYear = secondMonth === 0 && firstMonth === 11 ? firstYear + 1 : firstMonth === 11 ? firstYear + 1 : firstYear; // --------------------------------------------------------------------------- // Render // --------------------------------------------------------------------------- return (
{/* -------------------------------------------------------------- Months */} {/*
*/}
{!isMobile && (
)} {/* ------------------------------------------------------------ Flex tags */} {/* {combinedFlexibility.length > 1 && (
{combinedFlexibility.map((option, index) => ( handleFlexSearchClick(index)}> {option.name} ))}
)} */} {/* ----------------------------------------------------------- Actions */} {/*
Opslaan
Wis
*/}
); }; export default DateRangePicker;