"use client"; /** * DateInput — YYYY-MM-DD 형식 통일 공용 날짜 입력 컴포넌트 * * 브라우저 `` 는 OS/로케일에 따라 "연도. 월. 일." 등 다른 placeholder 를 * 보여주는 문제가 있어, text input + Popover Calendar 로 표시·저장을 YYYY-MM-DD 로 통일. * * - 직접 타이핑: YYYY-MM-DD (8자리 숫자 입력 시 자동으로 - 삽입) * - 캘린더 아이콘 클릭 → Popover Calendar 에서 날짜 선택 * - 항상 onChange 에 'YYYY-MM-DD' 또는 빈 문자열 전달 */ import React, { useMemo, useState, useEffect, useRef } from "react"; import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; import { Calendar } from "@/components/ui/calendar"; import { Input } from "@/components/ui/input"; import { Calendar as CalendarIcon, X } from "lucide-react"; import { cn } from "@/lib/utils"; import { format, parse, isValid } from "date-fns"; const FMT = "yyyy-MM-dd"; export interface DateInputProps { value: string; // 'YYYY-MM-DD' 또는 '' onChange: (v: string) => void; disabled?: boolean; placeholder?: string; className?: string; /** sm = h-7 (CompactFilter 행), md = h-9 (다이얼로그 폼) */ size?: "sm" | "md"; /** 값이 있을 때 ✕ 노출 (기본 true). 필수 필드는 false. */ clearable?: boolean; } function toDate(v: string): Date | null { if (!v) return null; const d = parse(v, FMT, new Date()); return isValid(d) ? d : null; } function isCompleteDate(v: string): boolean { if (!/^\d{4}-\d{2}-\d{2}$/.test(v)) return false; const d = parse(v, FMT, new Date()); return isValid(d); } /** 사용자 타이핑을 8자리 숫자로 받아 YYYY-MM-DD 로 슬라이스 */ function autoFormat(raw: string): string { const digits = raw.replace(/\D/g, "").slice(0, 8); if (digits.length <= 4) return digits; if (digits.length <= 6) return `${digits.slice(0, 4)}-${digits.slice(4)}`; return `${digits.slice(0, 4)}-${digits.slice(4, 6)}-${digits.slice(6, 8)}`; } export function DateInput({ value, onChange, disabled, placeholder = "YYYY-MM-DD", className, size = "md", clearable = true, }: DateInputProps) { const [open, setOpen] = useState(false); const [text, setText] = useState(value || ""); const inputRef = useRef(null); // 외부 value 동기화 useEffect(() => { setText(value || ""); }, [value]); const selectedDate = useMemo(() => toDate(value), [value]); const invalid = text.length > 0 && !isCompleteDate(text); const showClear = clearable && !disabled && !!value; const onTextChange = (raw: string) => { const formatted = autoFormat(raw); setText(formatted); if (formatted === "") { onChange(""); } else if (isCompleteDate(formatted)) { onChange(formatted); } // 아직 미완성이면 onChange 호출 안 함 (마지막 유효값 유지) }; const onBlur = () => { // 미완성 텍스트는 마지막 유효값으로 복귀 if (text && !isCompleteDate(text)) setText(value || ""); }; const handleCalendarSelect = (d: Date | undefined) => { if (!d) { onChange(""); setText(""); } else { const v = format(d, FMT); onChange(v); setText(v); } setOpen(false); }; const handleClear = (e: React.MouseEvent) => { e.preventDefault(); e.stopPropagation(); onChange(""); setText(""); inputRef.current?.focus(); }; const h = size === "sm" ? "h-7" : "h-9"; const textCls = size === "sm" ? "text-xs" : "text-sm"; return ( onTextChange(e.target.value)} onBlur={onBlur} className={cn( h, textCls, "pr-16 w-full", invalid && "border-destructive focus-visible:ring-destructive", )} aria-invalid={invalid || undefined} /> {showClear && ( { e.preventDefault(); e.stopPropagation(); }} onClick={handleClear} className="absolute right-8 top-1/2 -translate-y-1/2 z-10 inline-flex h-5 w-5 items-center justify-center rounded text-muted-foreground hover:text-foreground hover:bg-muted" > )} ); }