- Added a new function `isColumnRequired` to determine if a column is required based on its NOT NULL status from the table schema. - Updated the `SaveModal` and `InteractiveScreenViewer` components to incorporate this validation, ensuring that required fields are accurately assessed during form submission. - Enhanced the `DynamicComponentRenderer` to reflect the NOT NULL requirement in the component's required state. - Improved user feedback by marking required fields with an asterisk based on both manual settings and database constraints. These changes enhance the form validation process, ensuring that users are prompted for all necessary information based on the underlying data structure.
776 lines
30 KiB
TypeScript
776 lines
30 KiB
TypeScript
"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<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;
|
|
}
|
|
|
|
/**
|
|
* 단일 날짜 선택 컴포넌트
|
|
*/
|
|
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<HTMLInputElement>(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 (
|
|
<Popover open={open} onOpenChange={(v) => { if (!v) { setOpen(false); setIsTyping(false); } }}>
|
|
<PopoverTrigger asChild>
|
|
<div
|
|
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",
|
|
className,
|
|
)}
|
|
onClick={() => { if (!disabled && !readonly) setOpen(true); }}
|
|
>
|
|
<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>
|
|
</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}>
|
|
초기화
|
|
</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>
|
|
</>
|
|
)}
|
|
</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>
|
|
);
|
|
};
|
|
|
|
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 (
|
|
<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]} />
|
|
<span className="text-muted-foreground">~</span>
|
|
<RangeCalendarPopover open={openEnd} onOpenChange={setOpenEnd} selectedDate={endDate} onSelect={handleEndSelect} label="종료일" disabled={disabled} readonly={readonly} displayValue={value[1]} />
|
|
</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)}>
|
|
<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"
|
|
/>
|
|
</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">
|
|
<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">
|
|
<TimePicker value={timePart} onChange={handleTimeChange} disabled={disabled} readonly={readonly} />
|
|
</div>
|
|
</div>
|
|
);
|
|
});
|
|
DateTimePicker.displayName = "DateTimePicker";
|
|
|
|
/**
|
|
* 메인 V2Date 컴포넌트
|
|
*/
|
|
export const V2Date = forwardRef<HTMLDivElement, V2DateProps>((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}
|
|
placeholder={config.placeholder}
|
|
/>
|
|
);
|
|
|
|
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}
|
|
/>
|
|
);
|
|
}
|
|
};
|
|
|
|
const showLabel = label && style?.labelDisplay !== false && style?.labelDisplay !== "false";
|
|
const componentWidth = size?.width || style?.width;
|
|
const componentHeight = size?.height || style?.height;
|
|
|
|
// 라벨 위치 및 높이 계산
|
|
const labelPos = style?.labelPosition || "top";
|
|
const isHorizLabel = labelPos === "left" || labelPos === "right";
|
|
const labelFontSize = style?.labelFontSize ? parseInt(String(style.labelFontSize)) : 14;
|
|
const labelMarginBottom = style?.labelMarginBottom ? parseInt(String(style.labelMarginBottom)) : 4;
|
|
const estimatedLabelHeight = labelFontSize + labelMarginBottom + 2;
|
|
const labelGapValue = style?.labelGap || "8px";
|
|
|
|
const labelElement = showLabel ? (
|
|
<Label
|
|
htmlFor={id}
|
|
style={{
|
|
...(labelPos === "top" ? { position: "absolute" as const, top: `-${estimatedLabelHeight}px`, left: 0 } : {}),
|
|
...(labelPos === "bottom" ? { position: "absolute" as const, bottom: `-${estimatedLabelHeight}px`, left: 0 } : {}),
|
|
fontSize: style?.labelFontSize || "14px",
|
|
color: style?.labelColor || "#64748b",
|
|
fontWeight: style?.labelFontWeight || "500",
|
|
}}
|
|
className="text-sm font-medium whitespace-nowrap"
|
|
>
|
|
{label}{required && <span className="text-orange-500">*</span>}
|
|
</Label>
|
|
) : null;
|
|
|
|
const dateContent = (
|
|
<div className={isHorizLabel ? "min-w-0 flex-1" : "h-full w-full"} style={isHorizLabel ? { height: "100%" } : undefined}>
|
|
{renderDatePicker()}
|
|
</div>
|
|
);
|
|
|
|
if (isHorizLabel && showLabel) {
|
|
return (
|
|
<div
|
|
ref={ref}
|
|
id={id}
|
|
style={{
|
|
width: componentWidth,
|
|
height: componentHeight,
|
|
display: "flex",
|
|
flexDirection: labelPos === "left" ? "row" : "row-reverse",
|
|
alignItems: "center",
|
|
gap: labelGapValue,
|
|
}}
|
|
>
|
|
{labelElement}
|
|
{dateContent}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div
|
|
ref={ref}
|
|
id={id}
|
|
className="relative"
|
|
style={{
|
|
width: componentWidth,
|
|
height: componentHeight,
|
|
}}
|
|
>
|
|
{labelElement}
|
|
{dateContent}
|
|
</div>
|
|
);
|
|
});
|
|
|
|
V2Date.displayName = "V2Date";
|
|
|
|
export default V2Date;
|