539 lines
17 KiB
TypeScript
539 lines
17 KiB
TypeScript
import { cn } from "@/lib/utils";
|
|
import { Calendar } from "@/components/ui/calendar";
|
|
import { useEffect, useLayoutEffect, useRef, useState } from "react";
|
|
import { Popover, PopoverTrigger } from "@/components/ui/popover";
|
|
import { isSameDay, getWeeksInMonth, getWeekOfMonth, eachDayOfInterval, parse, startOfMonth, startOfWeek, endOfWeek, endOfMonth, format, isSameMonth } from "date-fns";
|
|
import { SchedulePopover } from "../schedule/SchedulePopover";
|
|
import { ScheduleNetwork } from "@/network/ScheduleNetwork";
|
|
import { ScheduleListData } from "@/data/response";
|
|
import { CustomCalendarCN } from "./CustomCalendarCN";
|
|
import { toast } from "sonner";
|
|
import type { SchedulePopoverMode } from "@/const/schedule/SchedulePopoverMode";
|
|
import { SchedulerDTO as DTO, Type } from '@baekyangdan/core-utils';
|
|
interface CustomCalendarProps {
|
|
data?: any;
|
|
}
|
|
|
|
interface EventBarPosition extends DTO.ScheduleList {
|
|
positionStyle: React.CSSProperties;
|
|
trackIndex: number;
|
|
isOverflow?: boolean;
|
|
segmentId: string;
|
|
}
|
|
|
|
const TRACK_HEIGHT = 20;
|
|
const TOP_OFFSET_FROM_CELL = 35;
|
|
const DATE_FORMAT_ARIA = 'EEEE, MMMM do, yyyy';
|
|
const DATE_FORMAT_KEY = 'yyyyMMdd';
|
|
|
|
export const CustomCalendar = ({ data }: CustomCalendarProps) => {
|
|
const [isLoading, setIsLoading] = useState(false);
|
|
const [refetchTrigger, setRefetchTrigger] = useState(0);
|
|
const [weekCount, setWeekCount] = useState(5);
|
|
const [selectedDate, setSelectedDate] = useState<Date | undefined>(undefined);
|
|
const [popoverOpen, setPopoverOpen] = useState(false);
|
|
const [popoverSide, setPopoverSide] = useState<'right' | 'left'>('right');
|
|
const [popoverAlign, setPopoverAlign] = useState<'start' | 'end'>('end');
|
|
const [popoverMode, setPopoverMode] = useState<SchedulePopoverMode>('list');
|
|
const [popoverDetailId, setPopoverDetailId] = useState('');
|
|
const [month, setMonth] = useState(new Date());
|
|
const [windowSize, setWindowSize] = useState({ width: window.innerWidth, height: window.innerHeight });
|
|
const [maxVisibleEvents, setMaxVisibleEvents] = useState(3);
|
|
const [overflowTrackIndex, setOverflowTrackIndex] = useState(maxVisibleEvents + 1);
|
|
const [scheduleList, setScheduleList] = useState<Array<DTO.ScheduleList>>([]);
|
|
const [barPositions, setBarPositions] = useState<Array<EventBarPosition>>([]);
|
|
const scheduleNetwork = new ScheduleNetwork();
|
|
const containerRef = useRef<HTMLDivElement>(null);
|
|
const cellInfoMapRef = useRef<Map<string, { cell: HTMLElement, rect: DOMRect }>>(new Map());
|
|
|
|
const updateWeekCount = () => {
|
|
if (containerRef === null) return;
|
|
if (!containerRef.current) return;
|
|
const weeks = containerRef.current.querySelectorAll('.rdp-week');
|
|
|
|
if (weeks?.length) setWeekCount(weeks.length);
|
|
}
|
|
|
|
useEffect(() => {
|
|
updateWeekCount();
|
|
const handleResize = () => {
|
|
setWindowSize({
|
|
width: window.innerWidth,
|
|
height: window.innerHeight
|
|
});
|
|
if (window.innerHeight >= 850) {
|
|
setMaxVisibleEvents(3);
|
|
setOverflowTrackIndex(4);
|
|
} else {
|
|
setMaxVisibleEvents(2);
|
|
setOverflowTrackIndex(3);
|
|
}
|
|
};
|
|
|
|
window.addEventListener('resize', handleResize);
|
|
|
|
return () => {
|
|
window.removeEventListener('resize', handleResize);
|
|
}
|
|
}, []);
|
|
|
|
// 화면 상의 달이 바뀌면 req 하는 로직
|
|
useLayoutEffect(() => {
|
|
updateWeekCount();
|
|
|
|
const reqList = async () => {
|
|
setIsLoading(true);
|
|
const requestedMonth = month;
|
|
|
|
const monthStart = startOfMonth(month);
|
|
const monthEnd = endOfMonth(month);
|
|
|
|
const startDate = startOfWeek(monthStart, { weekStartsOn: 0 });
|
|
const endDate = endOfWeek(monthEnd, { weekStartsOn: 0 });
|
|
|
|
const data = {
|
|
startDate: startDate,
|
|
endDate: endDate
|
|
} as DTO.ScheduleListRequest;
|
|
|
|
const result = await scheduleNetwork.getList(data);
|
|
|
|
if (result.success) {
|
|
if (result.data) {
|
|
if (isSameMonth(requestedMonth, month)) {
|
|
// setCurrentDataMonth(month);
|
|
setScheduleList(result.data);
|
|
}
|
|
// setScheduleList(result.data.data);
|
|
}
|
|
}
|
|
setIsLoading(false);
|
|
}
|
|
|
|
requestAnimationFrame(() => {
|
|
const reqListPromise = reqList;
|
|
|
|
toast.promise(
|
|
reqListPromise,
|
|
{
|
|
loading: `${month.getFullYear()}년 ${month.getMonth() + 1}월 일정을 불러오는 중입니다`,
|
|
success: `일정을 불러왔습니다.`,
|
|
error: `일정을 불러오는 데에 실패하였습니다.\n잠시 후 다시 시도해주십시오.`,
|
|
duration: 1000
|
|
},
|
|
)
|
|
updateWeekCount();
|
|
});
|
|
}, [month, refetchTrigger]);
|
|
|
|
const refetchList = () => {
|
|
setRefetchTrigger(prev => prev + 1);
|
|
}
|
|
|
|
// 이벤트 bar 그리는 로직
|
|
useLayoutEffect(() => {
|
|
// if (!isSameMonth(month, currentDataMonth)) {
|
|
// setBarPositions([]);
|
|
// return;
|
|
// }
|
|
|
|
if (!containerRef.current || scheduleList.length === 0) {
|
|
// setBarPositions([]);
|
|
return;
|
|
}
|
|
|
|
// setBarPositions([]);
|
|
const containerRect = containerRef.current.getBoundingClientRect();
|
|
const dayCells = containerRef.current.querySelectorAll('.rdp-day button');
|
|
|
|
const cellInfoMap = new Map<string, { cell: HTMLElement, rect: DOMRect }>();
|
|
|
|
dayCells.forEach((cell) => {
|
|
const dayButton = cell as HTMLButtonElement;
|
|
let ariaLabel = dayButton.getAttribute('aria-label');
|
|
if (ariaLabel) {
|
|
try {
|
|
if (ariaLabel.startsWith('Today, ')) {
|
|
ariaLabel = ariaLabel.replace('Today, ', '');
|
|
}
|
|
if (ariaLabel.endsWith(', selected')) {
|
|
ariaLabel = ariaLabel.replace(', selected', '');
|
|
}
|
|
const parsedDate = parse(ariaLabel, DATE_FORMAT_ARIA, new Date());
|
|
|
|
if (!isNaN(parsedDate.getTime())) {
|
|
const dateKey = format(parsedDate, DATE_FORMAT_KEY);
|
|
console.log(dateKey);
|
|
cellInfoMap.set(dateKey, {
|
|
cell: dayButton,
|
|
rect: dayButton.getBoundingClientRect()
|
|
});
|
|
|
|
}
|
|
} catch (e) {}
|
|
}
|
|
});
|
|
cellInfoMapRef.current = cellInfoMap;
|
|
|
|
const scheduleListWithTrack: (ScheduleListData & { trackIndex: number })[] = [];
|
|
const occupiedTrackList = new Map<string, number[]>();
|
|
const overflowCountMap = new Map<string, number>();
|
|
|
|
const sortedScheduleList = [...scheduleList].sort((a, b) =>
|
|
new Date(b.endDate).getTime() - new Date(b.startDate).getTime() -
|
|
(new Date(a.endDate).getTime() - new Date(a.startDate).getTime())
|
|
);
|
|
|
|
sortedScheduleList.forEach(schedule => {
|
|
const startDateObj = new Date(schedule.startDate);
|
|
const endDateObj = new Date(schedule.endDate);
|
|
|
|
const eventDays = eachDayOfInterval({ start: startDateObj, end: endDateObj });
|
|
|
|
let assignedTrack = -1;
|
|
|
|
for (let track = 0; track < maxVisibleEvents; track++) {
|
|
let isAvailable = true;
|
|
for (const day of eventDays) {
|
|
const dayKey = format(day, DATE_FORMAT_KEY);
|
|
if (cellInfoMap.has(dayKey)) {
|
|
const occupied = occupiedTrackList.get(dayKey) || [];
|
|
|
|
if (occupied.includes(track)) {
|
|
isAvailable = false;
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
if (isAvailable) {
|
|
assignedTrack = track;
|
|
break;
|
|
}
|
|
}
|
|
|
|
if (assignedTrack !== -1) {
|
|
for (const day of eventDays) {
|
|
const dayKey = format(day, DATE_FORMAT_KEY);
|
|
if (cellInfoMap.has(dayKey)) {
|
|
const occupied = occupiedTrackList.get(dayKey) || [];
|
|
occupiedTrackList.set(dayKey, [...occupied, assignedTrack]);
|
|
}
|
|
}
|
|
scheduleListWithTrack.push({
|
|
...schedule,
|
|
startDate: schedule.startDate.toISOString(),
|
|
endDate: schedule.endDate.toISOString(),
|
|
trackIndex: assignedTrack });
|
|
} else {
|
|
for (const day of eventDays) {
|
|
const dayKey = format(day, DATE_FORMAT_KEY);
|
|
if (cellInfoMap.has(dayKey)) {
|
|
overflowCountMap.set(dayKey, (overflowCountMap.get(dayKey) || 0) + 1);
|
|
}
|
|
}
|
|
}
|
|
});
|
|
|
|
const regularPositions: EventBarPosition[] = [];
|
|
|
|
// 달력을 초과한 부분에 대한 처리
|
|
const visibleDayKeys = Array.from(cellInfoMap.keys());
|
|
if (!visibleDayKeys || visibleDayKeys.length === 0) return;
|
|
|
|
const calendarStart = parse(visibleDayKeys.at(0)!, DATE_FORMAT_KEY, new Date());
|
|
const calendarEnd = parse(visibleDayKeys.at(-1)!, DATE_FORMAT_KEY, new Date());
|
|
|
|
scheduleListWithTrack.forEach(schedule => {
|
|
const startDateObj = new Date(schedule.startDate);
|
|
const endDateObj = new Date(schedule.endDate);
|
|
|
|
const renderStartDate = startDateObj > calendarStart ? startDateObj : calendarStart;
|
|
const renderEndDate = endDateObj < calendarEnd ? endDateObj : calendarEnd;
|
|
|
|
const renderStartKey = format(renderStartDate, DATE_FORMAT_KEY);
|
|
const renderEndKey = format(renderEndDate, DATE_FORMAT_KEY);
|
|
|
|
const allRenderDays = eachDayOfInterval({ start: renderStartDate, end: renderEndDate });
|
|
|
|
let segmentStartDay = renderStartDate;
|
|
|
|
allRenderDays.forEach((currentDay, index) => {
|
|
const dayOfWeek = currentDay.getDay();
|
|
const isLastDayOfEvent = index === allRenderDays.length - 1;
|
|
|
|
if (dayOfWeek === 6 && !isLastDayOfEvent) {
|
|
const segmentEndDay = currentDay;
|
|
|
|
createAndPushPosition(
|
|
regularPositions, schedule,
|
|
segmentStartDay, segmentEndDay,
|
|
cellInfoMap, containerRect
|
|
);
|
|
|
|
segmentStartDay = new Date(currentDay);
|
|
segmentStartDay.setDate(segmentStartDay.getDate() + 1);
|
|
} else if (isLastDayOfEvent) {
|
|
createAndPushPosition(
|
|
regularPositions, schedule,
|
|
segmentStartDay, currentDay,
|
|
cellInfoMap, containerRect
|
|
);
|
|
}
|
|
});
|
|
});
|
|
|
|
const overflowPositions: EventBarPosition[] = [];
|
|
overflowCountMap.forEach((count, dayKey) => {
|
|
const dayInfo = cellInfoMap.get(dayKey);
|
|
|
|
if (dayInfo) {
|
|
const dayRect = dayInfo.rect;
|
|
|
|
const baseTop = dayRect.top - containerRect.top;
|
|
const top = baseTop + TOP_OFFSET_FROM_CELL + ((overflowTrackIndex - 1) * (TRACK_HEIGHT + 5));
|
|
const left = dayRect.left - containerRect.left + 5;
|
|
const width = dayInfo.cell.clientWidth - 10;
|
|
|
|
overflowPositions.push({
|
|
id: `overflow-${dayKey}`,
|
|
name: `${count} more`,
|
|
startDate: new Date(),
|
|
endDate: new Date(),
|
|
style: '#9CA3AF',
|
|
trackIndex: overflowTrackIndex,
|
|
isOverflow: true,
|
|
segmentId: dayKey,
|
|
positionStyle: {
|
|
top: `${top}px`,
|
|
left: `${left}px`,
|
|
width: `${width}px`,
|
|
height: `${TRACK_HEIGHT}px`
|
|
}
|
|
} as EventBarPosition)
|
|
}
|
|
});
|
|
setBarPositions([...regularPositions, ...overflowPositions]);
|
|
}, [scheduleList, month, windowSize]);
|
|
|
|
const createAndPushPosition = (
|
|
positions: EventBarPosition[],
|
|
schedule: ScheduleListData & { trackIndex: number },
|
|
segmentStartDay: Date,
|
|
segmentEndDay: Date,
|
|
cellInfoMap: Map<string, { cell: HTMLElement, rect: DOMRect }>,
|
|
containerRect: DOMRect
|
|
) => {
|
|
const renderStartKey = format(segmentStartDay, DATE_FORMAT_KEY);
|
|
const renderEndKey = format(segmentEndDay, DATE_FORMAT_KEY);
|
|
|
|
const startInfo = cellInfoMap.get(renderStartKey);
|
|
const endInfo = cellInfoMap.get(renderEndKey);
|
|
|
|
if (startInfo && endInfo) {
|
|
const startRect = startInfo.rect;
|
|
const endRect = endInfo.rect;
|
|
|
|
const baseTop = startRect.top - containerRect.top;
|
|
const top = baseTop + TOP_OFFSET_FROM_CELL + (schedule.trackIndex * (TRACK_HEIGHT + 5));
|
|
|
|
const left = startRect.left - containerRect.left + 5;
|
|
const width = endRect.right - startRect.left - 10;
|
|
|
|
positions.push({
|
|
...schedule,
|
|
type: schedule.type as Type.Type,
|
|
status: schedule.status as Type.Status,
|
|
startDate: new Date(schedule.startDate),
|
|
endDate: new Date(schedule.endDate),
|
|
trackIndex: schedule.trackIndex,
|
|
id: schedule.id,
|
|
segmentId: `${schedule.id}-${renderStartKey}`,
|
|
positionStyle: {
|
|
top: `${top}px`,
|
|
left: `${left}px`,
|
|
width: `${width}px`,
|
|
height: `${TRACK_HEIGHT}px`
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
const handleMonthChange = async (month: Date) => {
|
|
if (isLoading) return;
|
|
setMonth(month);
|
|
}
|
|
|
|
const handleOpenChange = (open: boolean) => {
|
|
setPopoverOpen(open);
|
|
if (!open) {
|
|
setTimeout(() => {
|
|
setSelectedDate(undefined);
|
|
}, 150);
|
|
}
|
|
}
|
|
|
|
const handleDaySelect = (date: Date | undefined) => {
|
|
if (!date) {
|
|
setPopoverOpen(false);
|
|
setTimeout(() => {
|
|
setSelectedDate(undefined);
|
|
setPopoverDetailId('');
|
|
setPopoverMode('list');
|
|
}, 150);
|
|
return;
|
|
}
|
|
if (date) {
|
|
setSelectedDate(date);
|
|
|
|
const dayOfWeek = date.getDay();
|
|
|
|
if (0 <= dayOfWeek && dayOfWeek < 4) {
|
|
setPopoverSide('right');
|
|
} else {
|
|
setPopoverSide('left');
|
|
}
|
|
|
|
const options = { weekStartsOn: 0 as 0 };
|
|
|
|
const totalWeeks = getWeeksInMonth(date, options);
|
|
|
|
const currentWeekNumber = getWeekOfMonth(date, options);
|
|
|
|
const threshold = Math.ceil(totalWeeks / 2);
|
|
|
|
if (currentWeekNumber <= threshold) {
|
|
setPopoverAlign('start');
|
|
} else {
|
|
setPopoverAlign('end');
|
|
}
|
|
|
|
requestAnimationFrame(() => {
|
|
setPopoverOpen(true);
|
|
})
|
|
}
|
|
}
|
|
|
|
const findDateFromClick = (
|
|
clientX: number,
|
|
clientY: number
|
|
): string | null => {
|
|
if (!cellInfoMapRef.current) {
|
|
return null;
|
|
}
|
|
const cellInfoMap = cellInfoMapRef.current;
|
|
|
|
for (const [dateKey, info] of cellInfoMap.entries()) {
|
|
const rect = info.rect;
|
|
|
|
if (
|
|
clientX >= rect.left
|
|
&& clientX < rect.right
|
|
&& clientY >= rect.top
|
|
&& clientY < rect.bottom
|
|
) {
|
|
return dateKey;
|
|
}
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
const handleEventBarClick = (e: React.MouseEvent<HTMLDivElement>, bar: EventBarPosition) => {
|
|
const clientX = e.clientX;
|
|
const clientY = e.clientY;
|
|
const eventId = bar.id;
|
|
|
|
const dateKey = findDateFromClick(clientX, clientY);
|
|
|
|
if (dateKey && !selectedDate) {
|
|
const clickedDate = parse(dateKey, DATE_FORMAT_KEY, new Date());
|
|
if (!(eventId.includes('overflow'))) {
|
|
setPopoverMode('detail');
|
|
setPopoverDetailId(eventId);
|
|
}
|
|
handleDaySelect(clickedDate);
|
|
} else {
|
|
handleDaySelect(undefined);
|
|
|
|
}
|
|
}
|
|
|
|
return (
|
|
<div
|
|
className="w-full h-full relative"
|
|
ref={containerRef}
|
|
>
|
|
<Popover
|
|
open={popoverOpen}
|
|
onOpenChange={handleOpenChange}
|
|
>
|
|
<Calendar
|
|
mode="single"
|
|
className="h-full w-full border border-indigo-200 rounded-lg shadow-sm shadow-indigo-200"
|
|
selected={selectedDate}
|
|
onSelect={handleDaySelect}
|
|
month={month}
|
|
onMonthChange={handleMonthChange}
|
|
classNames={CustomCalendarCN}
|
|
styles={{
|
|
day: {
|
|
height: `calc(100%/${weekCount})`
|
|
},
|
|
}}
|
|
components={{
|
|
Day: ({ day, ...props }) => {
|
|
const date = day.date;
|
|
const isSelected = selectedDate && isSameDay(selectedDate, date);
|
|
return (
|
|
<td {...props}>
|
|
{ isSelected
|
|
? <PopoverTrigger asChild>
|
|
{props.children}
|
|
</PopoverTrigger>
|
|
: props.children
|
|
}
|
|
</td>
|
|
)
|
|
},
|
|
DayButton: ({ day, ...props}) => (
|
|
<button
|
|
{...props}
|
|
>
|
|
{props.children}
|
|
</button>
|
|
)
|
|
}}
|
|
/>
|
|
{
|
|
barPositions.map(pos => (
|
|
<div
|
|
onClick={(e: React.MouseEvent<HTMLDivElement>) => handleEventBarClick(e, pos)}
|
|
key={pos.segmentId}
|
|
id={pos.segmentId}
|
|
className={cn(
|
|
`flex flex-row justify-start items-center absolute select-none`,
|
|
"py-0.5 px-2 rounded-sm text-xs text-white overflow-hidden"
|
|
)}
|
|
style={{...pos.positionStyle, backgroundColor: pos.style}}
|
|
>
|
|
{pos.name}
|
|
</div>
|
|
))
|
|
}
|
|
<SchedulePopover
|
|
date={selectedDate}
|
|
open={popoverOpen}
|
|
mode={popoverMode}
|
|
setMode={setPopoverMode}
|
|
detailId={popoverDetailId}
|
|
setDetailId={setPopoverDetailId}
|
|
popoverSide={popoverSide}
|
|
popoverAlign={popoverAlign}
|
|
onScheduleCreated={refetchList}
|
|
/>
|
|
|
|
</Popover>
|
|
</div>
|
|
)
|
|
} |