"use client"; /** * UnifiedDate * * 통합 날짜/시간 컴포넌트 * - date: 날짜 선택 * - time: 시간 선택 * - datetime: 날짜+시간 선택 * - range 옵션: 범위 선택 (시작~종료) */ import React, { forwardRef, useCallback, useMemo, useState } from "react"; import { format, parse, isValid } from "date-fns"; import { ko } from "date-fns/locale"; import { Calendar as CalendarIcon, Clock } from "lucide-react"; import { Label } from "@/components/ui/label"; import { Input } from "@/components/ui/input"; import { Button } from "@/components/ui/button"; import { Calendar } from "@/components/ui/calendar"; import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; import { cn } from "@/lib/utils"; import { UnifiedDateProps, UnifiedDateType } from "@/types/unified-components"; // 날짜 형식 매핑 const DATE_FORMATS: Record = { "YYYY-MM-DD": "yyyy-MM-dd", "YYYY/MM/DD": "yyyy/MM/dd", "DD-MM-YYYY": "dd-MM-yyyy", "DD/MM/YYYY": "dd/MM/yyyy", "MM-DD-YYYY": "MM-dd-yyyy", "MM/DD/YYYY": "MM/dd/yyyy", "YYYY-MM-DD HH:mm": "yyyy-MM-dd HH:mm", "YYYY-MM-DD HH:mm:ss": "yyyy-MM-dd HH:mm:ss", }; // 날짜 문자열 → Date 객체 function parseDate(value: string | undefined, formatStr: string): Date | undefined { if (!value) return undefined; const dateFnsFormat = DATE_FORMATS[formatStr] || formatStr; try { // ISO 형식 먼저 시도 const isoDate = new Date(value); if (isValid(isoDate)) return isoDate; // 포맷에 맞게 파싱 const parsed = parse(value, dateFnsFormat, new Date()); return isValid(parsed) ? parsed : undefined; } catch { return undefined; } } // Date 객체 → 날짜 문자열 function formatDate(date: Date | undefined, formatStr: string): string { if (!date || !isValid(date)) return ""; const dateFnsFormat = DATE_FORMATS[formatStr] || formatStr; return format(date, dateFnsFormat); } /** * 단일 날짜 선택 컴포넌트 */ const SingleDatePicker = forwardRef< HTMLButtonElement, { value?: string; onChange?: (value: string) => void; dateFormat: string; showToday?: boolean; minDate?: string; maxDate?: string; disabled?: boolean; readonly?: boolean; className?: string; } >( ( { value, onChange, dateFormat = "YYYY-MM-DD", showToday = true, minDate, maxDate, disabled, readonly, className }, ref, ) => { const [open, setOpen] = useState(false); const date = useMemo(() => parseDate(value, dateFormat), [value, dateFormat]); const minDateObj = useMemo(() => parseDate(minDate, dateFormat), [minDate, dateFormat]); const maxDateObj = useMemo(() => parseDate(maxDate, dateFormat), [maxDate, dateFormat]); const handleSelect = useCallback( (selectedDate: Date | undefined) => { if (selectedDate) { onChange?.(formatDate(selectedDate, dateFormat)); setOpen(false); } }, [dateFormat, onChange], ); const handleToday = useCallback(() => { onChange?.(formatDate(new Date(), dateFormat)); setOpen(false); }, [dateFormat, onChange]); const handleClear = useCallback(() => { onChange?.(""); setOpen(false); }, [onChange]); return ( { if (minDateObj && date < minDateObj) return true; if (maxDateObj && date > maxDateObj) return true; return false; }} />
{showToday && ( )}
); }, ); SingleDatePicker.displayName = "SingleDatePicker"; /** * 날짜 범위 선택 컴포넌트 */ const RangeDatePicker = forwardRef< HTMLDivElement, { value?: [string, string]; onChange?: (value: [string, string]) => void; dateFormat: string; minDate?: string; maxDate?: string; disabled?: boolean; readonly?: boolean; className?: string; } >(({ value = ["", ""], onChange, dateFormat = "YYYY-MM-DD", minDate, maxDate, disabled, readonly, className }, ref) => { const [openStart, setOpenStart] = useState(false); const [openEnd, setOpenEnd] = useState(false); const startDate = useMemo(() => parseDate(value[0], dateFormat), [value, dateFormat]); const endDate = useMemo(() => parseDate(value[1], dateFormat), [value, dateFormat]); const minDateObj = useMemo(() => parseDate(minDate, dateFormat), [minDate, dateFormat]); const maxDateObj = useMemo(() => parseDate(maxDate, dateFormat), [maxDate, dateFormat]); const handleStartSelect = useCallback( (date: Date | undefined) => { if (date) { const newStart = formatDate(date, dateFormat); // 시작일이 종료일보다 크면 종료일도 같이 변경 if (endDate && date > endDate) { onChange?.([newStart, newStart]); } else { onChange?.([newStart, value[1]]); } setOpenStart(false); } }, [value, dateFormat, endDate, onChange], ); const handleEndSelect = useCallback( (date: Date | undefined) => { if (date) { const newEnd = formatDate(date, dateFormat); // 종료일이 시작일보다 작으면 시작일도 같이 변경 if (startDate && date < startDate) { onChange?.([newEnd, newEnd]); } else { onChange?.([value[0], newEnd]); } setOpenEnd(false); } }, [value, dateFormat, startDate, onChange], ); return (
{/* 시작 날짜 */} { if (minDateObj && date < minDateObj) return true; if (maxDateObj && date > maxDateObj) return true; return false; }} /> ~ {/* 종료 날짜 */} { if (minDateObj && date < minDateObj) return true; if (maxDateObj && date > maxDateObj) return true; // 시작일보다 이전 날짜는 선택 불가 if (startDate && date < startDate) return true; return false; }} />
); }); RangeDatePicker.displayName = "RangeDatePicker"; /** * 시간 선택 컴포넌트 */ const TimePicker = forwardRef< HTMLInputElement, { value?: string; onChange?: (value: string) => void; disabled?: boolean; readonly?: boolean; className?: string; } >(({ value, onChange, disabled, readonly, className }, ref) => { return (
onChange?.(e.target.value)} disabled={disabled} readOnly={readonly} className="h-10 pl-10" />
); }); TimePicker.displayName = "TimePicker"; /** * 날짜+시간 선택 컴포넌트 */ const DateTimePicker = forwardRef< HTMLDivElement, { value?: string; onChange?: (value: string) => void; dateFormat: string; minDate?: string; maxDate?: string; disabled?: boolean; readonly?: boolean; className?: string; } >(({ value, onChange, dateFormat = "YYYY-MM-DD HH:mm", minDate, maxDate, disabled, readonly, className }, ref) => { // 날짜와 시간 분리 const [datePart, timePart] = useMemo(() => { if (!value) return ["", ""]; const parts = value.split(" "); return [parts[0] || "", parts[1] || ""]; }, [value]); const handleDateChange = useCallback( (newDate: string) => { const newValue = `${newDate} ${timePart || "00:00"}`; onChange?.(newValue.trim()); }, [timePart, onChange], ); const handleTimeChange = useCallback( (newTime: string) => { const newValue = `${datePart || format(new Date(), "yyyy-MM-dd")} ${newTime}`; onChange?.(newValue.trim()); }, [datePart, onChange], ); return (
); }); DateTimePicker.displayName = "DateTimePicker"; /** * 메인 UnifiedDate 컴포넌트 */ export const UnifiedDate = forwardRef((props, ref) => { const { id, label, required, readonly, disabled, style, size, config: configProp, value, onChange } = props; // config가 없으면 기본값 사용 const config = configProp || { type: "date" as const }; const dateFormat = config.format || "YYYY-MM-DD"; // 타입별 컴포넌트 렌더링 const renderDatePicker = () => { const isDisabled = disabled || readonly; // 범위 선택 if (config.range) { return ( void} dateFormat={dateFormat} minDate={config.minDate} maxDate={config.maxDate} disabled={isDisabled} readonly={readonly} /> ); } // 타입별 렌더링 switch (config.type) { case "date": return ( onChange?.(v)} dateFormat={dateFormat} showToday={config.showToday} minDate={config.minDate} maxDate={config.maxDate} disabled={isDisabled} readonly={readonly} /> ); case "time": return ( onChange?.(v)} disabled={isDisabled} readonly={readonly} /> ); case "datetime": return ( onChange?.(v)} dateFormat={dateFormat} minDate={config.minDate} maxDate={config.maxDate} disabled={isDisabled} readonly={readonly} /> ); default: return ( onChange?.(v)} dateFormat={dateFormat} showToday={config.showToday} disabled={isDisabled} readonly={readonly} /> ); } }; const showLabel = label && style?.labelDisplay !== false; const componentWidth = size?.width || style?.width; const componentHeight = size?.height || style?.height; return (
{showLabel && ( )}
{renderDatePicker()}
); }); UnifiedDate.displayName = "UnifiedDate"; export default UnifiedDate;