489 lines
14 KiB
TypeScript
489 lines
14 KiB
TypeScript
"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<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);
|
|
}
|
|
|
|
/**
|
|
* 단일 날짜 선택 컴포넌트
|
|
*/
|
|
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 (
|
|
<Popover open={open} onOpenChange={setOpen}>
|
|
<PopoverTrigger asChild>
|
|
<Button
|
|
ref={ref}
|
|
variant="outline"
|
|
disabled={disabled || readonly}
|
|
className={cn(
|
|
"h-10 w-full justify-start text-left font-normal",
|
|
!value && "text-muted-foreground",
|
|
className,
|
|
)}
|
|
>
|
|
<CalendarIcon className="mr-2 h-4 w-4" />
|
|
{value || "날짜 선택"}
|
|
</Button>
|
|
</PopoverTrigger>
|
|
<PopoverContent className="w-auto p-0" align="start">
|
|
<Calendar
|
|
mode="single"
|
|
selected={date}
|
|
onSelect={handleSelect}
|
|
initialFocus
|
|
locale={ko}
|
|
disabled={(date) => {
|
|
if (minDateObj && date < minDateObj) return true;
|
|
if (maxDateObj && date > maxDateObj) return true;
|
|
return false;
|
|
}}
|
|
/>
|
|
<div className="flex gap-2 p-3 pt-0">
|
|
{showToday && (
|
|
<Button variant="outline" size="sm" onClick={handleToday}>
|
|
오늘
|
|
</Button>
|
|
)}
|
|
<Button variant="ghost" size="sm" onClick={handleClear}>
|
|
초기화
|
|
</Button>
|
|
</div>
|
|
</PopoverContent>
|
|
</Popover>
|
|
);
|
|
},
|
|
);
|
|
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 (
|
|
<div ref={ref} className={cn("flex items-center gap-2", className)}>
|
|
{/* 시작 날짜 */}
|
|
<Popover open={openStart} onOpenChange={setOpenStart}>
|
|
<PopoverTrigger asChild>
|
|
<Button
|
|
variant="outline"
|
|
disabled={disabled || readonly}
|
|
className={cn("h-10 flex-1 justify-start text-left font-normal", !value[0] && "text-muted-foreground")}
|
|
>
|
|
<CalendarIcon className="mr-2 h-4 w-4" />
|
|
{value[0] || "시작일"}
|
|
</Button>
|
|
</PopoverTrigger>
|
|
<PopoverContent className="w-auto p-0" align="start">
|
|
<Calendar
|
|
mode="single"
|
|
selected={startDate}
|
|
onSelect={handleStartSelect}
|
|
initialFocus
|
|
locale={ko}
|
|
disabled={(date) => {
|
|
if (minDateObj && date < minDateObj) return true;
|
|
if (maxDateObj && date > maxDateObj) return true;
|
|
return false;
|
|
}}
|
|
/>
|
|
</PopoverContent>
|
|
</Popover>
|
|
|
|
<span className="text-muted-foreground">~</span>
|
|
|
|
{/* 종료 날짜 */}
|
|
<Popover open={openEnd} onOpenChange={setOpenEnd}>
|
|
<PopoverTrigger asChild>
|
|
<Button
|
|
variant="outline"
|
|
disabled={disabled || readonly}
|
|
className={cn("h-10 flex-1 justify-start text-left font-normal", !value[1] && "text-muted-foreground")}
|
|
>
|
|
<CalendarIcon className="mr-2 h-4 w-4" />
|
|
{value[1] || "종료일"}
|
|
</Button>
|
|
</PopoverTrigger>
|
|
<PopoverContent className="w-auto p-0" align="start">
|
|
<Calendar
|
|
mode="single"
|
|
selected={endDate}
|
|
onSelect={handleEndSelect}
|
|
initialFocus
|
|
locale={ko}
|
|
disabled={(date) => {
|
|
if (minDateObj && date < minDateObj) return true;
|
|
if (maxDateObj && date > maxDateObj) return true;
|
|
// 시작일보다 이전 날짜는 선택 불가
|
|
if (startDate && date < startDate) return true;
|
|
return false;
|
|
}}
|
|
/>
|
|
</PopoverContent>
|
|
</Popover>
|
|
</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", className)}>
|
|
<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-10 pl-10"
|
|
/>
|
|
</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", className)}>
|
|
<div className="flex-1">
|
|
<SingleDatePicker
|
|
value={datePart}
|
|
onChange={handleDateChange}
|
|
dateFormat="YYYY-MM-DD"
|
|
minDate={minDate}
|
|
maxDate={maxDate}
|
|
disabled={disabled}
|
|
readonly={readonly}
|
|
/>
|
|
</div>
|
|
<div className="w-32">
|
|
<TimePicker value={timePart} onChange={handleTimeChange} disabled={disabled} readonly={readonly} />
|
|
</div>
|
|
</div>
|
|
);
|
|
});
|
|
DateTimePicker.displayName = "DateTimePicker";
|
|
|
|
/**
|
|
* 메인 UnifiedDate 컴포넌트
|
|
*/
|
|
export const UnifiedDate = forwardRef<HTMLDivElement, UnifiedDateProps>((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 (
|
|
<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}
|
|
/>
|
|
);
|
|
|
|
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}
|
|
/>
|
|
);
|
|
}
|
|
};
|
|
|
|
const showLabel = label && style?.labelDisplay !== false;
|
|
const componentWidth = size?.width || style?.width;
|
|
const componentHeight = size?.height || style?.height;
|
|
|
|
return (
|
|
<div
|
|
ref={ref}
|
|
id={id}
|
|
className="flex flex-col"
|
|
style={{
|
|
width: componentWidth,
|
|
height: componentHeight,
|
|
}}
|
|
>
|
|
{showLabel && (
|
|
<Label
|
|
htmlFor={id}
|
|
style={{
|
|
fontSize: style?.labelFontSize,
|
|
color: style?.labelColor,
|
|
fontWeight: style?.labelFontWeight,
|
|
marginBottom: style?.labelMarginBottom,
|
|
}}
|
|
className="flex-shrink-0 text-sm font-medium"
|
|
>
|
|
{label}
|
|
{required && <span className="ml-0.5 text-orange-500">*</span>}
|
|
</Label>
|
|
)}
|
|
<div className="min-h-0 flex-1">{renderDatePicker()}</div>
|
|
</div>
|
|
);
|
|
});
|
|
|
|
UnifiedDate.displayName = "UnifiedDate";
|
|
|
|
export default UnifiedDate;
|