264 lines
7.0 KiB
TypeScript
264 lines
7.0 KiB
TypeScript
|
|
"use client";
|
||
|
|
|
||
|
|
import * as React from "react";
|
||
|
|
import { ChevronLeft, ChevronRight } from "lucide-react";
|
||
|
|
import { cn } from "@/lib/utils";
|
||
|
|
import { Button } from "@/components/ui/button";
|
||
|
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||
|
|
|
||
|
|
interface CustomCalendarProps {
|
||
|
|
selected?: Date;
|
||
|
|
onSelect?: (date: Date | undefined) => void;
|
||
|
|
className?: string;
|
||
|
|
mode?: "single";
|
||
|
|
size?: "sm" | "default" | "lg";
|
||
|
|
}
|
||
|
|
|
||
|
|
const DAYS = ["Su", "Mo", "Tu", "We", "Th", "Fr", "Sa"];
|
||
|
|
const MONTHS = [
|
||
|
|
"January",
|
||
|
|
"February",
|
||
|
|
"March",
|
||
|
|
"April",
|
||
|
|
"May",
|
||
|
|
"June",
|
||
|
|
"July",
|
||
|
|
"August",
|
||
|
|
"September",
|
||
|
|
"October",
|
||
|
|
"November",
|
||
|
|
"December",
|
||
|
|
];
|
||
|
|
|
||
|
|
export function CustomCalendar({
|
||
|
|
selected,
|
||
|
|
onSelect,
|
||
|
|
className,
|
||
|
|
mode = "single",
|
||
|
|
size = "default",
|
||
|
|
}: CustomCalendarProps) {
|
||
|
|
// 크기별 클래스 정의
|
||
|
|
const sizeClasses = {
|
||
|
|
sm: {
|
||
|
|
cell: "h-7 w-7 text-xs",
|
||
|
|
header: "text-xs",
|
||
|
|
day: "text-[0.7rem]",
|
||
|
|
nav: "h-6 w-6",
|
||
|
|
},
|
||
|
|
default: {
|
||
|
|
cell: "h-9 w-9 text-sm",
|
||
|
|
header: "text-[0.8rem]",
|
||
|
|
day: "text-sm",
|
||
|
|
nav: "h-7 w-7",
|
||
|
|
},
|
||
|
|
lg: {
|
||
|
|
cell: "h-11 w-11 text-base",
|
||
|
|
header: "text-sm",
|
||
|
|
day: "text-base",
|
||
|
|
nav: "h-8 w-8",
|
||
|
|
},
|
||
|
|
};
|
||
|
|
|
||
|
|
const currentSize = sizeClasses[size];
|
||
|
|
const [currentDate, setCurrentDate] = React.useState(selected || new Date());
|
||
|
|
const [viewYear, setViewYear] = React.useState(currentDate.getFullYear());
|
||
|
|
const [viewMonth, setViewMonth] = React.useState(currentDate.getMonth());
|
||
|
|
|
||
|
|
const getDaysInMonth = (year: number, month: number) => {
|
||
|
|
return new Date(year, month + 1, 0).getDate();
|
||
|
|
};
|
||
|
|
|
||
|
|
const getFirstDayOfMonth = (year: number, month: number) => {
|
||
|
|
return new Date(year, month, 1).getDay();
|
||
|
|
};
|
||
|
|
|
||
|
|
const generateCalendarDays = () => {
|
||
|
|
const daysInMonth = getDaysInMonth(viewYear, viewMonth);
|
||
|
|
const firstDay = getFirstDayOfMonth(viewYear, viewMonth);
|
||
|
|
const daysInPrevMonth = getDaysInMonth(viewYear, viewMonth - 1);
|
||
|
|
|
||
|
|
const days: Array<{
|
||
|
|
date: number;
|
||
|
|
month: "prev" | "current" | "next";
|
||
|
|
fullDate: Date;
|
||
|
|
}> = [];
|
||
|
|
|
||
|
|
// Previous month days
|
||
|
|
for (let i = firstDay - 1; i >= 0; i--) {
|
||
|
|
const date = daysInPrevMonth - i;
|
||
|
|
days.push({
|
||
|
|
date,
|
||
|
|
month: "prev",
|
||
|
|
fullDate: new Date(viewYear, viewMonth - 1, date),
|
||
|
|
});
|
||
|
|
}
|
||
|
|
|
||
|
|
// Current month days
|
||
|
|
for (let i = 1; i <= daysInMonth; i++) {
|
||
|
|
days.push({
|
||
|
|
date: i,
|
||
|
|
month: "current",
|
||
|
|
fullDate: new Date(viewYear, viewMonth, i),
|
||
|
|
});
|
||
|
|
}
|
||
|
|
|
||
|
|
// Next month days
|
||
|
|
const remainingDays = 42 - days.length; // 6 rows * 7 days
|
||
|
|
for (let i = 1; i <= remainingDays; i++) {
|
||
|
|
days.push({
|
||
|
|
date: i,
|
||
|
|
month: "next",
|
||
|
|
fullDate: new Date(viewYear, viewMonth + 1, i),
|
||
|
|
});
|
||
|
|
}
|
||
|
|
|
||
|
|
return days;
|
||
|
|
};
|
||
|
|
|
||
|
|
const handlePrevMonth = () => {
|
||
|
|
if (viewMonth === 0) {
|
||
|
|
setViewMonth(11);
|
||
|
|
setViewYear(viewYear - 1);
|
||
|
|
} else {
|
||
|
|
setViewMonth(viewMonth - 1);
|
||
|
|
}
|
||
|
|
};
|
||
|
|
|
||
|
|
const handleNextMonth = () => {
|
||
|
|
if (viewMonth === 11) {
|
||
|
|
setViewMonth(0);
|
||
|
|
setViewYear(viewYear + 1);
|
||
|
|
} else {
|
||
|
|
setViewMonth(viewMonth + 1);
|
||
|
|
}
|
||
|
|
};
|
||
|
|
|
||
|
|
const handleDateClick = (date: Date) => {
|
||
|
|
if (onSelect) {
|
||
|
|
onSelect(date);
|
||
|
|
}
|
||
|
|
};
|
||
|
|
|
||
|
|
const isToday = (date: Date) => {
|
||
|
|
const today = new Date();
|
||
|
|
return (
|
||
|
|
date.getDate() === today.getDate() &&
|
||
|
|
date.getMonth() === today.getMonth() &&
|
||
|
|
date.getFullYear() === today.getFullYear()
|
||
|
|
);
|
||
|
|
};
|
||
|
|
|
||
|
|
const isSelected = (date: Date) => {
|
||
|
|
if (!selected) return false;
|
||
|
|
return (
|
||
|
|
date.getDate() === selected.getDate() &&
|
||
|
|
date.getMonth() === selected.getMonth() &&
|
||
|
|
date.getFullYear() === selected.getFullYear()
|
||
|
|
);
|
||
|
|
};
|
||
|
|
|
||
|
|
const calendarDays = generateCalendarDays();
|
||
|
|
|
||
|
|
return (
|
||
|
|
<div className={cn("p-3", className)}>
|
||
|
|
{/* Header */}
|
||
|
|
<div className="flex items-center justify-between gap-2 pb-4">
|
||
|
|
<Button
|
||
|
|
variant="outline"
|
||
|
|
size="icon"
|
||
|
|
className={cn("bg-transparent p-0 opacity-50 hover:opacity-100", currentSize.nav)}
|
||
|
|
onClick={handlePrevMonth}
|
||
|
|
>
|
||
|
|
<ChevronLeft className="h-4 w-4" />
|
||
|
|
</Button>
|
||
|
|
|
||
|
|
<div className="flex items-center gap-2">
|
||
|
|
{/* 월 선택 */}
|
||
|
|
<Select value={viewMonth.toString()} onValueChange={(value) => setViewMonth(parseInt(value))}>
|
||
|
|
<SelectTrigger className={cn("w-[110px] font-medium", currentSize.header)}>
|
||
|
|
<SelectValue />
|
||
|
|
</SelectTrigger>
|
||
|
|
<SelectContent>
|
||
|
|
{MONTHS.map((month, index) => (
|
||
|
|
<SelectItem key={index} value={index.toString()}>
|
||
|
|
{month}
|
||
|
|
</SelectItem>
|
||
|
|
))}
|
||
|
|
</SelectContent>
|
||
|
|
</Select>
|
||
|
|
|
||
|
|
{/* 연도 선택 */}
|
||
|
|
<Select value={viewYear.toString()} onValueChange={(value) => setViewYear(parseInt(value))}>
|
||
|
|
<SelectTrigger className={cn("w-[80px] font-medium", currentSize.header)}>
|
||
|
|
<SelectValue />
|
||
|
|
</SelectTrigger>
|
||
|
|
<SelectContent>
|
||
|
|
{Array.from({ length: 100 }, (_, i) => {
|
||
|
|
const year = new Date().getFullYear() - 50 + i;
|
||
|
|
return (
|
||
|
|
<SelectItem key={year} value={year.toString()}>
|
||
|
|
{year}
|
||
|
|
</SelectItem>
|
||
|
|
);
|
||
|
|
})}
|
||
|
|
</SelectContent>
|
||
|
|
</Select>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<Button
|
||
|
|
variant="outline"
|
||
|
|
size="icon"
|
||
|
|
className={cn("bg-transparent p-0 opacity-50 hover:opacity-100", currentSize.nav)}
|
||
|
|
onClick={handleNextMonth}
|
||
|
|
>
|
||
|
|
<ChevronRight className="h-4 w-4" />
|
||
|
|
</Button>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
{/* Days of week */}
|
||
|
|
<div className="mb-2 grid grid-cols-7 gap-0">
|
||
|
|
{DAYS.map((day) => (
|
||
|
|
<div
|
||
|
|
key={day}
|
||
|
|
className={cn(
|
||
|
|
"text-muted-foreground flex items-center justify-center font-normal",
|
||
|
|
currentSize.cell,
|
||
|
|
currentSize.day,
|
||
|
|
)}
|
||
|
|
>
|
||
|
|
{day}
|
||
|
|
</div>
|
||
|
|
))}
|
||
|
|
</div>
|
||
|
|
|
||
|
|
{/* Calendar grid */}
|
||
|
|
<div className="grid grid-cols-7 gap-0">
|
||
|
|
{calendarDays.map((day, index) => {
|
||
|
|
const isOutside = day.month !== "current";
|
||
|
|
const isTodayDate = isToday(day.fullDate);
|
||
|
|
const isSelectedDate = isSelected(day.fullDate);
|
||
|
|
|
||
|
|
return (
|
||
|
|
<Button
|
||
|
|
key={index}
|
||
|
|
variant="ghost"
|
||
|
|
className={cn(
|
||
|
|
"p-0 font-normal",
|
||
|
|
currentSize.cell,
|
||
|
|
isOutside && "text-muted-foreground opacity-50",
|
||
|
|
isTodayDate && !isSelectedDate && "bg-accent text-accent-foreground",
|
||
|
|
isSelectedDate && "bg-primary text-primary-foreground hover:bg-primary hover:text-primary-foreground",
|
||
|
|
)}
|
||
|
|
onClick={() => handleDateClick(day.fullDate)}
|
||
|
|
>
|
||
|
|
{day.date}
|
||
|
|
</Button>
|
||
|
|
);
|
||
|
|
})}
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
CustomCalendar.displayName = "CustomCalendar";
|