Merge branch 'jskim-node' of http://39.117.244.52:3000/kjs/ERP-node into gbpark-node
; Please enter a commit message to explain why this merge is necessary, ; especially if it merges an updated upstream into a topic branch. ; ; Lines starting with ';' will be ignored, and an empty message aborts ; the commit.
This commit is contained in:
@@ -10,14 +10,13 @@
|
||||
* - range 옵션: 범위 선택 (시작~종료)
|
||||
*/
|
||||
|
||||
import React, { forwardRef, useCallback, useMemo, useState } from "react";
|
||||
import { format, parse, isValid } from "date-fns";
|
||||
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 } from "lucide-react";
|
||||
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 { Calendar } from "@/components/ui/calendar";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { V2DateProps, V2DateType } from "@/types/v2-components";
|
||||
@@ -60,11 +59,24 @@ function formatDate(date: Date | undefined, formatStr: string): string {
|
||||
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<
|
||||
HTMLButtonElement,
|
||||
HTMLDivElement,
|
||||
{
|
||||
value?: string;
|
||||
onChange?: (value: string) => void;
|
||||
@@ -83,80 +95,227 @@ const SingleDatePicker = forwardRef<
|
||||
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 minDateObj = useMemo(() => parseDate(minDate, dateFormat), [minDate, dateFormat]);
|
||||
const maxDateObj = useMemo(() => parseDate(maxDate, dateFormat), [maxDate, dateFormat]);
|
||||
|
||||
// 표시할 날짜 텍스트 계산 (ISO 형식이면 포맷팅, 아니면 그대로)
|
||||
const displayText = useMemo(() => {
|
||||
if (!value) return "";
|
||||
// Date 객체로 변환 후 포맷팅
|
||||
if (date && isValid(date)) {
|
||||
return formatDate(date, dateFormat);
|
||||
}
|
||||
if (date && isValid(date)) return formatDate(date, dateFormat);
|
||||
return value;
|
||||
}, [value, date, dateFormat]);
|
||||
|
||||
const handleSelect = useCallback(
|
||||
(selectedDate: Date | undefined) => {
|
||||
if (selectedDate) {
|
||||
onChange?.(formatDate(selectedDate, dateFormat));
|
||||
setOpen(false);
|
||||
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);
|
||||
}
|
||||
},
|
||||
[dateFormat, onChange],
|
||||
);
|
||||
} 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={setOpen}>
|
||||
<Popover open={open} onOpenChange={(v) => { if (!v) { setOpen(false); setIsTyping(false); } }}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
<div
|
||||
ref={ref}
|
||||
variant="outline"
|
||||
disabled={disabled || readonly}
|
||||
className={cn(
|
||||
"h-full w-full justify-start text-left font-normal",
|
||||
!displayText && "text-muted-foreground",
|
||||
"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="mr-2 h-4 w-4 flex-shrink-0" />
|
||||
{displayText || placeholder}
|
||||
</Button>
|
||||
<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">
|
||||
<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}>
|
||||
오늘
|
||||
<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>
|
||||
</>
|
||||
)}
|
||||
<Button variant="ghost" size="sm" onClick={handleClear}>
|
||||
초기화
|
||||
</Button>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
@@ -168,6 +327,149 @@ 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,
|
||||
{
|
||||
@@ -186,102 +488,38 @@ const RangeDatePicker = forwardRef<
|
||||
|
||||
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);
|
||||
(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 | undefined) => {
|
||||
if (date) {
|
||||
const newEnd = formatDate(date, dateFormat);
|
||||
// 종료일이 시작일보다 작으면 시작일도 같이 변경
|
||||
if (startDate && date < startDate) {
|
||||
onChange?.([newEnd, newEnd]);
|
||||
} else {
|
||||
onChange?.([value[0], newEnd]);
|
||||
}
|
||||
setOpenEnd(false);
|
||||
(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)}>
|
||||
{/* 시작 날짜 */}
|
||||
<Popover open={openStart} onOpenChange={setOpenStart}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
disabled={disabled || readonly}
|
||||
className={cn("h-full 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>
|
||||
|
||||
<RangeCalendarPopover open={openStart} onOpenChange={setOpenStart} selectedDate={startDate} onSelect={handleStartSelect} label="시작일" disabled={disabled} readonly={readonly} displayValue={value[0]} />
|
||||
<span className="text-muted-foreground">~</span>
|
||||
|
||||
{/* 종료 날짜 */}
|
||||
<Popover open={openEnd} onOpenChange={setOpenEnd}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
disabled={disabled || readonly}
|
||||
className={cn("h-full 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>
|
||||
<RangeCalendarPopover open={openEnd} onOpenChange={setOpenEnd} selectedDate={endDate} onSelect={handleEndSelect} label="종료일" disabled={disabled} readonly={readonly} displayValue={value[1]} />
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user