| 1 | "use client"; |
| 2 | |
| 3 | import { |
| 4 | Popover, |
| 5 | PopoverContent, |
| 6 | PopoverTrigger, |
| 7 | } from "@/components/ui/popover"; |
| 8 | import { ScrollArea } from "@/components/ui/scroll-area"; |
| 9 | import { cn } from "@/lib/utils"; |
| 10 | import { format } from "date-fns"; |
| 11 | import { CalendarIcon } from "lucide-react"; |
| 12 | import { useRef, useState } from "react"; |
| 13 | import { Button } from "./button"; |
| 14 | import { Calendar } from "./calendar"; |
| 15 | |
| 16 | interface DateTimePickerProps { |
| 17 | date: Date | null; |
| 18 | onDateChange: (date: Date | null) => void; |
| 19 | className?: string; |
| 20 | } |
| 21 | |
| 22 | export function DateTimePicker({ |
| 23 | date, |
| 24 | onDateChange, |
| 25 | className, |
| 26 | }: DateTimePickerProps) { |
| 27 | const [isOpen, setIsOpen] = useState(false); |
| 28 | const [selectedDate, setSelectedDate] = useState<Date | null>(date); |
| 29 | const calendarRef = useRef<HTMLDivElement | null>(null); |
| 30 | |
| 31 | // Format time as 24-hour format for internal calculations |
| 32 | const getTimeString = (date: Date | null) => { |
| 33 | if (!date) return "00:00"; |
| 34 | const hours = date.getHours().toString().padStart(2, "0"); |
| 35 | const minutes = date.getMinutes().toString().padStart(2, "0"); |
| 36 | return `${hours}:${minutes}`; |
| 37 | }; |
| 38 | |
| 39 | const [time, setTime] = useState<string>(() => { |
| 40 | if (date) { |
| 41 | return getTimeString(date); |
| 42 | } |
| 43 | // Default to current hour, rounded to nearest 15 minutes |
| 44 | const now = new Date(); |
| 45 | const minutes = Math.ceil(now.getMinutes() / 15) * 15; |
| 46 | now.setMinutes(minutes === 60 ? 0 : minutes, 0, 0); |
| 47 | if (minutes === 60) { |
| 48 | now.setHours(now.getHours() + 1); |
| 49 | } |
| 50 | return getTimeString(now); |
| 51 | }); |
| 52 | |
| 53 | const handleDateSelect = (selected: Date | undefined) => { |
| 54 | if (!selected) return; |
| 55 | |
| 56 | setSelectedDate(selected); |
| 57 | |
| 58 | // Apply the selected time to the date |
| 59 | const [hours, minutes] = time.split(":"); |
| 60 | selected.setHours(parseInt(hours ?? "0", 10), parseInt(minutes ?? "0", 10)); |
| 61 | |
| 62 | onDateChange(selected); |
| 63 | }; |
| 64 | |
| 65 | const handleTimeSelect = (timeValue: string) => { |
| 66 | setTime(timeValue); |
| 67 | |
| 68 | if (selectedDate) { |
| 69 | const [hours, minutes] = timeValue.split(":"); |
| 70 | const newDate = new Date(selectedDate.getTime()); |
| 71 | newDate.setHours( |
| 72 | parseInt(hours ?? "0", 10), |
| 73 | parseInt(minutes ?? "0", 10), |
| 74 | ); |
| 75 | |
| 76 | setSelectedDate(newDate); |
| 77 | onDateChange(newDate); |
| 78 | } |
| 79 | setIsOpen(false); |
| 80 | }; |
| 81 | |
| 82 | return ( |
| 83 | <div className={cn("flex w-full", className)}> |
| 84 | <Popover open={isOpen} onOpenChange={setIsOpen}> |
| 85 | <PopoverTrigger asChild> |
| 86 | <Button |
| 87 | variant={"outline"} |
| 88 | className={cn( |
| 89 | "w-full font-normal", |
| 90 | !date && "text-muted-foreground", |
| 91 | )} |
| 92 | > |
| 93 | {date ? ( |
| 94 | `${format(date, "PPP")}, ${format(date, "p")}` |
| 95 | ) : ( |
| 96 | <span>Pick a date</span> |
| 97 | )} |
| 98 | <CalendarIcon className="ml-auto h-4 w-4 opacity-50" /> |
| 99 | </Button> |
| 100 | </PopoverTrigger> |
| 101 | <PopoverContent className="flex w-auto items-start p-0" align="start"> |
| 102 | <div ref={calendarRef}> |
| 103 | <Calendar |
| 104 | mode="single" |
| 105 | selected={selectedDate ?? undefined} |
| 106 | onSelect={handleDateSelect} |
| 107 | fromYear={2000} |
| 108 | toYear={new Date().getFullYear() + 10} |
| 109 | defaultMonth={date ?? undefined} |
| 110 | /> |
| 111 | </div> |
| 112 | <div className="my-4 mr-2 w-[120px]"> |
| 113 | <ScrollArea |
| 114 | className="h-72 pr-2.5" |
| 115 | onWheel={(e) => { |
| 116 | e.stopPropagation(); |
| 117 | }} |
| 118 | > |
| 119 | <div className="flex h-max flex-col gap-2"> |
| 120 | {Array.from({ length: 96 }).map((_, i) => { |
| 121 | const hour = Math.floor(i / 4) |
| 122 | .toString() |
| 123 | .padStart(2, "0"); |
| 124 | const minute = ((i % 4) * 15).toString().padStart(2, "0"); |
| 125 | const timeValue = `${hour}:${minute}`; |
| 126 | const displayTime = new Date(); |
| 127 | displayTime.setHours( |
| 128 | parseInt(hour, 10), |
| 129 | parseInt(minute, 10), |
| 130 | ); |
| 131 | |
| 132 | return ( |
| 133 | <Button |
| 134 | key={i} |
| 135 | className="w-full px-2 text-left" |
| 136 | variant="outline" |
| 137 | onClick={() => handleTimeSelect(timeValue)} |
| 138 | > |
| 139 | {format(displayTime, "p")} |
| 140 | </Button> |
| 141 | ); |
| 142 | })} |
| 143 | </div> |
| 144 | </ScrollArea> |
| 145 | </div> |
| 146 | </PopoverContent> |
| 147 | </Popover> |
| 148 | </div> |
| 149 | ); |
| 150 | } |
| 151 |