// components/ui/calendar.tsx import * as React from 'react'; import { View, Text, Pressable, ScrollView } from 'react-native'; import { cn } from '@/lib/utils'; import dayjs, { Dayjs } from 'dayjs'; import isBetween from 'dayjs/plugin/isBetween.js'; dayjs.extend(isBetween); type CalendarMode = 'date' | 'month' | 'year'; type CalendarVariant = 'date' | 'month' | 'year'; interface CalendarProps { value?: Date; onValueChange?: (date: Date) => void; variant?: CalendarVariant; minDate?: Date; maxDate?: Date; className?: string; // DONE: Range mode props mode?: 'single' | 'range'; startDate?: Date; endDate?: Date; onRangeChange?: (startDate: Date | undefined, endDate: Date | undefined) => void; maxDays?: number; } export function Calendar({ value, onValueChange, variant = 'date', minDate, maxDate, className, mode = 'single', startDate, endDate, onRangeChange, maxDays, }: CalendarProps) { const [currentDate, setCurrentDate] = React.useState(() => value || startDate ? dayjs(value || startDate) : dayjs() ); const [calendarMode, setCalendarMode] = React.useState(variant); // DONE: Range selection state const [tempStartDate, setTempStartDate] = React.useState( startDate ? dayjs(startDate) : undefined ); const [tempEndDate, setTempEndDate] = React.useState( endDate ? dayjs(endDate) : undefined ); React.useEffect(() => { setCalendarMode(variant); }, [variant]); const handleDateSelect = (date: Dayjs) => { if (mode === 'range') { // Range selection logic if (!tempStartDate || (tempStartDate && tempEndDate)) { setTempStartDate(date); setTempEndDate(undefined); } else { if (date.isBefore(tempStartDate)) { setTempStartDate(date); setTempEndDate(tempStartDate); } else { if (maxDays) { const daysDiff = date.diff(tempStartDate, 'day'); if (daysDiff > maxDays) return; } setTempEndDate(date); } // Call range change callback const newStart = tempStartDate; const newEnd = date.isBefore(tempStartDate) ? tempStartDate : date; onRangeChange?.(newStart.toDate(), newEnd.toDate()); } } else { // Single selection logic onValueChange?.(date.toDate()); } }; const handleMonthSelect = (monthIndex: number) => { const newDate = currentDate.month(monthIndex); setCurrentDate(newDate); if (variant === 'month') { if (mode === 'single') { onValueChange?.(newDate.toDate()); } } else { setCalendarMode('date'); } }; const handleYearSelect = (year: number) => { const newDate = currentDate.year(year); setCurrentDate(newDate); if (variant === 'year') { if (mode === 'single') { onValueChange?.(newDate.toDate()); } } else if (variant === 'month') { setCalendarMode('month'); } else { setCalendarMode('month'); } }; const handlePrevious = () => { if (calendarMode === 'date') { setCurrentDate(currentDate.subtract(1, 'month')); } else if (calendarMode === 'month') { setCurrentDate(currentDate.subtract(1, 'year')); } else { setCurrentDate(currentDate.subtract(12, 'year')); } }; const handleNext = () => { if (calendarMode === 'date') { setCurrentDate(currentDate.add(1, 'month')); } else if (calendarMode === 'month') { setCurrentDate(currentDate.add(1, 'year')); } else { setCurrentDate(currentDate.add(12, 'year')); } }; return ( {/* Header */} ← {calendarMode === 'date' && ( <> setCalendarMode('month')}> {currentDate.format('MMMM')} setCalendarMode('year')}> {currentDate.format('YYYY')} )} {calendarMode === 'month' && ( setCalendarMode('year')}> {currentDate.format('YYYY')} )} {calendarMode === 'year' && ( {currentDate.year() - 6} - {currentDate.year() + 5} )} → {/* Content */} {calendarMode === 'date' && ( )} {calendarMode === 'month' && ( )} {calendarMode === 'year' && ( )} ); } // Date Grid (updated with range support) function DateGrid({ currentDate, selectedDate, onSelect, minDate, maxDate, mode = 'single', startDate, endDate, maxDays, }: { currentDate: Dayjs; selectedDate?: Dayjs; onSelect: (date: Dayjs) => void; minDate?: Date; maxDate?: Date; mode?: 'single' | 'range'; startDate?: Dayjs; endDate?: Dayjs; maxDays?: number; }) { const generateCalendarDays = () => { const year = currentDate.year(); const month = currentDate.month(); const firstDay = new Date(year, month, 1, 12, 0, 0); const lastDay = new Date(year, month + 1, 0, 12, 0, 0); const startDayOfWeek = firstDay.getDay(); const daysInMonth = lastDay.getDate(); const startDateCalc = new Date(year, month, 1 - startDayOfWeek, 12, 0, 0); const totalDays = startDayOfWeek + daysInMonth; const weeksNeeded = Math.ceil(totalDays / 7); const totalCells = weeksNeeded * 7; const days: Dayjs[] = []; for (let i = 0; i < totalCells; i++) { const date = new Date(startDateCalc); date.setDate(startDateCalc.getDate() + i); days.push(dayjs(date)); } return days; }; const days = generateCalendarDays(); const weekDays = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']; const isDisabled = (date: Dayjs) => { if (minDate && date.isBefore(dayjs(minDate), 'day')) return true; if (maxDate && date.isAfter(dayjs(maxDate), 'day')) return true; if (mode === 'range' && startDate && !endDate && maxDays) { const daysDiff = Math.abs(date.diff(startDate, 'day')); if (daysDiff > maxDays) return true; } return false; }; const rows: Dayjs[][] = []; for (let i = 0; i < days.length; i += 7) { rows.push(days.slice(i, i + 7)); } return ( {weekDays.map((day, index) => ( {day} ))} {rows.map((week, weekIndex) => ( {week.map((day, dayIndex) => { const isCurrentMonth = day.month() === currentDate.month(); const isToday = day.isSame(dayjs(), 'day'); const disabled = isDisabled(day); // Range mode checks const isStart = mode === 'range' && startDate && day.isSame(startDate, 'day'); const isEnd = mode === 'range' && endDate && day.isSame(endDate, 'day'); const isInRange = mode === 'range' && startDate && endDate && day.isBetween(startDate, endDate, 'day', '[]'); // Single mode checks const isSelected = mode === 'single' && selectedDate && day.isSame(selectedDate, 'day'); return ( !disabled && onSelect(day)} disabled={disabled} className={cn( 'aspect-square items-center justify-center rounded-lg', isSelected && 'bg-blue-600', isStart && 'bg-blue-600', isEnd && 'bg-blue-600', isInRange && !isStart && !isEnd && 'bg-blue-100', !isSelected && !isStart && !isEnd && !isInRange && isToday && 'border border-blue-600', !isSelected && !isStart && !isEnd && !isInRange && !isToday && isCurrentMonth && 'bg-slate-50', disabled && 'opacity-30', 'web:min-w-10 web:min-h-10' )} > {day.format('D')} ); })} ))} ); } // MonthGrid & YearGrid stay the same... function MonthGrid({ currentDate, selectedDate, onSelect, minDate, maxDate, }: { currentDate: Dayjs; selectedDate?: Dayjs; onSelect: (monthIndex: number) => void; minDate?: Date; maxDate?: Date; }) { const months = Array.from({ length: 12 }, (_, i) => i); const isDisabled = (monthIndex: number) => { const date = currentDate.month(monthIndex); if (minDate && date.isBefore(dayjs(minDate), 'month')) return true; if (maxDate && date.isAfter(dayjs(maxDate), 'month')) return true; return false; }; return ( {months.map((monthIndex) => { const isSelected = selectedDate && monthIndex === selectedDate.month() && currentDate.year() === selectedDate.year(); const disabled = isDisabled(monthIndex); return ( !disabled && onSelect(monthIndex)} disabled={disabled} className={cn( 'py-4 items-center justify-center rounded-lg', isSelected && 'bg-blue-600', !isSelected && 'bg-slate-50', disabled && 'opacity-30' )} > {dayjs().month(monthIndex).format('MMM')} ); })} ); } function YearGrid({ currentDate, selectedDate, onSelect, minDate, maxDate, }: { currentDate: Dayjs; selectedDate?: Dayjs; onSelect: (year: number) => void; minDate?: Date; maxDate?: Date; }) { const currentYear = currentDate.year(); const startYear = currentYear - 6; const years = Array.from({ length: 12 }, (_, i) => startYear + i); const isDisabled = (year: number) => { const date = currentDate.year(year); if (minDate && date.isBefore(dayjs(minDate), 'year')) return true; if (maxDate && date.isAfter(dayjs(maxDate), 'year')) return true; return false; }; return ( {years.map((year) => { const isSelected = selectedDate && year === selectedDate.year(); const disabled = isDisabled(year); return ( !disabled && onSelect(year)} disabled={disabled} className={cn( 'py-4 items-center justify-center rounded-lg', isSelected && 'bg-blue-600', !isSelected && 'bg-slate-50', disabled && 'opacity-30' )} > {year} ); })} ); }