DatePicker 공통화: - date-picker.tsx 공통 컴포넌트 신규 추가 - 전체 폼 컴포넌트 DatePicker 통일 적용 (50+ 파일) - DateRangeSelector 개선 공정관리: - RuleModal 대폭 리팩토링 (-592줄 → 간소화) - ProcessForm, StepForm 개선 - ProcessDetail 수정, actions 확장 작업자화면: - WorkerScreen 기능 대폭 확장 (+543줄) - WorkItemCard 개선 - types 확장 회계/인사/영업/품질: - BadDebtDetail, BillDetail, DepositDetail, SalesDetail 등 DatePicker 적용 - EmployeeForm, VacationDialog 등 DatePicker 적용 - OrderRegistration, QuoteRegistration DatePicker 적용 - InspectionCreate, InspectionDetail DatePicker 적용 공사관리/CEO대시보드: - BiddingDetail, ContractDetail, HandoverReport 등 DatePicker 적용 - ScheduleDetailModal, TodayIssueSection 개선 기타: - WorkOrderCreate/Edit/Detail/List 개선 - ShipmentCreate/Edit, ReceivingDetail 개선 - calendar, calendarEvents 수정 - datepicker 마이그레이션 체크리스트 추가 Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
279 lines
9.7 KiB
TypeScript
279 lines
9.7 KiB
TypeScript
"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 { cn } from "./utils";
|
|
import { Button } from "./button";
|
|
import { Calendar } from "./calendar";
|
|
import { Popover, PopoverContent, PopoverTrigger } from "./popover";
|
|
|
|
interface DatePickerProps {
|
|
/** 선택된 날짜 (YYYY-MM-DD 형식 문자열) */
|
|
value?: string;
|
|
/** 날짜 변경 핸들러 */
|
|
onChange?: (date: string) => void;
|
|
/** 플레이스홀더 텍스트 */
|
|
placeholder?: string;
|
|
/** 비활성화 여부 */
|
|
disabled?: boolean;
|
|
/** 추가 className */
|
|
className?: string;
|
|
/** 트리거 버튼 크기 */
|
|
size?: "default" | "sm" | "lg";
|
|
/** 날짜 표시 형식 (date-fns format) */
|
|
displayFormat?: string;
|
|
/** 최소 선택 가능 날짜 */
|
|
minDate?: Date;
|
|
/** 최대 선택 가능 날짜 */
|
|
maxDate?: Date;
|
|
/** 팝오버 정렬 (start, center, end) */
|
|
align?: "start" | "center" | "end";
|
|
/** 팝오버 위치 (top, right, bottom, left) */
|
|
side?: "top" | "right" | "bottom" | "left";
|
|
/** 팝오버 사이드 오프셋 */
|
|
sideOffset?: number;
|
|
}
|
|
|
|
const MONTH_LABELS = [
|
|
"1월", "2월", "3월", "4월", "5월", "6월",
|
|
"7월", "8월", "9월", "10월", "11월", "12월",
|
|
];
|
|
|
|
/**
|
|
* DatePicker 컴포넌트
|
|
*
|
|
* 공휴일/주말 색상 표시가 포함된 커스텀 날짜 선택기
|
|
* 기존 input[type="date"]를 대체하여 사용
|
|
*
|
|
* @example
|
|
* // 기본 사용
|
|
* <DatePicker value={date} onChange={setDate} />
|
|
*
|
|
* // 형식 지정
|
|
* <DatePicker value={date} onChange={setDate} displayFormat="yyyy년 MM월 dd일" />
|
|
*/
|
|
function DatePicker({
|
|
value,
|
|
onChange,
|
|
placeholder = "날짜 선택",
|
|
disabled = false,
|
|
className,
|
|
size = "default",
|
|
displayFormat = "yyyy-MM-dd",
|
|
minDate,
|
|
maxDate,
|
|
align = "center",
|
|
side = "bottom",
|
|
sideOffset = 8,
|
|
}: DatePickerProps) {
|
|
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());
|
|
|
|
// 문자열 → Date 변환
|
|
const selectedDate = React.useMemo(() => {
|
|
if (!value) return undefined;
|
|
const parsed = parse(value, "yyyy-MM-dd", new Date());
|
|
return isValid(parsed) ? parsed : undefined;
|
|
}, [value]);
|
|
|
|
// 팝오버 열릴 때 리셋
|
|
React.useEffect(() => {
|
|
if (open) {
|
|
const date = selectedDate ?? new Date();
|
|
setDisplayMonth(date);
|
|
setPickerYear(date.getFullYear());
|
|
setShowMonthPicker(false);
|
|
}
|
|
}, [open, selectedDate]);
|
|
|
|
// 날짜 선택 핸들러
|
|
const handleSelect = React.useCallback(
|
|
(date: Date | undefined) => {
|
|
if (date && onChange) {
|
|
onChange(format(date, "yyyy-MM-dd"));
|
|
}
|
|
setOpen(false);
|
|
},
|
|
[onChange]
|
|
);
|
|
|
|
// 연월 피커에서 월 선택
|
|
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 (!selectedDate) return placeholder;
|
|
return format(selectedDate, displayFormat, { locale: ko });
|
|
}, [selectedDate, 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",
|
|
!selectedDate && "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} side={side} sideOffset={sideOffset}>
|
|
<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="single"
|
|
selected={selectedDate}
|
|
onSelect={handleSelect}
|
|
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",
|
|
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",
|
|
}}
|
|
/>
|
|
{/* 오늘 버튼 */}
|
|
<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 { DatePicker };
|
|
export type { DatePickerProps };
|