418 lines
17 KiB
TypeScript
418 lines
17 KiB
TypeScript
|
|
|
|
import type React from "react"
|
|
|
|
import { useEffect, useMemo, useState, useCallback, useRef } from "react"
|
|
import { ChevronLeft, ChevronRight, Clock } from "lucide-react"
|
|
import { DayPicker } from "react-day-picker"
|
|
import { useVirtualizer } from "@tanstack/react-virtual"
|
|
|
|
import { cn } from "@/lib/utils"
|
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/app/_components/ui/select"
|
|
import { Button } from "@/app/_components/ui/button"
|
|
|
|
// Custom calendar component with enhanced year and month navigation
|
|
export function DateTimePicker({
|
|
selected,
|
|
onSelect,
|
|
disabled,
|
|
fromYear = 1900,
|
|
toYear = new Date().getFullYear() + 10,
|
|
showTimePicker = true,
|
|
className,
|
|
minuteStep = 1,
|
|
showSeconds = true,
|
|
}: {
|
|
selected?: Date
|
|
onSelect: (date?: Date) => void
|
|
disabled?: (date: Date) => boolean
|
|
fromYear?: number
|
|
toYear?: number
|
|
showTimePicker?: boolean
|
|
className?: string
|
|
minuteStep?: number
|
|
showSeconds?: boolean
|
|
}) {
|
|
// Initialize with selected date or current date
|
|
const [date, setDate] = useState<Date>(() => {
|
|
return selected ? new Date(selected) : new Date()
|
|
})
|
|
|
|
const [hours, setHours] = useState<string>(() => {
|
|
return selected ? String(selected.getHours()).padStart(2, "0") : String(new Date().getHours()).padStart(2, "0")
|
|
})
|
|
|
|
const [minutes, setMinutes] = useState<string>(() => {
|
|
if (selected) {
|
|
return String(selected.getMinutes()).padStart(2, "0")
|
|
}
|
|
// Round current minutes to nearest step
|
|
const currentMinutes = new Date().getMinutes()
|
|
const roundedMinutes = Math.round(currentMinutes / minuteStep) * minuteStep
|
|
return String(roundedMinutes % 60).padStart(2, "0")
|
|
})
|
|
|
|
const [seconds, setSeconds] = useState<string>(() => {
|
|
return selected ? String(selected.getSeconds()).padStart(2, "0") : String(0).padStart(2, "0")
|
|
})
|
|
|
|
// Track if we're in the middle of an update to prevent loops
|
|
const isUpdatingRef = useRef(false)
|
|
|
|
// Generate valid minute options based on minuteStep
|
|
const minuteOptions = useMemo(() => {
|
|
const options = []
|
|
for (let i = 0; i < 60; i += minuteStep) {
|
|
options.push(String(i).padStart(2, "0"))
|
|
}
|
|
return options
|
|
}, [minuteStep])
|
|
|
|
// Generate valid hour options
|
|
const hourOptions = useMemo(() => {
|
|
return Array.from({ length: 24 }, (_, i) => String(i).padStart(2, "0"))
|
|
}, [])
|
|
|
|
// Generate valid second options
|
|
const secondOptions = useMemo(() => {
|
|
return Array.from({ length: 60 }, (_, i) => String(i).padStart(2, "0"))
|
|
}, [])
|
|
|
|
// Update the parent component when date or time changes
|
|
useEffect(() => {
|
|
if (isUpdatingRef.current) return
|
|
|
|
if (date) {
|
|
const newDate = new Date(date)
|
|
const newHours = Number.parseInt(hours, 10)
|
|
const newMinutes = Number.parseInt(minutes, 10)
|
|
const newSeconds = Number.parseInt(seconds, 10)
|
|
|
|
newDate.setHours(newHours, newMinutes, newSeconds, 0)
|
|
|
|
// Only call onSelect if the date actually changed
|
|
if (!selected || Math.abs(newDate.getTime() - (selected?.getTime() || 0)) > 100) {
|
|
isUpdatingRef.current = true
|
|
onSelect(newDate)
|
|
// Use requestAnimationFrame instead of setTimeout for better performance
|
|
requestAnimationFrame(() => {
|
|
isUpdatingRef.current = false
|
|
})
|
|
}
|
|
} else {
|
|
onSelect(undefined)
|
|
}
|
|
}, [date, hours, minutes, seconds, onSelect, selected])
|
|
|
|
// Update internal state when selected prop changes
|
|
useEffect(() => {
|
|
if (isUpdatingRef.current) return
|
|
|
|
if (selected) {
|
|
setDate(new Date(selected))
|
|
setHours(String(selected.getHours()).padStart(2, "0"))
|
|
setMinutes(String(selected.getMinutes()).padStart(2, "0"))
|
|
setSeconds(String(selected.getSeconds()).padStart(2, "0"))
|
|
}
|
|
}, [selected])
|
|
|
|
// Generate years array from fromYear to toYear
|
|
const years = useMemo(() => {
|
|
const yearsArray = []
|
|
for (let i = toYear; i >= fromYear; i--) {
|
|
yearsArray.push(i)
|
|
}
|
|
return yearsArray
|
|
}, [fromYear, toYear])
|
|
|
|
const months = useMemo(
|
|
() => [
|
|
"January",
|
|
"February",
|
|
"March",
|
|
"April",
|
|
"May",
|
|
"June",
|
|
"July",
|
|
"August",
|
|
"September",
|
|
"October",
|
|
"November",
|
|
"December",
|
|
],
|
|
[],
|
|
)
|
|
|
|
// Handle time input changes
|
|
const handleHoursChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
|
|
let value = e.target.value.replace(/\D/g, "")
|
|
if (value === "") {
|
|
setHours("00")
|
|
return
|
|
}
|
|
|
|
const numValue = Number.parseInt(value, 10)
|
|
if (numValue > 23) {
|
|
value = "23"
|
|
}
|
|
setHours(value.padStart(2, "0"))
|
|
}, [])
|
|
|
|
const handleMinutesChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
|
|
let value = e.target.value.replace(/\D/g, "")
|
|
if (value === "") {
|
|
setMinutes("00")
|
|
return
|
|
}
|
|
|
|
const numValue = Number.parseInt(value, 10)
|
|
if (numValue > 59) {
|
|
value = "59"
|
|
}
|
|
setMinutes(value.padStart(2, "0"))
|
|
}, [])
|
|
|
|
const handleSecondsChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
|
|
let value = e.target.value.replace(/\D/g, "")
|
|
if (value === "") {
|
|
setSeconds("00")
|
|
return
|
|
}
|
|
|
|
const numValue = Number.parseInt(value, 10)
|
|
if (numValue > 59) {
|
|
value = "59"
|
|
}
|
|
setSeconds(value.padStart(2, "0"))
|
|
}, [])
|
|
|
|
// Clear date selection
|
|
const handleClear = useCallback(() => {
|
|
const now = new Date()
|
|
setDate(now)
|
|
setHours("00")
|
|
setMinutes("00")
|
|
setSeconds("00")
|
|
onSelect(new Date(now.getFullYear(), now.getMonth(), now.getDate(), 0, 0, 0, 0))
|
|
}, [onSelect])
|
|
|
|
// Set date to now
|
|
const handleSetNow = useCallback(() => {
|
|
const now = new Date()
|
|
setDate(now)
|
|
setHours(String(now.getHours()).padStart(2, "0"))
|
|
const roundedMinutes = Math.round(now.getMinutes() / minuteStep) * minuteStep
|
|
setMinutes(String(roundedMinutes % 60).padStart(2, "0"))
|
|
setSeconds(String(now.getSeconds()).padStart(2, "0"))
|
|
onSelect(now)
|
|
}, [minuteStep, onSelect])
|
|
|
|
// Custom caption component with optimized year selection
|
|
const CustomCaption = useCallback(
|
|
({ displayMonth }: { displayMonth: Date }) => {
|
|
const month = displayMonth.getMonth()
|
|
const year = displayMonth.getFullYear()
|
|
const yearListRef = useRef<HTMLDivElement>(null)
|
|
|
|
const handleMonthChange = useCallback(
|
|
(newMonth: string) => {
|
|
const monthIndex = months.findIndex((m) => m === newMonth)
|
|
const newDate = new Date(date)
|
|
newDate.setMonth(monthIndex)
|
|
setDate(newDate)
|
|
},
|
|
[date],
|
|
)
|
|
|
|
const handleYearChange = useCallback(
|
|
(newYear: string) => {
|
|
const newDate = new Date(date)
|
|
newDate.setFullYear(Number.parseInt(newYear))
|
|
setDate(newDate)
|
|
},
|
|
[date],
|
|
)
|
|
|
|
// Create a virtualizer for the years list
|
|
const virtualizer = useVirtualizer({
|
|
count: years.length,
|
|
getScrollElement: () => yearListRef.current,
|
|
estimateSize: () => 36, // Approximate height of each year item
|
|
overscan: 5, // Reduced overscan for better performance
|
|
})
|
|
|
|
// Pre-scroll to current year when the select opens
|
|
const handleYearSelectOpen = useCallback(() => {
|
|
const yearIndex = years.findIndex((y) => y === year)
|
|
if (yearIndex !== -1 && yearListRef.current) {
|
|
// Use requestAnimationFrame for smoother scrolling
|
|
requestAnimationFrame(() => {
|
|
virtualizer.scrollToIndex(yearIndex, { align: "center" })
|
|
})
|
|
}
|
|
}, [virtualizer, years, year])
|
|
|
|
return (
|
|
<div className="flex items-center justify-center gap-2 py-2">
|
|
<Select value={months[month]} onValueChange={handleMonthChange}>
|
|
<SelectTrigger className="h-8 w-[110px] text-sm">
|
|
<SelectValue placeholder={months[month]} />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
{months.map((monthName) => (
|
|
<SelectItem key={monthName} value={monthName} className="text-sm">
|
|
{monthName}
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
|
|
<Select
|
|
value={year.toString()}
|
|
onValueChange={handleYearChange}
|
|
onOpenChange={(open) => open && handleYearSelectOpen()}
|
|
>
|
|
<SelectTrigger className="h-8 w-[90px] text-sm">
|
|
<SelectValue placeholder={year.toString()} />
|
|
</SelectTrigger>
|
|
<SelectContent
|
|
ref={yearListRef}
|
|
className="h-[200px] overflow-auto"
|
|
onCloseAutoFocus={(e) => e.preventDefault()}
|
|
>
|
|
<div
|
|
style={{
|
|
height: `${virtualizer.getTotalSize()}px`,
|
|
width: "100%",
|
|
position: "relative",
|
|
}}
|
|
>
|
|
{virtualizer.getVirtualItems().map((virtualItem) => (
|
|
<SelectItem
|
|
key={years[virtualItem.index]}
|
|
value={years[virtualItem.index].toString()}
|
|
className="text-sm absolute top-0 left-0 w-full"
|
|
style={{
|
|
height: `${virtualItem.size}px`,
|
|
transform: `translateY(${virtualItem.start}px)`,
|
|
}}
|
|
>
|
|
{years[virtualItem.index]}
|
|
</SelectItem>
|
|
))}
|
|
</div>
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
)
|
|
},
|
|
[date, months, years],
|
|
)
|
|
|
|
return (
|
|
<div className={cn("space-y-4 p-2 border rounded-md shadow-sm", className)}>
|
|
<DayPicker
|
|
mode="single"
|
|
selected={date}
|
|
onSelect={(newDate) => newDate && setDate(newDate)}
|
|
disabled={disabled}
|
|
month={date}
|
|
onMonthChange={setDate}
|
|
components={{
|
|
IconLeft: () => <ChevronLeft className="h-4 w-4" />,
|
|
IconRight: () => <ChevronRight className="h-4 w-4" />,
|
|
Caption: CustomCaption,
|
|
}}
|
|
classNames={{
|
|
caption: "flex justify-center relative items-center",
|
|
nav: "space-x-1 flex items-center",
|
|
nav_button: cn(
|
|
"h-7 w-7 bg-transparent p-0 opacity-50 hover:opacity-100 hover:bg-muted rounded-md transition-colors",
|
|
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
|
|
),
|
|
nav_button_previous: "absolute left-1",
|
|
nav_button_next: "absolute right-1",
|
|
table: "w-full border-collapse space-y-1",
|
|
head_row: "flex",
|
|
head_cell: "text-muted-foreground rounded-md w-9 font-normal text-[0.8rem]",
|
|
row: "flex w-full mt-2",
|
|
cell: "h-9 w-9 text-center text-sm p-0 relative [&:has([aria-selected])]:bg-accent first:[&:has([aria-selected])]:rounded-l-md last:[&:has([aria-selected])]:rounded-r-md focus-within:relative focus-within:z-20",
|
|
day: cn(
|
|
"h-9 w-9 p-0 font-normal aria-selected:opacity-100 hover:bg-accent hover:text-accent-foreground rounded-md transition-colors",
|
|
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
|
|
),
|
|
day_selected:
|
|
"bg-primary text-primary-foreground hover:bg-primary hover:text-primary-foreground focus:bg-primary focus:text-primary-foreground",
|
|
day_today: "bg-accent text-accent-foreground",
|
|
day_outside: "text-muted-foreground opacity-50",
|
|
day_disabled: "text-muted-foreground opacity-50",
|
|
day_range_middle: "aria-selected:bg-accent aria-selected:text-accent-foreground",
|
|
day_hidden: "invisible",
|
|
}}
|
|
/>
|
|
|
|
{showTimePicker && (
|
|
<div className="border-t pt-3">
|
|
<div className="flex items-center justify-center space-x-1">
|
|
<Clock className="mr-2 h-4 w-4 text-muted-foreground" />
|
|
<div className="flex items-center">
|
|
<Select value={hours} onValueChange={setHours}>
|
|
<SelectTrigger className="h-8 w-16 text-center text-sm">
|
|
<SelectValue placeholder={hours} />
|
|
</SelectTrigger>
|
|
<SelectContent className="max-h-60">
|
|
{hourOptions.map((hour) => (
|
|
<SelectItem key={hour} value={hour}>
|
|
{hour}
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
<span className="mx-1 text-sm">:</span>
|
|
<Select value={minutes} onValueChange={setMinutes}>
|
|
<SelectTrigger className="h-8 w-16 text-center text-sm">
|
|
<SelectValue placeholder={minutes} />
|
|
</SelectTrigger>
|
|
<SelectContent className="max-h-60">
|
|
{minuteOptions.map((minute) => (
|
|
<SelectItem key={minute} value={minute}>
|
|
{minute}
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
{showSeconds && (
|
|
<>
|
|
<span className="mx-1 text-sm">:</span>
|
|
<Select value={seconds} onValueChange={setSeconds}>
|
|
<SelectTrigger className="h-8 w-16 text-center text-sm">
|
|
<SelectValue placeholder={seconds} />
|
|
</SelectTrigger>
|
|
<SelectContent className="max-h-60">
|
|
{secondOptions.map((second) => (
|
|
<SelectItem key={second} value={second}>
|
|
{second}
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
</>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
<div className="flex justify-between border-t pt-3">
|
|
<Button variant="outline" size="sm" onClick={handleClear}>
|
|
Clear
|
|
</Button>
|
|
<Button variant="outline" size="sm" onClick={handleSetNow}>
|
|
Now
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|