193 lines
5.7 KiB
TypeScript
193 lines
5.7 KiB
TypeScript
|
|
'use client'
|
|||
|
|
|
|||
|
|
import * as React from 'react'
|
|||
|
|
import { format, setHours, setMinutes } from 'date-fns'
|
|||
|
|
import { CalendarIcon, Clock } from 'lucide-react'
|
|||
|
|
|
|||
|
|
import { cn } from '@/lib/utils'
|
|||
|
|
import { Button } from '@/components/ui/button'
|
|||
|
|
import { Calendar } from '@/components/ui/calendar'
|
|||
|
|
import {
|
|||
|
|
Popover,
|
|||
|
|
PopoverContent,
|
|||
|
|
PopoverTrigger,
|
|||
|
|
} from '@/components/ui/popover'
|
|||
|
|
import {
|
|||
|
|
Select,
|
|||
|
|
SelectContent,
|
|||
|
|
SelectItem,
|
|||
|
|
SelectTrigger,
|
|||
|
|
SelectValue,
|
|||
|
|
} from '@/components/ui/select'
|
|||
|
|
|
|||
|
|
interface DateTimePickerProps {
|
|||
|
|
value?: Date | null
|
|||
|
|
onChange?: (date: Date | null) => void
|
|||
|
|
placeholder?: string
|
|||
|
|
disabled?: boolean
|
|||
|
|
className?: string
|
|||
|
|
/** If true, only shows date picker without time */
|
|||
|
|
dateOnly?: boolean
|
|||
|
|
/** If true, allows clearing the value */
|
|||
|
|
clearable?: boolean
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Generate hour options (00-23)
|
|||
|
|
const hours = Array.from({ length: 24 }, (_, i) => i)
|
|||
|
|
|
|||
|
|
// Generate minute options (00, 15, 30, 45) for easier selection
|
|||
|
|
const minutes = [0, 15, 30, 45]
|
|||
|
|
|
|||
|
|
export function DateTimePicker({
|
|||
|
|
value,
|
|||
|
|
onChange,
|
|||
|
|
placeholder = 'Select date and time',
|
|||
|
|
disabled = false,
|
|||
|
|
className,
|
|||
|
|
dateOnly = false,
|
|||
|
|
clearable = true,
|
|||
|
|
}: DateTimePickerProps) {
|
|||
|
|
const [open, setOpen] = React.useState(false)
|
|||
|
|
const [selectedDate, setSelectedDate] = React.useState<Date | undefined>(
|
|||
|
|
value ?? undefined
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
// Sync internal state with external value
|
|||
|
|
React.useEffect(() => {
|
|||
|
|
setSelectedDate(value ?? undefined)
|
|||
|
|
}, [value])
|
|||
|
|
|
|||
|
|
const handleDateSelect = (date: Date | undefined) => {
|
|||
|
|
if (!date) {
|
|||
|
|
setSelectedDate(undefined)
|
|||
|
|
onChange?.(null)
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Preserve time from previous selection or use noon as default
|
|||
|
|
const newDate = selectedDate
|
|||
|
|
? setHours(setMinutes(date, selectedDate.getMinutes()), selectedDate.getHours())
|
|||
|
|
: setHours(setMinutes(date, 0), 12) // Default to noon
|
|||
|
|
|
|||
|
|
setSelectedDate(newDate)
|
|||
|
|
onChange?.(newDate)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const handleTimeChange = (type: 'hour' | 'minute', valueStr: string) => {
|
|||
|
|
if (!selectedDate) return
|
|||
|
|
|
|||
|
|
const numValue = parseInt(valueStr, 10)
|
|||
|
|
let newDate: Date
|
|||
|
|
|
|||
|
|
if (type === 'hour') {
|
|||
|
|
newDate = setHours(selectedDate, numValue)
|
|||
|
|
} else {
|
|||
|
|
newDate = setMinutes(selectedDate, numValue)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
setSelectedDate(newDate)
|
|||
|
|
onChange?.(newDate)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const handleClear = (e: React.MouseEvent) => {
|
|||
|
|
e.stopPropagation()
|
|||
|
|
setSelectedDate(undefined)
|
|||
|
|
onChange?.(null)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const formatDisplayDate = (date: Date) => {
|
|||
|
|
if (dateOnly) {
|
|||
|
|
return format(date, 'MMM d, yyyy')
|
|||
|
|
}
|
|||
|
|
return format(date, 'MMM d, yyyy HH:mm')
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
return (
|
|||
|
|
<Popover open={open} onOpenChange={setOpen}>
|
|||
|
|
<PopoverTrigger asChild>
|
|||
|
|
<Button
|
|||
|
|
variant="outline"
|
|||
|
|
disabled={disabled}
|
|||
|
|
className={cn(
|
|||
|
|
'w-full justify-start text-left font-normal',
|
|||
|
|
!selectedDate && 'text-muted-foreground',
|
|||
|
|
className
|
|||
|
|
)}
|
|||
|
|
>
|
|||
|
|
<CalendarIcon className="mr-2 h-4 w-4" />
|
|||
|
|
{selectedDate ? formatDisplayDate(selectedDate) : placeholder}
|
|||
|
|
{clearable && selectedDate && (
|
|||
|
|
<span
|
|||
|
|
role="button"
|
|||
|
|
tabIndex={0}
|
|||
|
|
className="ml-auto text-muted-foreground hover:text-foreground"
|
|||
|
|
onClick={handleClear}
|
|||
|
|
onKeyDown={(e) => e.key === 'Enter' && handleClear(e as unknown as React.MouseEvent)}
|
|||
|
|
>
|
|||
|
|
×
|
|||
|
|
</span>
|
|||
|
|
)}
|
|||
|
|
</Button>
|
|||
|
|
</PopoverTrigger>
|
|||
|
|
<PopoverContent className="w-auto p-0" align="start">
|
|||
|
|
<div className="flex flex-col">
|
|||
|
|
<Calendar
|
|||
|
|
mode="single"
|
|||
|
|
selected={selectedDate}
|
|||
|
|
onSelect={handleDateSelect}
|
|||
|
|
initialFocus
|
|||
|
|
/>
|
|||
|
|
|
|||
|
|
{!dateOnly && (
|
|||
|
|
<div className="border-t p-3">
|
|||
|
|
<div className="flex items-center gap-2">
|
|||
|
|
<Clock className="h-4 w-4 text-muted-foreground" />
|
|||
|
|
<span className="text-sm text-muted-foreground">Time:</span>
|
|||
|
|
<Select
|
|||
|
|
value={selectedDate ? String(selectedDate.getHours()) : undefined}
|
|||
|
|
onValueChange={(v) => handleTimeChange('hour', v)}
|
|||
|
|
disabled={!selectedDate}
|
|||
|
|
>
|
|||
|
|
<SelectTrigger className="w-[70px]">
|
|||
|
|
<SelectValue placeholder="HH" />
|
|||
|
|
</SelectTrigger>
|
|||
|
|
<SelectContent>
|
|||
|
|
{hours.map((hour) => (
|
|||
|
|
<SelectItem key={hour} value={String(hour)}>
|
|||
|
|
{String(hour).padStart(2, '0')}
|
|||
|
|
</SelectItem>
|
|||
|
|
))}
|
|||
|
|
</SelectContent>
|
|||
|
|
</Select>
|
|||
|
|
<span className="text-muted-foreground">:</span>
|
|||
|
|
<Select
|
|||
|
|
value={selectedDate ? String(selectedDate.getMinutes()) : undefined}
|
|||
|
|
onValueChange={(v) => handleTimeChange('minute', v)}
|
|||
|
|
disabled={!selectedDate}
|
|||
|
|
>
|
|||
|
|
<SelectTrigger className="w-[70px]">
|
|||
|
|
<SelectValue placeholder="MM" />
|
|||
|
|
</SelectTrigger>
|
|||
|
|
<SelectContent>
|
|||
|
|
{minutes.map((minute) => (
|
|||
|
|
<SelectItem key={minute} value={String(minute)}>
|
|||
|
|
{String(minute).padStart(2, '0')}
|
|||
|
|
</SelectItem>
|
|||
|
|
))}
|
|||
|
|
</SelectContent>
|
|||
|
|
</Select>
|
|||
|
|
</div>
|
|||
|
|
{selectedDate && (
|
|||
|
|
<p className="mt-2 text-xs text-muted-foreground">
|
|||
|
|
Selected: {format(selectedDate, 'EEEE, MMMM d, yyyy')} at{' '}
|
|||
|
|
{format(selectedDate, 'HH:mm')}
|
|||
|
|
</p>
|
|||
|
|
)}
|
|||
|
|
</div>
|
|||
|
|
)}
|
|||
|
|
</div>
|
|||
|
|
</PopoverContent>
|
|||
|
|
</Popover>
|
|||
|
|
)
|
|||
|
|
}
|