refactor(WEB): 프론트엔드 대규모 코드 정리 및 리팩토링
- 미사용 코드 삭제: ThemeContext, itemStore, utils/date.ts, utils/formatAmount.ts - 유틸리티 이동: date, formatAmount → src/lib/utils/ (중앙 집중화) - 다수 page.tsx 클라이언트 컴포넌트 패턴 통일 - DateRangeSelector 리팩토링 및 date-range-picker UI 컴포넌트 추가 - ThemeSelect/themeStore Zustand 직접 연동으로 전환 - 건설/회계/영업/품목/출하 등 전반적 컴포넌트 개선 - UniversalListPage, IntegratedListTemplateV2 타입 확장 - 프론트엔드 종합 리뷰 문서 및 개선 체크리스트 추가 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
320
src/components/ui/date-range-picker.tsx
Normal file
320
src/components/ui/date-range-picker.tsx
Normal file
@@ -0,0 +1,320 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import { format, parse, isValid } from "date-fns";
|
||||
import { ko } from "date-fns/locale";
|
||||
import { CalendarIcon, ChevronLeft, ChevronRight } from "lucide-react";
|
||||
import type { DateRange } from "react-day-picker";
|
||||
|
||||
import { cn } from "./utils";
|
||||
import { Button } from "./button";
|
||||
import { Calendar } from "./calendar";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "./popover";
|
||||
|
||||
interface DateRangePickerProps {
|
||||
/** 시작 날짜 (yyyy-MM-dd 형식) */
|
||||
startDate?: string;
|
||||
/** 종료 날짜 (yyyy-MM-dd 형식) */
|
||||
endDate?: string;
|
||||
/** 시작 날짜 변경 핸들러 */
|
||||
onStartDateChange?: (date: string) => void;
|
||||
/** 종료 날짜 변경 핸들러 */
|
||||
onEndDateChange?: (date: string) => void;
|
||||
/** 플레이스홀더 텍스트 */
|
||||
placeholder?: string;
|
||||
/** 비활성화 여부 */
|
||||
disabled?: boolean;
|
||||
/** 추가 className */
|
||||
className?: string;
|
||||
/** 트리거 버튼 크기 */
|
||||
size?: "default" | "sm" | "lg";
|
||||
/** 날짜 표시 형식 (date-fns format) */
|
||||
displayFormat?: string;
|
||||
/** 최소 선택 가능 날짜 */
|
||||
minDate?: Date;
|
||||
/** 최대 선택 가능 날짜 */
|
||||
maxDate?: Date;
|
||||
/** 팝오버 정렬 */
|
||||
align?: "start" | "center" | "end";
|
||||
}
|
||||
|
||||
const MONTH_LABELS = [
|
||||
"1월", "2월", "3월", "4월", "5월", "6월",
|
||||
"7월", "8월", "9월", "10월", "11월", "12월",
|
||||
];
|
||||
|
||||
function DateRangePicker({
|
||||
startDate,
|
||||
endDate,
|
||||
onStartDateChange,
|
||||
onEndDateChange,
|
||||
placeholder = "기간 선택",
|
||||
disabled = false,
|
||||
className,
|
||||
size = "default",
|
||||
displayFormat = "yyyy년 MM월 dd일",
|
||||
minDate,
|
||||
maxDate,
|
||||
align = "start",
|
||||
}: DateRangePickerProps) {
|
||||
const [open, setOpen] = React.useState(false);
|
||||
const [displayMonth, setDisplayMonth] = React.useState<Date | undefined>();
|
||||
const [showMonthPicker, setShowMonthPicker] = React.useState(false);
|
||||
const [pickerYear, setPickerYear] = React.useState(new Date().getFullYear());
|
||||
|
||||
// 내부 범위 상태 (팝오버 내 선택 중간 상태 관리)
|
||||
const [internalRange, setInternalRange] = React.useState<DateRange | undefined>();
|
||||
// 선택 단계: 'idle' → 'selecting-end' (시작일 선택 후 종료일 대기)
|
||||
const [selectPhase, setSelectPhase] = React.useState<'idle' | 'selecting-end'>('idle');
|
||||
|
||||
// 문자열 → Date 변환 (외부 prop → 버튼 텍스트용)
|
||||
const parsedStart = React.useMemo(() => {
|
||||
if (!startDate) return undefined;
|
||||
const parsed = parse(startDate, "yyyy-MM-dd", new Date());
|
||||
return isValid(parsed) ? parsed : undefined;
|
||||
}, [startDate]);
|
||||
|
||||
const parsedEnd = React.useMemo(() => {
|
||||
if (!endDate) return undefined;
|
||||
const parsed = parse(endDate, "yyyy-MM-dd", new Date());
|
||||
return isValid(parsed) ? parsed : undefined;
|
||||
}, [endDate]);
|
||||
|
||||
// 팝오버 열릴 때 → 기존 범위 표시, idle 상태로 시작
|
||||
React.useEffect(() => {
|
||||
if (open) {
|
||||
const date = parsedStart ?? new Date();
|
||||
setDisplayMonth(date);
|
||||
setPickerYear(date.getFullYear());
|
||||
setShowMonthPicker(false);
|
||||
// 기존 범위를 내부 상태에 반영 (하이라이트 표시용)
|
||||
setInternalRange(parsedStart ? { from: parsedStart, to: parsedEnd } : undefined);
|
||||
setSelectPhase('idle');
|
||||
}
|
||||
}, [open, parsedStart, parsedEnd]);
|
||||
|
||||
// 날짜 클릭 핸들러 (react-day-picker onSelect 우회, 직접 2단계 관리)
|
||||
const handleDayClick = React.useCallback(
|
||||
(day: Date) => {
|
||||
if (selectPhase === 'idle') {
|
||||
// 첫 클릭 → 시작일만 설정, 종료일 대기
|
||||
setInternalRange({ from: day, to: undefined });
|
||||
setSelectPhase('selecting-end');
|
||||
} else {
|
||||
// 두 번째 클릭 → 종료일 설정
|
||||
const from = internalRange?.from;
|
||||
if (!from) return;
|
||||
|
||||
let rangeFrom = from;
|
||||
let rangeTo = day;
|
||||
|
||||
// 종료일이 시작일보다 이전이면 스왑
|
||||
if (rangeTo < rangeFrom) {
|
||||
[rangeFrom, rangeTo] = [rangeTo, rangeFrom];
|
||||
}
|
||||
|
||||
// 외부 상태 반영
|
||||
onStartDateChange?.(format(rangeFrom, "yyyy-MM-dd"));
|
||||
onEndDateChange?.(format(rangeTo, "yyyy-MM-dd"));
|
||||
|
||||
// 내부 상태 업데이트 & 팝오버 닫기
|
||||
setInternalRange({ from: rangeFrom, to: rangeTo });
|
||||
setSelectPhase('idle');
|
||||
setOpen(false);
|
||||
}
|
||||
},
|
||||
[selectPhase, internalRange, onStartDateChange, onEndDateChange]
|
||||
);
|
||||
|
||||
// react-day-picker onSelect (내부 하이라이트 업데이트용, 닫기 로직은 handleDayClick에서 처리)
|
||||
const handleSelect = React.useCallback(
|
||||
(_range: DateRange | undefined) => {
|
||||
// handleDayClick에서 모든 로직 처리, 여기서는 무시
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
// 연월 피커에서 월 선택
|
||||
const handleMonthSelect = React.useCallback(
|
||||
(month: number) => {
|
||||
setDisplayMonth(new Date(pickerYear, month, 1));
|
||||
setShowMonthPicker(false);
|
||||
},
|
||||
[pickerYear]
|
||||
);
|
||||
|
||||
// 연월 텍스트 클릭 → 연월 피커 열기
|
||||
const handleCaptionClick = React.useCallback(() => {
|
||||
setPickerYear(displayMonth?.getFullYear() ?? new Date().getFullYear());
|
||||
setShowMonthPicker(true);
|
||||
}, [displayMonth]);
|
||||
|
||||
// 오늘로 이동
|
||||
const handleGoToToday = React.useCallback(() => {
|
||||
const today = new Date();
|
||||
setDisplayMonth(today);
|
||||
setShowMonthPicker(false);
|
||||
}, []);
|
||||
|
||||
// 표시 텍스트
|
||||
const displayText = React.useMemo(() => {
|
||||
if (!parsedStart && !parsedEnd) return placeholder;
|
||||
const startText = parsedStart
|
||||
? format(parsedStart, displayFormat, { locale: ko })
|
||||
: "...";
|
||||
const endText = parsedEnd
|
||||
? format(parsedEnd, displayFormat, { locale: ko })
|
||||
: "...";
|
||||
return `${startText} ~ ${endText}`;
|
||||
}, [parsedStart, parsedEnd, displayFormat, placeholder]);
|
||||
|
||||
// 버튼 크기 스타일
|
||||
const sizeClasses = {
|
||||
default: "h-10 px-3",
|
||||
sm: "h-8 px-2 text-sm",
|
||||
lg: "h-12 px-4 text-base",
|
||||
};
|
||||
|
||||
return (
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
disabled={disabled}
|
||||
className={cn(
|
||||
"w-full justify-start text-left font-normal",
|
||||
!parsedStart && "text-muted-foreground",
|
||||
sizeClasses[size],
|
||||
className
|
||||
)}
|
||||
>
|
||||
<CalendarIcon className="mr-2 h-4 w-4 shrink-0" />
|
||||
<span className="truncate">{displayText}</span>
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-auto p-0" align={align}>
|
||||
<div className="p-3">
|
||||
{showMonthPicker ? (
|
||||
/* 연월 선택 피커 */
|
||||
<div className="w-[252px]">
|
||||
{/* 연도 네비게이션 */}
|
||||
<div className="flex items-center justify-between h-10 mb-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setPickerYear((y) => y - 1)}
|
||||
className="h-7 w-7 flex items-center justify-center rounded-md hover:bg-accent transition-colors"
|
||||
>
|
||||
<ChevronLeft className="h-4 w-4" />
|
||||
</button>
|
||||
<span className="text-sm font-medium">{pickerYear}년</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setPickerYear((y) => y + 1)}
|
||||
className="h-7 w-7 flex items-center justify-center rounded-md hover:bg-accent transition-colors"
|
||||
>
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
{/* 월 그리드 (4행 3열) */}
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
{MONTH_LABELS.map((label, i) => {
|
||||
const isActive =
|
||||
displayMonth?.getMonth() === i &&
|
||||
displayMonth?.getFullYear() === pickerYear;
|
||||
return (
|
||||
<button
|
||||
key={i}
|
||||
type="button"
|
||||
onClick={() => handleMonthSelect(i)}
|
||||
className={cn(
|
||||
"h-9 rounded-lg text-sm font-medium transition-colors",
|
||||
"hover:bg-accent hover:text-accent-foreground",
|
||||
isActive &&
|
||||
"bg-primary text-primary-foreground hover:bg-primary hover:text-primary-foreground"
|
||||
)}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{/* 오늘 버튼 */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleGoToToday}
|
||||
className="w-full mt-2 h-8 text-sm font-medium text-primary hover:bg-accent rounded-md transition-colors"
|
||||
>
|
||||
오늘
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
/* 달력 뷰 */
|
||||
<div className="relative">
|
||||
{/* 연월 텍스트 클릭 영역 (화살표 사이) */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleCaptionClick}
|
||||
className="absolute top-0 left-8 right-8 h-10 z-20 cursor-pointer hover:bg-accent/50 rounded-md transition-colors"
|
||||
/>
|
||||
<Calendar
|
||||
mode="range"
|
||||
selected={internalRange}
|
||||
onSelect={handleSelect}
|
||||
onDayClick={handleDayClick}
|
||||
disabled={(date) => {
|
||||
if (minDate && date < minDate) return true;
|
||||
if (maxDate && date > maxDate) return true;
|
||||
return false;
|
||||
}}
|
||||
month={displayMonth}
|
||||
onMonthChange={setDisplayMonth}
|
||||
classNames={{
|
||||
months: "flex flex-col relative",
|
||||
month: "relative",
|
||||
month_caption: "flex justify-center items-center h-10",
|
||||
caption_label: "text-sm font-medium",
|
||||
nav: "absolute top-0 left-1 right-1 h-10 flex items-center justify-between z-10",
|
||||
button_previous: cn(
|
||||
"h-7 w-7 bg-transparent p-0 hover:bg-accent hover:text-accent-foreground rounded-md flex items-center justify-center"
|
||||
),
|
||||
button_next: cn(
|
||||
"h-7 w-7 bg-transparent p-0 hover:bg-accent hover:text-accent-foreground rounded-md flex items-center justify-center"
|
||||
),
|
||||
month_grid: "w-full border-collapse space-y-1",
|
||||
weekdays: "flex",
|
||||
weekday:
|
||||
"text-muted-foreground rounded-md w-9 font-normal text-[0.8rem] text-center",
|
||||
week: "flex w-full mt-2",
|
||||
day: "h-9 w-9 text-center text-sm p-0 relative [&:has(>.range-end)]:rounded-r-md [&:has(>.range-start)]:rounded-l-md first:[&:has([aria-selected])]:rounded-l-md last:[&:has([aria-selected])]:rounded-r-md",
|
||||
day_button: cn(
|
||||
"h-9 w-9 p-0 font-normal rounded-md hover:bg-accent hover:text-accent-foreground flex items-center justify-center"
|
||||
),
|
||||
selected:
|
||||
"bg-primary text-primary-foreground hover:bg-primary hover:text-primary-foreground rounded-lg",
|
||||
today: "bg-orange-100 text-orange-600 font-bold rounded-lg",
|
||||
outside: "text-muted-foreground/40",
|
||||
disabled: "opacity-50 cursor-not-allowed",
|
||||
hidden: "invisible",
|
||||
range_start: "range-start aria-selected:bg-primary aria-selected:text-primary-foreground rounded-l-lg",
|
||||
range_end: "range-end aria-selected:bg-primary aria-selected:text-primary-foreground rounded-r-lg",
|
||||
range_middle: "aria-selected:bg-accent aria-selected:text-accent-foreground",
|
||||
}}
|
||||
/>
|
||||
{/* 오늘 버튼 */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleGoToToday}
|
||||
className="w-full mt-2 h-8 text-sm font-medium text-primary hover:bg-accent rounded-md transition-colors"
|
||||
>
|
||||
오늘
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
|
||||
export { DateRangePicker };
|
||||
export type { DateRangePickerProps };
|
||||
Reference in New Issue
Block a user