ERP-node/frontend/components/v2/V2Date.tsx

750 lines
30 KiB
TypeScript
Raw Normal View History

2025-12-23 09:31:18 +09:00
"use client";
/**
* V2Date
2025-12-23 09:31:18 +09:00
*
* /
* - 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";
2025-12-23 09:31:18 +09:00
import { ko } from "date-fns/locale";
import { Calendar as CalendarIcon, Clock, ChevronLeft, ChevronRight } from "lucide-react";
2025-12-23 09:31:18 +09:00
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";
2025-12-23 09:31:18 +09:00
// 날짜 형식 매핑
const DATE_FORMATS: Record<string, string> = {
"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;
}
2025-12-23 09:31:18 +09:00
/**
*
*/
const SingleDatePicker = forwardRef<
HTMLDivElement,
2025-12-23 09:31:18 +09:00
{
value?: string;
onChange?: (value: string) => void;
dateFormat: string;
showToday?: boolean;
minDate?: string;
maxDate?: string;
disabled?: boolean;
readonly?: boolean;
className?: string;
placeholder?: string;
2025-12-23 09:31:18 +09:00
}
>(
(
{ value, onChange, dateFormat = "YYYY-MM-DD", showToday = true, minDate, maxDate, disabled, readonly, className, placeholder = "날짜 선택" },
2025-12-23 09:31:18 +09:00
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<HTMLInputElement>(null);
2025-12-23 09:31:18 +09:00
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);
2025-12-23 09:31:18 +09:00
}
} else {
setIsTyping(false);
setTypingValue("");
}
}, [open]);
const handleDateClick = useCallback((clickedDate: Date) => {
onChange?.(formatDate(clickedDate, dateFormat));
setIsTyping(false);
setOpen(false);
}, [dateFormat, onChange]);
2025-12-23 09:31:18 +09:00
const handleToday = useCallback(() => {
onChange?.(formatDate(new Date(), dateFormat));
setIsTyping(false);
2025-12-23 09:31:18 +09:00
setOpen(false);
}, [dateFormat, onChange]);
const handleClear = useCallback(() => {
onChange?.("");
setIsTyping(false);
2025-12-23 09:31:18 +09:00
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];
2025-12-23 09:31:18 +09:00
return (
<Popover open={open} onOpenChange={(v) => { if (!v) { setOpen(false); setIsTyping(false); } }}>
2025-12-23 09:31:18 +09:00
<PopoverTrigger asChild>
<div
2025-12-23 09:31:18 +09:00
ref={ref}
className={cn(
"border-input bg-background flex h-full w-full cursor-pointer items-center rounded-md border px-3",
(disabled || readonly) && "cursor-not-allowed opacity-50",
2025-12-23 09:31:18 +09:00
className,
)}
onClick={() => { if (!disabled && !readonly) setOpen(true); }}
2025-12-23 09:31:18 +09:00
>
<CalendarIcon className="text-muted-foreground mr-2 h-4 w-4 shrink-0" />
<input
ref={inputRef}
type="text"
inputMode="numeric"
value={isTyping ? typingValue : (displayText || "")}
placeholder={placeholder}
disabled={disabled || readonly}
onChange={(e) => 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",
)}
/>
</div>
2025-12-23 09:31:18 +09:00
</PopoverTrigger>
<PopoverContent className="w-auto p-0" align="start" onOpenAutoFocus={(e) => e.preventDefault()}>
<div className="p-4">
<div className="mb-3 flex items-center gap-2">
{showToday && (
<Button variant="outline" size="sm" className="h-7 text-xs" onClick={handleToday}>
</Button>
)}
<Button variant="outline" size="sm" className="h-7 text-xs" onClick={handleClear}>
2025-12-23 09:31:18 +09:00
</Button>
</div>
{viewMode === "year" ? (
<>
<div className="mb-4 flex items-center justify-between">
<Button variant="ghost" size="sm" className="h-8 w-8 p-0" onClick={() => setYearRangeStart(yearRangeStart - 12)}>
<ChevronLeft className="h-4 w-4" />
</Button>
<div className="text-sm font-medium">{yearRangeStart} - {yearRangeStart + 11}</div>
<Button variant="ghost" size="sm" className="h-8 w-8 p-0" onClick={() => setYearRangeStart(yearRangeStart + 12)}>
<ChevronRight className="h-4 w-4" />
</Button>
</div>
<div className="grid grid-cols-4 gap-2">
{Array.from({ length: 12 }, (_, i) => yearRangeStart + i).map((year) => (
<Button
key={year}
variant="ghost"
size="sm"
className={cn(
"h-9 text-xs",
year === currentMonth.getFullYear() && "bg-primary text-primary-foreground hover:bg-primary",
year === new Date().getFullYear() && year !== currentMonth.getFullYear() && "border-primary border",
)}
onClick={() => { setCurrentMonth(new Date(year, currentMonth.getMonth(), 1)); setViewMode("month"); }}
>
{year}
</Button>
))}
</div>
</>
) : viewMode === "month" ? (
<>
<div className="mb-4 flex items-center justify-between">
<Button variant="ghost" size="sm" className="h-8 w-8 p-0" onClick={() => setCurrentMonth(new Date(currentMonth.getFullYear() - 1, currentMonth.getMonth(), 1))}>
<ChevronLeft className="h-4 w-4" />
</Button>
<button type="button" className="hover:bg-accent rounded-md px-2 py-1 text-sm font-medium transition-colors" onClick={() => { setYearRangeStart(Math.floor(currentMonth.getFullYear() / 12) * 12); setViewMode("year"); }}>
{currentMonth.getFullYear()}
</button>
<Button variant="ghost" size="sm" className="h-8 w-8 p-0" onClick={() => setCurrentMonth(new Date(currentMonth.getFullYear() + 1, currentMonth.getMonth(), 1))}>
<ChevronRight className="h-4 w-4" />
</Button>
</div>
<div className="grid grid-cols-4 gap-2">
{Array.from({ length: 12 }, (_, i) => i).map((month) => (
<Button
key={month}
variant="ghost"
size="sm"
className={cn(
"h-9 text-xs",
month === currentMonth.getMonth() && "bg-primary text-primary-foreground hover:bg-primary",
month === new Date().getMonth() && currentMonth.getFullYear() === new Date().getFullYear() && month !== currentMonth.getMonth() && "border-primary border",
)}
onClick={() => { setCurrentMonth(new Date(currentMonth.getFullYear(), month, 1)); setViewMode("calendar"); }}
>
{month + 1}
</Button>
))}
</div>
</>
) : (
<>
<div className="mb-4 flex items-center justify-between">
<Button variant="ghost" size="sm" className="h-8 w-8 p-0" onClick={() => setCurrentMonth(subMonths(currentMonth, 1))}>
<ChevronLeft className="h-4 w-4" />
</Button>
<button type="button" className="hover:bg-accent rounded-md px-2 py-1 text-sm font-medium transition-colors" onClick={() => { setYearRangeStart(Math.floor(currentMonth.getFullYear() / 12) * 12); setViewMode("year"); }}>
{format(currentMonth, "yyyy년 MM월", { locale: ko })}
</button>
<Button variant="ghost" size="sm" className="h-8 w-8 p-0" onClick={() => setCurrentMonth(addMonths(currentMonth, 1))}>
<ChevronRight className="h-4 w-4" />
</Button>
</div>
<div className="mb-2 grid grid-cols-7 gap-1">
{["월", "화", "수", "목", "금", "토", "일"].map((d) => (
<div key={d} className="text-muted-foreground p-2 text-center text-xs font-medium">{d}</div>
))}
</div>
<div className="grid grid-cols-7 gap-1">
{allDays.map((d, idx) => {
if (!d) return <div key={idx} className="p-2" />;
const isCur = isSameMonth(d, currentMonth);
const isSel = date ? isSameDay(d, date) : false;
const isT = isTodayFn(d);
return (
<Button
key={d.toISOString()}
variant="ghost"
size="sm"
className={cn(
"h-8 w-8 p-0 text-xs",
!isCur && "text-muted-foreground opacity-50",
isSel && "bg-primary text-primary-foreground hover:bg-primary",
isT && !isSel && "border-primary border",
)}
onClick={() => handleDateClick(d)}
disabled={!isCur}
>
{format(d, "d")}
</Button>
);
})}
</div>
</>
2025-12-23 09:31:18 +09:00
)}
</div>
</PopoverContent>
</Popover>
);
},
);
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 (
<Popover open={open} onOpenChange={(v) => { if (!v) { setIsTyping(false); } onOpenChange(v); }}>
<PopoverTrigger asChild>
<div
className={cn(
"border-input bg-background flex h-full flex-1 cursor-pointer items-center rounded-md border px-3",
(disabled || readonly) && "cursor-not-allowed opacity-50",
!displayValue && !isTyping && "text-muted-foreground",
)}
onClick={() => { if (!disabled && !readonly) onOpenChange(true); }}
>
<CalendarIcon className="text-muted-foreground mr-2 h-4 w-4 shrink-0" />
<input
type="text"
inputMode="numeric"
value={isTyping ? typingValue : (displayValue || "")}
placeholder={label}
disabled={disabled || readonly}
onChange={(e) => 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"
/>
</div>
</PopoverTrigger>
<PopoverContent className="w-auto p-0" align="start" onOpenAutoFocus={(e) => e.preventDefault()}>
<div className="p-4">
{viewMode === "year" ? (
<>
<div className="mb-4 flex items-center justify-between">
<Button variant="ghost" size="sm" className="h-8 w-8 p-0" onClick={() => setYearRangeStart(yearRangeStart - 12)}><ChevronLeft className="h-4 w-4" /></Button>
<div className="text-sm font-medium">{yearRangeStart} - {yearRangeStart + 11}</div>
<Button variant="ghost" size="sm" className="h-8 w-8 p-0" onClick={() => setYearRangeStart(yearRangeStart + 12)}><ChevronRight className="h-4 w-4" /></Button>
</div>
<div className="grid grid-cols-4 gap-2">
{Array.from({ length: 12 }, (_, i) => yearRangeStart + i).map((year) => (
<Button key={year} variant="ghost" size="sm" className={cn("h-9 text-xs", year === currentMonth.getFullYear() && "bg-primary text-primary-foreground hover:bg-primary", year === new Date().getFullYear() && year !== currentMonth.getFullYear() && "border-primary border")}
onClick={() => { setCurrentMonth(new Date(year, currentMonth.getMonth(), 1)); setViewMode("month"); }}>{year}</Button>
))}
</div>
</>
) : viewMode === "month" ? (
<>
<div className="mb-4 flex items-center justify-between">
<Button variant="ghost" size="sm" className="h-8 w-8 p-0" onClick={() => setCurrentMonth(new Date(currentMonth.getFullYear() - 1, currentMonth.getMonth(), 1))}><ChevronLeft className="h-4 w-4" /></Button>
<button type="button" className="hover:bg-accent rounded-md px-2 py-1 text-sm font-medium transition-colors" onClick={() => { setYearRangeStart(Math.floor(currentMonth.getFullYear() / 12) * 12); setViewMode("year"); }}>{currentMonth.getFullYear()}</button>
<Button variant="ghost" size="sm" className="h-8 w-8 p-0" onClick={() => setCurrentMonth(new Date(currentMonth.getFullYear() + 1, currentMonth.getMonth(), 1))}><ChevronRight className="h-4 w-4" /></Button>
</div>
<div className="grid grid-cols-4 gap-2">
{Array.from({ length: 12 }, (_, i) => i).map((month) => (
<Button key={month} variant="ghost" size="sm" className={cn("h-9 text-xs", month === currentMonth.getMonth() && "bg-primary text-primary-foreground hover:bg-primary", month === new Date().getMonth() && currentMonth.getFullYear() === new Date().getFullYear() && month !== currentMonth.getMonth() && "border-primary border")}
onClick={() => { setCurrentMonth(new Date(currentMonth.getFullYear(), month, 1)); setViewMode("calendar"); }}>{month + 1}</Button>
))}
</div>
</>
) : (
<>
<div className="mb-4 flex items-center justify-between">
<Button variant="ghost" size="sm" className="h-8 w-8 p-0" onClick={() => setCurrentMonth(subMonths(currentMonth, 1))}><ChevronLeft className="h-4 w-4" /></Button>
<button type="button" className="hover:bg-accent rounded-md px-2 py-1 text-sm font-medium transition-colors" onClick={() => { setYearRangeStart(Math.floor(currentMonth.getFullYear() / 12) * 12); setViewMode("year"); }}>{format(currentMonth, "yyyy년 MM월", { locale: ko })}</button>
<Button variant="ghost" size="sm" className="h-8 w-8 p-0" onClick={() => setCurrentMonth(addMonths(currentMonth, 1))}><ChevronRight className="h-4 w-4" /></Button>
</div>
<div className="mb-2 grid grid-cols-7 gap-1">
{["월", "화", "수", "목", "금", "토", "일"].map((d) => (
<div key={d} className="text-muted-foreground p-2 text-center text-xs font-medium">{d}</div>
))}
</div>
<div className="grid grid-cols-7 gap-1">
{allDays.map((d, idx) => {
if (!d) return <div key={idx} className="p-2" />;
const isCur = isSameMonth(d, currentMonth);
const isSel = selectedDate ? isSameDay(d, selectedDate) : false;
const isT = isTodayFn(d);
return (
<Button key={d.toISOString()} variant="ghost" size="sm" className={cn("h-8 w-8 p-0 text-xs", !isCur && "text-muted-foreground opacity-50", isSel && "bg-primary text-primary-foreground hover:bg-primary", isT && !isSel && "border-primary border")}
onClick={() => onSelect(d)} disabled={!isCur}>{format(d, "d")}</Button>
);
})}
</div>
</>
)}
</div>
</PopoverContent>
</Popover>
);
};
2025-12-23 09:31:18 +09:00
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]]);
2025-12-23 09:31:18 +09:00
}
setOpenStart(false);
2025-12-23 09:31:18 +09:00
},
[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]);
2025-12-23 09:31:18 +09:00
}
setOpenEnd(false);
2025-12-23 09:31:18 +09:00
},
[value, dateFormat, startDate, onChange],
);
return (
<div ref={ref} className={cn("flex items-center gap-2 h-full", className)}>
<RangeCalendarPopover open={openStart} onOpenChange={setOpenStart} selectedDate={startDate} onSelect={handleStartSelect} label="시작일" disabled={disabled} readonly={readonly} displayValue={value[0]} />
2025-12-23 09:31:18 +09:00
<span className="text-muted-foreground">~</span>
<RangeCalendarPopover open={openEnd} onOpenChange={setOpenEnd} selectedDate={endDate} onSelect={handleEndSelect} label="종료일" disabled={disabled} readonly={readonly} displayValue={value[1]} />
2025-12-23 09:31:18 +09:00
</div>
);
});
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 (
<div className={cn("relative h-full", className)}>
2025-12-23 09:31:18 +09:00
<Clock className="text-muted-foreground absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2" />
<Input
ref={ref}
type="time"
value={value || ""}
onChange={(e) => onChange?.(e.target.value)}
disabled={disabled}
readOnly={readonly}
className="h-full pl-10"
2025-12-23 09:31:18 +09:00
/>
</div>
);
});
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 (
<div ref={ref} className={cn("flex gap-2 h-full", className)}>
<div className="flex-1 h-full">
2025-12-23 09:31:18 +09:00
<SingleDatePicker
value={datePart}
onChange={handleDateChange}
dateFormat="YYYY-MM-DD"
minDate={minDate}
maxDate={maxDate}
disabled={disabled}
readonly={readonly}
/>
</div>
<div className="w-1/3 min-w-[100px] h-full">
2025-12-23 09:31:18 +09:00
<TimePicker value={timePart} onChange={handleTimeChange} disabled={disabled} readonly={readonly} />
</div>
</div>
);
});
DateTimePicker.displayName = "DateTimePicker";
/**
* V2Date
2025-12-23 09:31:18 +09:00
*/
export const V2Date = forwardRef<HTMLDivElement, V2DateProps>((props, ref) => {
2025-12-23 09:31:18 +09:00
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 (
<RangeDatePicker
value={Array.isArray(value) ? (value as [string, string]) : ["", ""]}
onChange={onChange as (value: [string, string]) => void}
dateFormat={dateFormat}
minDate={config.minDate}
maxDate={config.maxDate}
disabled={isDisabled}
readonly={readonly}
/>
);
}
// 타입별 렌더링
switch (config.type) {
case "date":
return (
<SingleDatePicker
value={typeof value === "string" ? value : ""}
onChange={(v) => onChange?.(v)}
dateFormat={dateFormat}
showToday={config.showToday}
minDate={config.minDate}
maxDate={config.maxDate}
disabled={isDisabled}
readonly={readonly}
placeholder={config.placeholder}
2025-12-23 09:31:18 +09:00
/>
);
case "time":
return (
<TimePicker
value={typeof value === "string" ? value : ""}
onChange={(v) => onChange?.(v)}
disabled={isDisabled}
readonly={readonly}
/>
);
case "datetime":
return (
<DateTimePicker
value={typeof value === "string" ? value : ""}
onChange={(v) => onChange?.(v)}
dateFormat={dateFormat}
minDate={config.minDate}
maxDate={config.maxDate}
disabled={isDisabled}
readonly={readonly}
/>
);
default:
return (
<SingleDatePicker
value={typeof value === "string" ? value : ""}
onChange={(v) => onChange?.(v)}
dateFormat={dateFormat}
showToday={config.showToday}
disabled={isDisabled}
readonly={readonly}
placeholder={config.placeholder}
2025-12-23 09:31:18 +09:00
/>
);
}
};
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;
2025-12-23 09:31:18 +09:00
return (
<div
ref={ref}
id={id}
className="relative"
2025-12-23 09:31:18 +09:00
style={{
width: componentWidth,
height: componentHeight,
2025-12-23 09:31:18 +09:00
}}
>
{/* 🔧 라벨을 absolute로 컴포넌트 위에 배치 */}
2025-12-23 09:31:18 +09:00
{showLabel && (
<Label
htmlFor={id}
style={{
position: "absolute",
top: `-${estimatedLabelHeight}px`,
left: 0,
fontSize: style?.labelFontSize || "14px",
color: style?.labelColor || "#64748b",
fontWeight: style?.labelFontWeight || "500",
2025-12-23 09:31:18 +09:00
}}
className="text-sm font-medium whitespace-nowrap"
2025-12-23 09:31:18 +09:00
>
{label}
{required && <span className="ml-0.5 text-orange-500">*</span>}
</Label>
)}
<div className="h-full w-full">
{renderDatePicker()}
</div>
2025-12-23 09:31:18 +09:00
</div>
);
});
V2Date.displayName = "V2Date";
2025-12-23 09:31:18 +09:00
export default V2Date;