summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorjoonhoekim <26rote@gmail.com>2025-12-04 11:42:07 +0900
committerjoonhoekim <26rote@gmail.com>2025-12-04 11:42:07 +0900
commitc26d78eabf13498c9817885b54512593c6a33f8d (patch)
tree5da80c5acad09b897ce0de42c6771ee36f3d83cc
parent0f3954bf57e65caef7b7dd14ea5fccb63fdb2bef (diff)
(김준회) 공통컴포넌트: YYYY-MM-DD 형태 수동 입력과 캘린더에서 선택 지원하는 date 입력 컴포넌트
-rw-r--r--components/common/date-picker/date-picker-with-input.tsx322
-rw-r--r--components/common/date-picker/index.ts3
2 files changed, 325 insertions, 0 deletions
diff --git a/components/common/date-picker/date-picker-with-input.tsx b/components/common/date-picker/date-picker-with-input.tsx
new file mode 100644
index 00000000..6e768601
--- /dev/null
+++ b/components/common/date-picker/date-picker-with-input.tsx
@@ -0,0 +1,322 @@
+"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
+ * <DatePickerWithInput
+ * value={selectedDate}
+ * onChange={(date) => 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<string>("")
+ const [month, setMonth] = React.useState<Date>(value || new Date())
+ const [hasError, setHasError] = React.useState(false)
+ const [errorMessage, setErrorMessage] = React.useState<string>("")
+
+ // 외부 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<HTMLInputElement>) => {
+ 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<HTMLInputElement>) => {
+ 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 (
+ <div className={cn("relative", className)}>
+ <div className="flex items-center gap-1">
+ <Input
+ type="text"
+ value={inputValue}
+ onChange={handleInputChange}
+ onBlur={handleInputBlur}
+ onKeyDown={handleKeyDown}
+ placeholder={placeholder}
+ disabled={disabled}
+ className={cn(
+ "pr-10",
+ hasError && "border-red-500 focus-visible:ring-red-500",
+ inputClassName
+ )}
+ />
+ <Popover open={open} onOpenChange={handleOpenChange} modal={true}>
+ <PopoverTrigger asChild>
+ <Button
+ variant="ghost"
+ size="icon"
+ className="absolute right-0 h-full px-3 hover:bg-transparent"
+ disabled={disabled}
+ type="button"
+ onClick={handleCalendarButtonClick}
+ >
+ <CalendarIcon className={cn(
+ "h-4 w-4",
+ hasError ? "text-red-500" : "text-muted-foreground"
+ )} />
+ </Button>
+ </PopoverTrigger>
+ <PopoverContent
+ className="w-auto p-0"
+ align="end"
+ onPointerDownOutside={(e) => e.preventDefault()}
+ onInteractOutside={(e) => e.preventDefault()}
+ >
+ <div
+ onClick={(e) => e.stopPropagation()}
+ onMouseDown={(e) => e.stopPropagation()}
+ >
+ <DayPicker
+ mode="single"
+ selected={value}
+ onSelect={(date, selectedDay, activeModifiers, e) => {
+ 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: () => <ChevronLeft className="h-4 w-4" />,
+ IconRight: () => <ChevronRight className="h-4 w-4" />,
+ }}
+ />
+ </div>
+ </PopoverContent>
+ </Popover>
+ </div>
+ {/* 에러 메시지 표시 */}
+ {hasError && errorMessage && (
+ <p className="text-xs text-red-500 mt-1">{errorMessage}</p>
+ )}
+ </div>
+ )
+}
+
diff --git a/components/common/date-picker/index.ts b/components/common/date-picker/index.ts
new file mode 100644
index 00000000..85c0c259
--- /dev/null
+++ b/components/common/date-picker/index.ts
@@ -0,0 +1,3 @@
+// 공용 날짜 선택기 컴포넌트
+export { DatePickerWithInput, type DatePickerWithInputProps } from './date-picker-with-input'
+