Files
scheduler-front/src/ui/component/calendar/CustomCalendar.tsx
geonhee-min 60e9d2a631
All checks were successful
Test CI / build (push) Successful in 28s
issue #
- DTO 패키지 레지스트리 전환 중
2025-12-16 17:26:58 +09:00

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>
)
}