"use client" import * as React from "react" import { format, parse, isValid } from "date-fns" import { ko } from "date-fns/locale" import { CalendarIcon, ChevronLeft, ChevronRight } from "lucide-react" import { DayPicker } from "react-day-picker" import { cn } from "@/lib/utils" import { Button, buttonVariants } from "@/components/ui/button" import { Input } from "@/components/ui/input" import { Popover, PopoverContent, PopoverTrigger, } from "@/components/ui/popover" export interface DatePickerWithInputProps { value?: Date onChange?: (date: Date | undefined) => void disabled?: boolean placeholder?: string className?: string minDate?: Date maxDate?: Date dateFormat?: string inputClassName?: string locale?: "ko" | "en" } /** * DatePickerWithInput - 캘린더 선택 및 직접 입력이 가능한 날짜 선택기 * * 사용법: * ```tsx * setSelectedDate(date)} * placeholder="날짜를 선택하세요" * minDate={new Date()} * /> * ``` */ export function DatePickerWithInput({ value, onChange, disabled = false, placeholder = "YYYY-MM-DD", className, minDate, maxDate, dateFormat = "yyyy-MM-dd", inputClassName, locale: localeProp = "en", }: DatePickerWithInputProps) { const [open, setOpen] = React.useState(false) const [inputValue, setInputValue] = React.useState("") const [month, setMonth] = React.useState(value || new Date()) const [hasError, setHasError] = React.useState(false) const [errorMessage, setErrorMessage] = React.useState("") // 외부 value가 변경되면 inputValue도 업데이트 React.useEffect(() => { if (value && isValid(value)) { setInputValue(format(value, dateFormat)) setMonth(value) setHasError(false) setErrorMessage("") } else { setInputValue("") } }, [value, dateFormat]) // 날짜 유효성 검사 및 에러 메시지 설정 const validateDate = (date: Date): { valid: boolean; message: string } => { if (minDate) { const minDateStart = new Date(minDate) minDateStart.setHours(0, 0, 0, 0) const dateToCheck = new Date(date) dateToCheck.setHours(0, 0, 0, 0) if (dateToCheck < minDateStart) { return { valid: false, message: `${format(minDate, dateFormat)} 이후 날짜를 선택해주세요` } } } if (maxDate) { const maxDateEnd = new Date(maxDate) maxDateEnd.setHours(23, 59, 59, 999) if (date > maxDateEnd) { return { valid: false, message: `${format(maxDate, dateFormat)} 이전 날짜를 선택해주세요` } } } return { valid: true, message: "" } } // 캘린더에서 날짜 선택 const handleCalendarSelect = React.useCallback((date: Date | undefined, e?: React.MouseEvent) => { // 이벤트 전파 중지 if (e) { e.preventDefault() e.stopPropagation() } if (date) { const validation = validateDate(date) if (validation.valid) { setInputValue(format(date, dateFormat)) setHasError(false) setErrorMessage("") onChange?.(date) setMonth(date) } else { setHasError(true) setErrorMessage(validation.message) } } setOpen(false) }, [dateFormat, onChange, minDate, maxDate]) // 직접 입력값 변경 const handleInputChange = (e: React.ChangeEvent) => { const newValue = e.target.value setInputValue(newValue) // 입력 중에는 에러 상태 초기화 if (hasError) { setHasError(false) setErrorMessage("") } // YYYY-MM-DD 형식인 경우에만 파싱 시도 if (/^\d{4}-\d{2}-\d{2}$/.test(newValue)) { const parsedDate = parse(newValue, dateFormat, new Date()) if (isValid(parsedDate)) { const validation = validateDate(parsedDate) if (validation.valid) { setHasError(false) setErrorMessage("") onChange?.(parsedDate) setMonth(parsedDate) } else { setHasError(true) setErrorMessage(validation.message) } } else { setHasError(true) setErrorMessage("유효하지 않은 날짜 형식입니다") } } } // 입력 완료 시 (blur) 유효성 검사 const handleInputBlur = () => { if (!inputValue) { setHasError(false) setErrorMessage("") onChange?.(undefined) return } const parsedDate = parse(inputValue, dateFormat, new Date()) if (isValid(parsedDate)) { const validation = validateDate(parsedDate) if (validation.valid) { setHasError(false) setErrorMessage("") onChange?.(parsedDate) } else { setHasError(true) setErrorMessage(validation.message) // 유효 범위를 벗어난 경우 입력값은 유지하되 에러 표시 } } else { // 유효하지 않은 형식인 경우 setHasError(true) setErrorMessage("YYYY-MM-DD 형식으로 입력해주세요") } } // 키보드 이벤트 처리 (Enter 키로 완료) const handleKeyDown = (e: React.KeyboardEvent) => { if (e.key === "Enter") { handleInputBlur() } } // 날짜 비활성화 체크 (캘린더용) const isDateDisabled = (date: Date) => { if (disabled) return true if (minDate) { const minDateStart = new Date(minDate) minDateStart.setHours(0, 0, 0, 0) const dateToCheck = new Date(date) dateToCheck.setHours(0, 0, 0, 0) if (dateToCheck < minDateStart) return true } if (maxDate) { const maxDateEnd = new Date(maxDate) maxDateEnd.setHours(23, 59, 59, 999) if (date > maxDateEnd) return true } return false } // 캘린더 버튼 클릭 핸들러 const handleCalendarButtonClick = (e: React.MouseEvent) => { e.preventDefault() e.stopPropagation() setOpen(!open) } // Popover 상태 변경 핸들러 const handleOpenChange = (newOpen: boolean) => { setOpen(newOpen) } return (
e.preventDefault()} onInteractOutside={(e) => e.preventDefault()} >
e.stopPropagation()} onMouseDown={(e) => e.stopPropagation()} > { handleCalendarSelect(date, e as unknown as React.MouseEvent) }} month={month} onMonthChange={setMonth} disabled={isDateDisabled} locale={localeProp === "ko" ? ko : undefined} showOutsideDays className="p-3" classNames={{ months: "flex flex-col sm:flex-row space-y-4 sm:space-x-4 sm:space-y-0", month: "space-y-4", caption: "flex justify-center pt-1 relative items-center", caption_label: "text-sm font-medium", nav: "space-x-1 flex items-center", nav_button: cn( buttonVariants({ variant: "outline" }), "h-7 w-7 bg-transparent p-0 opacity-50 hover:opacity-100" ), 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-8 font-normal text-[0.8rem]", row: "flex w-full mt-2", cell: "relative p-0 text-center text-sm focus-within:relative focus-within:z-20 [&:has([aria-selected])]:bg-accent [&:has([aria-selected])]:rounded-md", day: cn( buttonVariants({ variant: "ghost" }), "h-8 w-8 p-0 font-normal aria-selected:opacity-100" ), 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_hidden: "invisible", }} components={{ IconLeft: () => , IconRight: () => , }} />
{/* 에러 메시지 표시 */} {hasError && errorMessage && (

{errorMessage}

)}
) }