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(undefined); const [popoverOpen, setPopoverOpen] = useState(false); const [popoverSide, setPopoverSide] = useState<'right' | 'left'>('right'); const [popoverAlign, setPopoverAlign] = useState<'start' | 'end'>('end'); const [popoverMode, setPopoverMode] = useState('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>([]); const [barPositions, setBarPositions] = useState>([]); const scheduleNetwork = new ScheduleNetwork(); const containerRef = useRef(null); const cellInfoMapRef = useRef>(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(); 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(); const overflowCountMap = new Map(); 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, 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, 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 (
{ const date = day.date; const isSelected = selectedDate && isSameDay(selectedDate, date); return ( { isSelected ? {props.children} : props.children } ) }, DayButton: ({ day, ...props}) => ( ) }} /> { barPositions.map(pos => (
) => 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}
)) }
) }