import { type ChangeEvent, type ComponentProps, createContext, useCallback, useContext, useEffect, useMemo, useState, } from "react" import { addMonths, format, isBefore, isSameMonth, parse, subMonths, } from "date-fns" import { DayButton } from "react-day-picker" import type { DateRange } from "react-day-picker" import { useIsMobile } from "@telemt/ui/hooks/use-mobile" import { cn } from "@telemt/ui/lib/utils" import { Button } from "@telemt/ui/components/button" import { Calendar, CalendarDayButton } from "@telemt/ui/components/calendar" import { Input } from "@telemt/ui/components/input" import { ScrollArea } from "@telemt/ui/components/scroll-area" import { Tabs, TabsList, TabsTrigger } from "@telemt/ui/components/tabs" import { CornerUpLeftIcon, CornerUpRightIcon, ChevronLeftIcon, ChevronRightIcon, XIcon } from "lucide-react" export interface DateSelectorI18nConfig { // Labels selectDate: string apply: string cancel: string clear: string today: string // Filter types filterTypes: { is: string before: string after: string between: string } // Period types periodTypes: { day: string month: string quarter: string halfYear: string year: string } // Months months: string[] monthsShort: string[] // Quarters quarters: string[] // Half years halfYears: string[] // Weekdays weekdays: string[] weekdaysShort: string[] // Placeholders placeholder: string rangePlaceholder: string } export const DEFAULT_DATE_SELECTOR_I18N: DateSelectorI18nConfig = { selectDate: "Select date", apply: "Apply", cancel: "Cancel", clear: "Clear", today: "Today", filterTypes: { is: "is", before: "before", after: "after", between: "between", }, periodTypes: { day: "Day", month: "Month", quarter: "Quarter", halfYear: "Half-year", year: "Year", }, months: [ "January", "February", "March", "April", "May", "June", "July", "August", "September", "October", "November", "December", ], monthsShort: [ "Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec", ], quarters: ["Q1", "Q2", "Q3", "Q4"], halfYears: ["H1", "H2"], weekdays: [ "Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", ], weekdaysShort: ["Su", "Mo", "Tu", "We", "Th", "Fr", "Sa"], placeholder: "Select date...", rangePlaceholder: "Select date range...", } export type DateSelectorPeriodType = | "day" | "month" | "quarter" | "half-year" | "year" export type DateSelectorFilterType = "is" | "before" | "after" | "between" export interface DateSelectorValue { period: DateSelectorPeriodType operator: DateSelectorFilterType startDate?: Date endDate?: Date year?: number month?: number quarter?: number halfYear?: number rangeStart?: { year: number; value: number } rangeEnd?: { year: number; value: number } } export interface DateSelectorContextValue { i18n: DateSelectorI18nConfig variant: "outline" | "default" size: "sm" | "default" | "lg" } const DateSelectorContext = createContext({ i18n: DEFAULT_DATE_SELECTOR_I18N, variant: "outline", size: "default", }) export const useDateSelectorContext = () => useContext(DateSelectorContext) export function formatDateValue( value: DateSelectorValue, i18n: DateSelectorI18nConfig = DEFAULT_DATE_SELECTOR_I18N, dayDateFormat: string = "MM/dd/yyyy" ): string { const { period, startDate, endDate, year, month, quarter, halfYear, rangeStart, rangeEnd, } = value if (period === "day") { if (startDate && endDate) { return `${format(startDate, dayDateFormat)} - ${format(endDate, dayDateFormat)}` } if (startDate) { return format(startDate, dayDateFormat) } return "" } if (period === "month") { if (rangeStart && rangeEnd) { return `${i18n.monthsShort[rangeStart.value]} ${rangeStart.year} - ${i18n.monthsShort[rangeEnd.value]} ${rangeEnd.year}` } if (year !== undefined && month !== undefined) { return `${i18n.monthsShort[month]} ${year}` } return "" } if (period === "quarter") { if (rangeStart && rangeEnd) { return `${i18n.quarters[rangeStart.value]} ${rangeStart.year} - ${i18n.quarters[rangeEnd.value]} ${rangeEnd.year}` } if (year !== undefined && quarter !== undefined) { return `${i18n.quarters[quarter]} ${year}` } return "" } if (period === "half-year") { if (rangeStart && rangeEnd) { return `${i18n.halfYears[rangeStart.value]} ${rangeStart.year} - ${i18n.halfYears[rangeEnd.value]} ${rangeEnd.year}` } if (year !== undefined && halfYear !== undefined) { return `${i18n.halfYears[halfYear]} ${year}` } return "" } if (period === "year") { if (rangeStart && rangeEnd) { return `${rangeStart.year} - ${rangeEnd.year}` } if (year !== undefined) { return `${year}` } return "" } return "" } interface UseDateSelectorOptions { value?: DateSelectorValue onChange?: (value: DateSelectorValue) => void defaultPeriodType?: DateSelectorPeriodType defaultFilterType?: DateSelectorFilterType presetMode?: DateSelectorFilterType allowRange?: boolean yearRange?: number baseYear?: number minYear?: number maxYear?: number periodTypes?: DateSelectorPeriodType[] } export function useDateSelector({ value, onChange, defaultPeriodType = "day", defaultFilterType = "is", presetMode, allowRange = true, yearRange = 11, baseYear, minYear, maxYear, periodTypes, }: UseDateSelectorOptions) { const currentYear = baseYear ?? new Date().getFullYear() const validDefaultPeriodType = useMemo(() => { if (!periodTypes || periodTypes.length === 0) return defaultPeriodType if (periodTypes.includes(defaultPeriodType)) return defaultPeriodType return periodTypes[0] }, [periodTypes, defaultPeriodType]) // Use presetMode if provided, otherwise use value or default const effectiveFilterType = presetMode ?? value?.operator ?? defaultFilterType const [periodType, setPeriodType] = useState( value?.period || validDefaultPeriodType ) const [filterType, setFilterType] = useState(effectiveFilterType) const [selectedDate, setSelectedDate] = useState( value?.startDate ) const [selectedEndDate, setSelectedEndDate] = useState( value?.endDate ) const [calendarMonth, setCalendarMonth] = useState( value?.startDate || new Date() ) const [selectedYear, setSelectedYear] = useState( value?.year ) const [selectedMonth, setSelectedMonth] = useState( value?.month ) const [selectedQuarter, setSelectedQuarter] = useState( value?.quarter ) const [selectedHalfYear, setSelectedHalfYear] = useState( value?.halfYear ) const [rangeStart, setRangeStart] = useState< { year: number; value: number } | undefined >(value?.rangeStart) const [rangeEnd, setRangeEnd] = useState< { year: number; value: number } | undefined >(value?.rangeEnd) const [hoverDate, setHoverDate] = useState() const years = useMemo(() => { if (minYear !== undefined && maxYear !== undefined) { return Array.from( { length: maxYear - minYear + 1 }, (_, i) => minYear + i ) } return Array.from( { length: yearRange }, (_, i) => currentYear - Math.floor(yearRange / 2) + i ) }, [currentYear, yearRange, minYear, maxYear]) const currentValue = useMemo( () => ({ period: periodType, operator: presetMode ?? filterType, startDate: selectedDate, endDate: selectedEndDate, year: selectedYear, month: selectedMonth, quarter: selectedQuarter, halfYear: selectedHalfYear, rangeStart, rangeEnd, }), [ periodType, presetMode, filterType, selectedDate, selectedEndDate, selectedYear, selectedMonth, selectedQuarter, selectedHalfYear, rangeStart, rangeEnd, ] ) const clearSelection = useCallback(() => { setSelectedDate(undefined) setSelectedEndDate(undefined) setSelectedYear(undefined) setSelectedMonth(undefined) setSelectedQuarter(undefined) setSelectedHalfYear(undefined) setRangeStart(undefined) setRangeEnd(undefined) }, []) const handleDayClick = useCallback( (day: Date) => { if (filterType === "between" && allowRange) { if (!selectedDate || (selectedDate && selectedEndDate)) { setSelectedDate(day) setSelectedEndDate(undefined) } else { if (isBefore(day, selectedDate)) { setSelectedEndDate(selectedDate) setSelectedDate(day) } else { setSelectedEndDate(day) } } } else { setSelectedDate(day) setSelectedEndDate(undefined) } }, [filterType, allowRange, selectedDate, selectedEndDate] ) const handlePeriodSelect = useCallback( (year: number, value: number) => { if (filterType === "between" && allowRange) { if (!rangeStart || (rangeStart && rangeEnd)) { setRangeStart({ year, value }) setRangeEnd(undefined) setSelectedYear(year) if (periodType === "month") setSelectedMonth(value) if (periodType === "quarter") setSelectedQuarter(value) if (periodType === "half-year") setSelectedHalfYear(value) } else { const startKey = rangeStart.year * 100 + rangeStart.value const endKey = year * 100 + value if (endKey < startKey) { setRangeEnd(rangeStart) setRangeStart({ year, value }) } else { setRangeEnd({ year, value }) } } } else { setSelectedYear(year) if (periodType === "month") setSelectedMonth(value) if (periodType === "quarter") setSelectedQuarter(value) if (periodType === "half-year") setSelectedHalfYear(value) setRangeStart(undefined) setRangeEnd(undefined) } }, [filterType, allowRange, rangeStart, rangeEnd, periodType] ) const handleYearSelect = useCallback( (year: number) => { if (filterType === "between" && allowRange) { if (!rangeStart || (rangeStart && rangeEnd)) { setRangeStart({ year, value: 0 }) setRangeEnd(undefined) setSelectedYear(year) } else { if (year < rangeStart.year) { setRangeEnd(rangeStart) setRangeStart({ year, value: 0 }) } else { setRangeEnd({ year, value: 0 }) } } } else { setSelectedYear(year) setRangeStart(undefined) setRangeEnd(undefined) } }, [filterType, allowRange, rangeStart, rangeEnd] ) const handlePeriodTypeChange = useCallback( (type: DateSelectorPeriodType) => { setPeriodType(type) clearSelection() }, [clearSelection] ) const handleFilterTypeChange = useCallback( (type: DateSelectorFilterType) => { // Don't allow changes if presetMode is set if (presetMode !== undefined) return setFilterType(type) clearSelection() }, [clearSelection, presetMode] ) const isInRange = useCallback( (year: number, value: number) => { if (!rangeStart || !rangeEnd) return false const key = year * 100 + value const startKey = rangeStart.year * 100 + rangeStart.value const endKey = rangeEnd.year * 100 + rangeEnd.value return key >= startKey && key <= endKey }, [rangeStart, rangeEnd] ) const isYearInRange = useCallback( (year: number) => { if (!rangeStart || !rangeEnd) return false return year >= rangeStart.year && year <= rangeEnd.year }, [rangeStart, rangeEnd] ) useEffect(() => { if (value) { setPeriodType(value.period || validDefaultPeriodType) // Use presetMode if provided, otherwise use value's operator or default const newFilterType = presetMode ?? value.operator ?? defaultFilterType setFilterType(newFilterType) setSelectedDate(value.startDate) setSelectedEndDate(value.endDate) setSelectedYear(value.year) setSelectedMonth(value.month) setSelectedQuarter(value.quarter) setSelectedHalfYear(value.halfYear) setRangeStart(value.rangeStart) setRangeEnd(value.rangeEnd) } }, [value, validDefaultPeriodType, defaultFilterType, presetMode]) // Sync filterType when presetMode changes useEffect(() => { if (presetMode !== undefined) { setFilterType(presetMode) } }, [presetMode]) useEffect(() => { onChange?.(currentValue) }, [currentValue, onChange]) return { // State periodType, filterType, selectedDate, selectedEndDate, calendarMonth, selectedYear, selectedMonth, selectedQuarter, selectedHalfYear, rangeStart, rangeEnd, hoverDate, years, currentValue, allowRange, // Setters setPeriodType: handlePeriodTypeChange, setFilterType: handleFilterTypeChange, setSelectedDate, setSelectedEndDate, setCalendarMonth, setHoverDate, // Actions clearSelection, handleDayClick, handlePeriodSelect, handleYearSelect, isInRange, isYearInRange, } } interface DateSelectorFilterToggleProps { value: DateSelectorFilterType onChange: (value: DateSelectorFilterType) => void showBetween?: boolean showIs?: boolean presetMode?: DateSelectorFilterType className?: string } function DateSelectorFilterToggle({ value, onChange, showBetween = true, showIs = true, presetMode, className, }: DateSelectorFilterToggleProps) { const { i18n } = useDateSelectorContext() const isDisabled = presetMode !== undefined return ( { if (!isDisabled && newValue) { onChange(newValue as DateSelectorFilterType) } }} className={className} > {showIs && ( {i18n.filterTypes.is} )} {i18n.filterTypes.before} {i18n.filterTypes.after} {showBetween && ( {i18n.filterTypes.between} )} ) } interface DateSelectorDateSelectorPeriodTabsProps { value: DateSelectorPeriodType onChange: (value: DateSelectorPeriodType) => void periodTypes?: DateSelectorPeriodType[] className?: string calendarMonth?: Date onMonthChange?: (date: Date) => void showNavigationButtons?: boolean } function DateSelectorPeriodTabs({ value, onChange, periodTypes, className, calendarMonth, onMonthChange, showNavigationButtons = false, }: DateSelectorDateSelectorPeriodTabsProps) { const { i18n } = useDateSelectorContext() const tabs: { value: DateSelectorPeriodType; label: string }[] = [ { value: "day", label: i18n.periodTypes.day }, { value: "month", label: i18n.periodTypes.month }, { value: "quarter", label: i18n.periodTypes.quarter }, { value: "half-year", label: i18n.periodTypes.halfYear }, { value: "year", label: i18n.periodTypes.year }, ] const filteredTabs = periodTypes ? tabs.filter((tab) => periodTypes.includes(tab.value)) : tabs return (
{ if (newValue) { onChange(newValue as DateSelectorPeriodType) } }} > {filteredTabs.map((tab) => ( {tab.label} ))} {showNavigationButtons && value === "day" && calendarMonth && onMonthChange && (
{(() => { const today = new Date() const isCurrentMonth = isSameMonth(calendarMonth, today) // Only show today button if not on current month if (isCurrentMonth) { return null } // Determine direction based on whether calendarMonth is in future or past const isFuture = calendarMonth > today return ( ) })()}
)}
) } interface DateSelectorDayPickerProps { currentMonth: Date selectedDate?: Date selectedEndDate?: Date onDayClick: (day: Date) => void isRange: boolean onDayHover?: (day: Date | undefined) => void hoverDate?: Date showTwoMonths?: boolean weekStartsOn?: 0 | 1 | 2 | 3 | 4 | 5 | 6 className?: string } function DateSelectorDayPicker({ currentMonth, selectedDate, selectedEndDate, onDayClick, isRange, onDayHover, hoverDate, showTwoMonths = true, weekStartsOn, className, }: DateSelectorDayPickerProps) { const { i18n } = useDateSelectorContext() const isMobile = useIsMobile() // Convert to react-day-picker format const selected: Date | DateRange | undefined = isRange ? selectedDate && selectedEndDate ? { from: selectedDate, to: selectedEndDate } : selectedDate ? { from: selectedDate, to: hoverDate || selectedDate } : undefined : selectedDate const handleSelect = (date: Date | DateRange | undefined) => { if (!date) { return } if (isRange && "from" in date) { // For range mode if (date.from && !date.to) { // First click - set start date onDayClick(date.from) } else if (date.from && date.to) { // Range selected - set end date onDayClick(date.to) } } else if (!isRange && date instanceof Date) { onDayClick(date) } } // Create custom DayButton component with hover support const CustomDayButton = useCallback( (props: ComponentProps) => { return ( { if (isRange && onDayHover && props.day) { onDayHover(props.day.date) } }} onMouseLeave={() => { if (isRange && onDayHover) { onDayHover(undefined) } }} /> ) }, [isRange, onDayHover] ) // Create custom formatters for i18n const formatters = { formatWeekdayName: (date: Date) => { const dayIndex = date.getDay() return i18n.weekdaysShort[dayIndex] || i18n.weekdays[dayIndex] }, formatMonthCaption: (date: Date) => { const monthIndex = date.getMonth() const year = date.getFullYear() return `${i18n.months[monthIndex]} ${year}` }, } return (
{isRange ? ( void} numberOfMonths={isMobile ? 1 : showTwoMonths ? 2 : 1} showOutsideDays={true} weekStartsOn={weekStartsOn} formatters={formatters} className="w-full shrink-0 p-0" classNames={{ months: "flex flex-wrap items-start justify-between gap-5 w-full", month: "flex flex-col items-center min-w-0 flex-1", nav: "hidden", }} components={{ DayButton: CustomDayButton, }} /> ) : ( void} numberOfMonths={isMobile ? 1 : showTwoMonths ? 2 : 1} showOutsideDays={true} weekStartsOn={weekStartsOn} formatters={formatters} className="w-full shrink-0 p-0" classNames={{ months: "flex flex-wrap items-start justify-between gap-5 w-full", month: "flex flex-col items-center min-w-0 flex-1", nav: "hidden", }} components={{ DayButton: CustomDayButton, }} /> )}
) } interface DateSelectorDateSelectorPeriodGridProps { years: number[] items: string[] selectedYear?: number selectedValue?: number rangeStart?: { year: number; value: number } rangeEnd?: { year: number; value: number } isInRange: (year: number, value: number) => boolean onSelect: (year: number, value: number) => void columns: number className?: string } function DateSelectorPeriodGrid({ years, items, selectedYear, selectedValue, rangeStart, rangeEnd, isInRange, onSelect, columns, className, }: DateSelectorDateSelectorPeriodGridProps) { return (
{years.map((year) => (
{year}
{items.map((item, index) => { const isSelected = selectedYear === year && selectedValue === index const isRangeStart = rangeStart?.year === year && rangeStart?.value === index const isRangeEnd = rangeEnd?.year === year && rangeEnd?.value === index const inRange = isInRange(year, index) return ( ) })}
))}
) } interface DateSelectorYearListProps { years: number[] selectedYear?: number rangeStart?: { year: number; value: number } rangeEnd?: { year: number; value: number } isYearInRange: (year: number) => boolean onSelect: (year: number) => void className?: string } function DateSelectorYearList({ years, selectedYear, rangeStart, rangeEnd, isYearInRange, onSelect, className, }: DateSelectorYearListProps) { return (
{years.map((year) => { const isSelected = selectedYear === year && !rangeStart && !rangeEnd const isRangeStart = rangeStart?.year === year const isRangeEnd = rangeEnd?.year === year const inRange = isYearInRange(year) return ( ) })}
) } export interface DateSelectorProps { value?: DateSelectorValue onChange?: (value: DateSelectorValue) => void allowRange?: boolean periodTypes?: DateSelectorPeriodType[] defaultPeriodType?: DateSelectorPeriodType defaultFilterType?: DateSelectorFilterType presetMode?: DateSelectorFilterType showInput?: boolean showTwoMonths?: boolean label?: string className?: string yearRange?: number baseYear?: number minYear?: number maxYear?: number i18n?: Partial inputHint?: string dayDateFormat?: string dayDateFormats?: string[] weekStartsOn?: 0 | 1 | 2 | 3 | 4 | 5 | 6 } export function DateSelector({ value, onChange, allowRange = true, periodTypes, defaultPeriodType = "day", defaultFilterType = "is", presetMode, showInput = true, showTwoMonths = true, label, className, yearRange = 10, baseYear, minYear = 2015, maxYear = 2026, i18n: i18nOverride, inputHint, dayDateFormat = "MM/dd/yyyy", dayDateFormats, weekStartsOn, }: DateSelectorProps) { const mergedI18n = useMemo( () => ({ ...DEFAULT_DATE_SELECTOR_I18N, ...i18nOverride }), [i18nOverride] ) const selector = useDateSelector({ value, onChange, defaultPeriodType, defaultFilterType, presetMode, allowRange, yearRange, baseYear, minYear, maxYear, periodTypes, }) const { periodType, filterType, selectedDate, selectedEndDate, calendarMonth, selectedYear, selectedMonth, selectedQuarter, selectedHalfYear, rangeStart, rangeEnd, hoverDate, years, currentValue, setPeriodType, setFilterType, setCalendarMonth, setHoverDate, clearSelection, handleDayClick, handlePeriodSelect, handleYearSelect, isInRange, isYearInRange, } = selector const displayValue = formatDateValue(currentValue, mergedI18n, dayDateFormat) const [inputValue, setInputValue] = useState(displayValue) const [isInputFocused, setIsInputFocused] = useState(false) // Sync input value when displayValue changes (but not when user is typing) useEffect(() => { if (!isInputFocused) { setInputValue(displayValue) } }, [displayValue, isInputFocused]) // Compute date formats for parsing const dateFormats = useMemo(() => { if (dayDateFormats && dayDateFormats.length > 0) { // Use provided formats, with dayDateFormat first if not already included const formats = [...dayDateFormats] if (!formats.includes(dayDateFormat)) { formats.unshift(dayDateFormat) } return formats } // Default formats: use dayDateFormat first, then common alternatives const defaultFormats = [ dayDateFormat, "dd/MM/yyyy", "yyyy-MM-dd", "MM-dd-yyyy", "dd-MM-yyyy", ] // Remove duplicates while preserving order return Array.from(new Set(defaultFormats)) }, [dayDateFormat, dayDateFormats]) // Parse input text to DateSelectorValue const parseInputValue = useCallback( (text: string): DateSelectorValue | null => { if (!text.trim()) return null const trimmed = text.trim() // Try parsing as year (e.g., "2025") const yearMatch = trimmed.match(/^\d{4}$/) if (yearMatch) { const year = parseInt(yearMatch[0]) if (year >= 1900 && year <= 2100) { return { period: "year", operator: presetMode ?? filterType, year, } } } // Try parsing as quarter (e.g., "Q4", "Q1 2025") const quarterMatch = trimmed.match(/^Q([1-4])(?:\s+(\d{4}))?$/i) if (quarterMatch) { const quarter = parseInt(quarterMatch[1]) - 1 const year = quarterMatch[2] ? parseInt(quarterMatch[2]) : new Date().getFullYear() if (year >= 1900 && year <= 2100) { return { period: "quarter", operator: presetMode ?? filterType, year, quarter, } } } // Try parsing as date using computed formats for (const dateFormat of dateFormats) { try { const parsed = parse(trimmed, dateFormat, new Date()) if (!isNaN(parsed.getTime())) { return { period: "day", operator: presetMode ?? filterType, startDate: parsed, } } } catch { // Continue to next format } } return null }, [filterType, presetMode, dateFormats] ) const handleInputChange = useCallback( (e: ChangeEvent) => { const newValue = e.target.value setInputValue(newValue) // Try to parse the input const parsed = parseInputValue(newValue) if (parsed) { onChange?.(parsed) } }, [onChange, parseInputValue] ) const handleInputBlur = useCallback(() => { setIsInputFocused(false) // Reset to display value if parsing failed if (!parseInputValue(inputValue)) { setInputValue(displayValue) } }, [inputValue, displayValue, parseInputValue]) return (
{label && (

{label}

)}
{showInput && (
setIsInputFocused(true)} onBlur={handleInputBlur} onChange={handleInputChange} /> {(inputHint ? inputValue : displayValue) && ( )}
)} {periodType === "day" ? (
) : (
{periodType === "month" && ( )} {periodType === "quarter" && ( )} {periodType === "half-year" && ( )} {periodType === "year" && ( )}
)}
) }