"use client"; /** * V2Date * * 통합 날짜/시간 컴포넌트 * - date: 날짜 선택 * - time: 시간 선택 * - datetime: 날짜+시간 선택 * - range 옵션: 범위 선택 (시작~종료) */ import React, { forwardRef, useCallback, useMemo, useState, useEffect } from "react"; import { format, parse, isValid, addMonths, subMonths, startOfMonth, endOfMonth, eachDayOfInterval, isSameMonth, isSameDay, isToday as isTodayFn } from "date-fns"; import { ko } from "date-fns/locale"; import { Calendar as CalendarIcon, Clock, ChevronLeft, ChevronRight } from "lucide-react"; import { Label } from "@/components/ui/label"; import { Input } from "@/components/ui/input"; import { Button } from "@/components/ui/button"; import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; import { cn } from "@/lib/utils"; import { V2DateProps, V2DateType } from "@/types/v2-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); } // YYYYMMDD 또는 YYYY-MM-DD 문자열 → 유효한 Date 객체 반환 (유효하지 않으면 null) function parseManualDateInput(raw: string): Date | null { const digits = raw.replace(/\D/g, ""); if (digits.length !== 8) return null; const y = parseInt(digits.slice(0, 4), 10); const m = parseInt(digits.slice(4, 6), 10) - 1; const d = parseInt(digits.slice(6, 8), 10); const date = new Date(y, m, d); if (date.getFullYear() !== y || date.getMonth() !== m || date.getDate() !== d) return null; if (y < 1900 || y > 2100) return null; return date; } /** * 단일 날짜 선택 컴포넌트 */ const SingleDatePicker = forwardRef< HTMLDivElement, { value?: string; onChange?: (value: string) => void; dateFormat: string; showToday?: boolean; minDate?: string; maxDate?: string; disabled?: boolean; readonly?: boolean; className?: string; placeholder?: string; } >( ( { value, onChange, dateFormat = "YYYY-MM-DD", showToday = true, minDate, maxDate, disabled, readonly, className, placeholder = "날짜 선택" }, ref, ) => { const [open, setOpen] = useState(false); const [currentMonth, setCurrentMonth] = useState(new Date()); const [viewMode, setViewMode] = useState<"calendar" | "year" | "month">("calendar"); const [yearRangeStart, setYearRangeStart] = useState(() => Math.floor(new Date().getFullYear() / 12) * 12); const [isTyping, setIsTyping] = useState(false); const [typingValue, setTypingValue] = useState(""); const inputRef = React.useRef(null); const date = useMemo(() => parseDate(value, dateFormat), [value, dateFormat]); const displayText = useMemo(() => { if (!value) return ""; if (date && isValid(date)) return formatDate(date, dateFormat); return value; }, [value, date, dateFormat]); useEffect(() => { if (open) { setViewMode("calendar"); if (date && isValid(date)) { setCurrentMonth(new Date(date.getFullYear(), date.getMonth(), 1)); setYearRangeStart(Math.floor(date.getFullYear() / 12) * 12); } else { setCurrentMonth(new Date()); setYearRangeStart(Math.floor(new Date().getFullYear() / 12) * 12); } } else { setIsTyping(false); setTypingValue(""); } }, [open]); const handleDateClick = useCallback((clickedDate: Date) => { onChange?.(formatDate(clickedDate, dateFormat)); setIsTyping(false); setOpen(false); }, [dateFormat, onChange]); const handleToday = useCallback(() => { onChange?.(formatDate(new Date(), dateFormat)); setIsTyping(false); setOpen(false); }, [dateFormat, onChange]); const handleClear = useCallback(() => { onChange?.(""); setIsTyping(false); setOpen(false); }, [onChange]); const handleTriggerInput = useCallback((raw: string) => { setIsTyping(true); setTypingValue(raw); if (!open) setOpen(true); const digitsOnly = raw.replace(/\D/g, ""); if (digitsOnly.length === 8) { const parsed = parseManualDateInput(digitsOnly); if (parsed) { onChange?.(formatDate(parsed, dateFormat)); setCurrentMonth(new Date(parsed.getFullYear(), parsed.getMonth(), 1)); setTimeout(() => { setIsTyping(false); setOpen(false); }, 400); } } }, [dateFormat, onChange, open]); const mStart = startOfMonth(currentMonth); const mEnd = endOfMonth(currentMonth); const days = eachDayOfInterval({ start: mStart, end: mEnd }); const dow = mStart.getDay(); const padding = dow === 0 ? 6 : dow - 1; const allDays = [...Array(padding).fill(null), ...days]; return ( { if (!v) { setOpen(false); setIsTyping(false); } }}>
{ if (!disabled && !readonly) setOpen(true); }} > handleTriggerInput(e.target.value)} onClick={(e) => e.stopPropagation()} onFocus={() => { if (!disabled && !readonly && !open) setOpen(true); }} onBlur={() => { if (!open) setIsTyping(false); }} className={cn( "h-full w-full bg-transparent text-sm outline-none", "placeholder:text-muted-foreground disabled:cursor-not-allowed", !displayText && !isTyping && "text-muted-foreground", )} />
e.preventDefault()}>
{showToday && ( )}
{viewMode === "year" ? ( <>
{yearRangeStart} - {yearRangeStart + 11}
{Array.from({ length: 12 }, (_, i) => yearRangeStart + i).map((year) => ( ))}
) : viewMode === "month" ? ( <>
{Array.from({ length: 12 }, (_, i) => i).map((month) => ( ))}
) : ( <>
{["월", "화", "수", "목", "금", "토", "일"].map((d) => (
{d}
))}
{allDays.map((d, idx) => { if (!d) return
; const isCur = isSameMonth(d, currentMonth); const isSel = date ? isSameDay(d, date) : false; const isT = isTodayFn(d); return ( ); })}
)}
); }, ); SingleDatePicker.displayName = "SingleDatePicker"; /** * 날짜 범위 선택 컴포넌트 */ /** * 범위 날짜 팝오버 내부 캘린더 (drill-down 지원) */ const RangeCalendarPopover: React.FC<{ open: boolean; onOpenChange: (open: boolean) => void; selectedDate?: Date; onSelect: (date: Date) => void; label: string; disabled?: boolean; readonly?: boolean; displayValue?: string; }> = ({ open, onOpenChange, selectedDate, onSelect, label, disabled, readonly, displayValue }) => { const [currentMonth, setCurrentMonth] = useState(new Date()); const [viewMode, setViewMode] = useState<"calendar" | "year" | "month">("calendar"); const [yearRangeStart, setYearRangeStart] = useState(() => Math.floor(new Date().getFullYear() / 12) * 12); const [isTyping, setIsTyping] = useState(false); const [typingValue, setTypingValue] = useState(""); useEffect(() => { if (open) { setViewMode("calendar"); if (selectedDate && isValid(selectedDate)) { setCurrentMonth(new Date(selectedDate.getFullYear(), selectedDate.getMonth(), 1)); setYearRangeStart(Math.floor(selectedDate.getFullYear() / 12) * 12); } else { setCurrentMonth(new Date()); setYearRangeStart(Math.floor(new Date().getFullYear() / 12) * 12); } } else { setIsTyping(false); setTypingValue(""); } }, [open]); const handleTriggerInput = (raw: string) => { setIsTyping(true); setTypingValue(raw); const digitsOnly = raw.replace(/\D/g, ""); if (digitsOnly.length === 8) { const parsed = parseManualDateInput(digitsOnly); if (parsed) { setIsTyping(false); onSelect(parsed); } } }; const mStart = startOfMonth(currentMonth); const mEnd = endOfMonth(currentMonth); const days = eachDayOfInterval({ start: mStart, end: mEnd }); const dow = mStart.getDay(); const padding = dow === 0 ? 6 : dow - 1; const allDays = [...Array(padding).fill(null), ...days]; return ( { if (!v) { setIsTyping(false); } onOpenChange(v); }}>
{ if (!disabled && !readonly) onOpenChange(true); }} > handleTriggerInput(e.target.value)} onClick={(e) => e.stopPropagation()} onFocus={() => { if (!disabled && !readonly && !open) onOpenChange(true); }} onBlur={() => { if (!open) setIsTyping(false); }} className="h-full w-full bg-transparent text-sm font-normal outline-none placeholder:text-muted-foreground disabled:cursor-not-allowed" />
e.preventDefault()}>
{viewMode === "year" ? ( <>
{yearRangeStart} - {yearRangeStart + 11}
{Array.from({ length: 12 }, (_, i) => yearRangeStart + i).map((year) => ( ))}
) : viewMode === "month" ? ( <>
{Array.from({ length: 12 }, (_, i) => i).map((month) => ( ))}
) : ( <>
{["월", "화", "수", "목", "금", "토", "일"].map((d) => (
{d}
))}
{allDays.map((d, idx) => { if (!d) return
; const isCur = isSameMonth(d, currentMonth); const isSel = selectedDate ? isSameDay(d, selectedDate) : false; const isT = isTodayFn(d); return ( ); })}
)}
); }; 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 handleStartSelect = useCallback( (date: 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) => { const newEnd = formatDate(date, dateFormat); if (startDate && date < startDate) { onChange?.([newEnd, newEnd]); } else { onChange?.([value[0], newEnd]); } setOpenEnd(false); }, [value, dateFormat, startDate, onChange], ); return (
~
); }); 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-full 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"; /** * 메인 V2Date 컴포넌트 */ export const V2Date = 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} placeholder={config.placeholder} /> ); 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} placeholder={config.placeholder} /> ); } }; const showLabel = label && style?.labelDisplay !== false; const componentWidth = size?.width || style?.width; const componentHeight = size?.height || style?.height; // 라벨 높이 계산 (기본 20px, 사용자 설정에 따라 조정) const labelFontSize = style?.labelFontSize ? parseInt(String(style.labelFontSize)) : 14; const labelMarginBottom = style?.labelMarginBottom ? parseInt(String(style.labelMarginBottom)) : 4; const estimatedLabelHeight = labelFontSize + labelMarginBottom + 2; return (
{/* 🔧 라벨을 absolute로 컴포넌트 위에 배치 */} {showLabel && ( )}
{renderDatePicker()}
); }); V2Date.displayName = "V2Date"; export default V2Date;