CalendarView组件
import "react-big-calendar/lib/addons/dragAndDrop/styles.css"
import "react-big-calendar/lib/css/react-big-calendar.css"
import { cn } from "@utils/cn"
import dayjs from "dayjs"
import React, { useCallback, useMemo, useRef, useState } from "react"
import { Calendar, type Components, dayjsLocalizer, type SlotInfo, type View, Views } from "react-big-calendar"
import withDragAndDrop from "react-big-calendar/lib/addons/dragAndDrop"
import { useTimeSlotContext } from "../../../../context/TimeSlotContext"
import { TimeSlotPopover } from "../components/TimeSlotPopover/Popover"
import { checkIsBeforeToday, generateTimeSlots } from "../utils"
import { customCalendarConfig } from "./customCalendarConfig"
import { CustomCalendarToolbar } from "./CustomCalendarToolbar"
import { CustomDateCellWrapper } from "./CustomComponents/MonthView/CustomDateCellWrapper"
import { CustomDateHeader } from "./CustomComponents/MonthView/CustomDateHeader"
import styles from "./TimeSlotScheduleCalendar.module.css"
const DragAndDropCalendar = withDragAndDrop(Calendar)
const localizer = dayjsLocalizer(dayjs)
export const CalendarView = () => {
const defaultDate = new Date()
const CalendarRef = useRef<HTMLDivElement>(null)
const [isOpen, setIsOpened] = useState(false)
const [selectedSlot, setSelectedSlot] = useState<SlotInfo | null>(null)
const [selectedDate, setSelectedDate] = useState<Date>(defaultDate)
const [activeView, setActiveView] = useState<View>(Views.MONTH)
const { existingTimeSlots, updateExistingTimeSlots } = useTimeSlotContext()
// custom components to overwrite the default ones
const components: Components = {
toolbar: CustomCalendarToolbar,
dateCellWrapper: (props) => {
return <CustomDateCellWrapper currentSelectedDate={selectedSlot?.start} {...props} />
},
month: {
dateHeader: CustomDateHeader,
},
}
// filter time slots by selected date
// for display in the popover
const selectedTimeSlots = useMemo(() => {
return existingTimeSlots.filter((timeSlot) => {
return dayjs(timeSlot.start).isSame(dayjs(selectedDate), "day")
})
}, [existingTimeSlots, selectedDate])
const handleSelectSlot = (slotInfo: SlotInfo) => {
setIsOpened(false)
const isBeforeToday = checkIsBeforeToday(slotInfo.start)
if (isBeforeToday) return
setSelectedSlot(slotInfo)
setSelectedDate(slotInfo.start)
const newlyCalendarTimeSlots = generateTimeSlots(slotInfo, 30)
// for month view, we don't need to generate time slots
// when clicking on a day
if (activeView === Views.WEEK) {
const isBeforeCurrentTime = dayjs(slotInfo.start).isBefore(dayjs())
if (isBeforeCurrentTime) return
updateExistingTimeSlots(newlyCalendarTimeSlots)
}
setIsOpened(true)
}
// prevent dragging time slots before today
const handleSelecting = useCallback((range: { start: Date; end: Date }) => {
const isBeforeToday = checkIsBeforeToday(range.start)
return !isBeforeToday
}, [])
const onView = useCallback((newView: View) => setActiveView(newView), [setActiveView])
return (
<>
<section
className={cn("h-96 min-w-[300px]", styles.timeSlotScheduleCalendarWrapper)}
ref={CalendarRef}
style={{ position: "relative" }}
>
<DragAndDropCalendar
{...customCalendarConfig}
components={components}
onSelecting={handleSelecting}
className={styles.timeSlotScheduleCalendar}
defaultDate={defaultDate}
events={existingTimeSlots}
localizer={localizer}
view={activeView}
popup
selectable
onView={onView}
onSelectSlot={handleSelectSlot}
onRangeChange={(range) => console.log(range)}
// TODO: handle select event
onSelectEvent={(event, e) => {
console.log("onSelectEvent", event)
console.log(e)
}}
></DragAndDropCalendar>
<TimeSlotPopover
CalendarRef={CalendarRef}
currentSlotInfo={selectedSlot}
selectedTimeSlots={selectedTimeSlots}
localizer={localizer}
isPopoverOpen={isOpen}
handlePopoverClose={() => {
setIsOpened(false)
}}
/>
</section>
</>
)
}
Popover组件
import { CloseOutlined, EditOutlined } from "@ant-design/icons"
import { Button, Card } from "antd"
import dayjs, { Dayjs } from "dayjs"
import React, { useEffect, useMemo, useState } from "react"
import type { DateLocalizer, SlotInfo } from "react-big-calendar"
import { type CalendarTimeSlot, useTimeSlotContext } from "../../../../../context/TimeSlotContext"
import { formatTimeSlot, generateTimeSlotUniqueId } from "../../utils"
import { EditTimeSlotForm } from "./EditTimeSlotForm"
import { TimeSlotsPopoverContent } from "./TimeSlotsPopoverContent"
interface TimeSlotPopoverProps {
localizer: DateLocalizer
selectedTimeSlots?: CalendarTimeSlot[]
currentSlotInfo: SlotInfo | null
isPopoverOpen: boolean
handlePopoverClose: () => void
CalendarRef: React.RefObject<HTMLDivElement>
children?: React.ReactNode
}
enum POPOVER_MODE {
VIEW = "VIEW",
EDIT = "EDIT",
}
// TODO: need to enhance popup placement
export const TimeSlotPopover = ({
isPopoverOpen,
selectedTimeSlots,
handlePopoverClose,
currentSlotInfo,
localizer,
CalendarRef,
children,
}: TimeSlotPopoverProps) => {
const [popoverMode, setPopoverMode] = useState<POPOVER_MODE>(POPOVER_MODE.VIEW)
const { partialUpdateTimeSlotBySelectedDate } = useTimeSlotContext()
const [initialTimeSlots, setInitialTimeSlots] = useState<{
start: Dayjs
end: Dayjs
key: string
}>()
const renderPopoverHeader = () => {
const shouldRenderEditButton = popoverMode === POPOVER_MODE.VIEW && selectedTimeSlots.length > 0
const formattedDate = localizer.format(currentSlotInfo.start, "ddd DD MMMM YYYY")
return (
<header className="flex flex-row items-center">
<Button type="text" className="mr-2 !px-0" onClick={handlePopoverClose}>
<CloseOutlined />
</Button>
{formattedDate}
{shouldRenderEditButton && (
<Button type="text" className="ml-auto !px-0" onClick={() => setPopoverMode(POPOVER_MODE.EDIT)}>
<EditOutlined />
</Button>
)}
</header>
)
}
const renderPopoverContent = () => {
if (popoverMode === POPOVER_MODE.EDIT) {
return (
<EditTimeSlotForm
// TODO: will replace to real duration
duration={30}
selectedDate={currentSlotInfo.start}
initialValues={{
timeSlots: [
initialTimeSlots,
...selectedTimeSlots.map((timeSlot) => ({
start: dayjs(timeSlot.start),
end: dayjs(timeSlot.end),
key: timeSlot.id,
})),
].filter((item) => item !== undefined),
}}
onCancelButtonClick={() => {
setPopoverMode(POPOVER_MODE.VIEW)
setInitialTimeSlots(undefined)
}}
onConfirmButtonClick={(timeSlots) => {
const calendarTimeSlots = timeSlots.map((timeSlot) =>
formatTimeSlot({
start: timeSlot.start.toISOString(),
end: timeSlot.end.toISOString(),
})
)
partialUpdateTimeSlotBySelectedDate(currentSlotInfo.start, calendarTimeSlots)
setInitialTimeSlots(undefined)
setPopoverMode(POPOVER_MODE.VIEW)
}}
/>
)
}
const handleAddTimeSlot = () => {
const isToday = dayjs(currentSlotInfo.start).isSame(dayjs(), "day")
const defaultStartHour = isToday ? dayjs().hour() + 1 : 9
const startTime = dayjs(currentSlotInfo.start).hour(defaultStartHour)
setInitialTimeSlots({
start: startTime,
end: dayjs(startTime).add(30, "minute"),
key: generateTimeSlotUniqueId({
start: dayjs(currentSlotInfo.start).toISOString(),
end: dayjs(currentSlotInfo.start).add(30, "minute").toISOString(),
}),
})
}
return (
<TimeSlotsPopoverContent
onAddTimeSlotButtonClick={() => {
setPopoverMode(POPOVER_MODE.EDIT)
handleAddTimeSlot()
}}
selectedTimeSlots={selectedTimeSlots}
onDeleteButtonClick={(timeSlotId: string) => {
const updatedTimeSlots = selectedTimeSlots.filter((timeSlot) => timeSlot.id !== timeSlotId)
partialUpdateTimeSlotBySelectedDate(currentSlotInfo.start, updatedTimeSlots)
}}
/>
)
}
// reset popover mode to view when popover is open
useEffect(() => {
if (currentSlotInfo?.start && isPopoverOpen) {
setPopoverMode(POPOVER_MODE.VIEW)
}
}, [currentSlotInfo?.start, isPopoverOpen])
// TODO: need to enhance popup placement
const offset = useMemo(() => {
if (!currentSlotInfo) return null
const { right, bottom, width, height } = CalendarRef.current.getBoundingClientRect()
const x = currentSlotInfo?.box?.x || currentSlotInfo?.bounds?.x
const y = currentSlotInfo?.box?.y || currentSlotInfo?.bounds?.y
if (x > right - 330) {
return y > bottom - 200
? { right: right - x, bottom: bottom - y }
: { right: right - x, top: height - (bottom - y) }
} else {
return y > bottom - 200
? { left: width - (right - x), bottom: bottom - y }
: { left: width - (right - x), top: height - (bottom - y) }
}
// if (x > window.innerWidth / 2) {
// x = x - 300
// }
// if (y > window.innerHeight / 2) {
// y = y - 200
// }
// return {
// left: x,
// top: y,
// }
}, [currentSlotInfo])
if (!currentSlotInfo) return
return (
<>
{isPopoverOpen ? (
<div
style={{
position: "absolute",
top: `${offset.top}px`,
left: `${offset.left}px`,
bottom: `${offset.bottom}px`,
right: `${offset.right}px`,
zIndex: 10000,
}}
>
<Card title={renderPopoverHeader()} style={{ width: 330 }}>
{renderPopoverContent()}
</Card>
</div>
) : null}
</>
// <Popover
// open={isPopoverOpen}
// content={renderPopoverContent}
// title={renderPopoverHeader}
// trigger="click"
// overlayStyle={{
// // position: "absolute",
// top: `${offset.top}px`,
// left: `${offset.left}px`,
// }}
// destroyTooltipOnHide
// >
// {children}
// </Popover>
)
}