diff --git a/src/components/absences/details/AbsenceDetails.tsx b/src/components/absences/details/AbsenceDetails.tsx index c200367f..b0fcf7ac 100644 --- a/src/components/absences/details/AbsenceDetails.tsx +++ b/src/components/absences/details/AbsenceDetails.tsx @@ -1,245 +1,281 @@ -import { Text } from '@chakra-ui/react'; +import { + Box, + Button, + CloseButton, + Flex, + Icon, + IconButton, + Popover, + PopoverArrow, + PopoverBody, + PopoverContent, + PopoverTrigger, + Text, + useTheme, + VStack, +} from '@chakra-ui/react'; import { useUserData } from '@hooks/useUserData'; import { formatFullDate } from '@utils/formatDate'; -import { EventDetails, Role } from '@utils/types'; -import { useState } from 'react'; -import { useCustomToast } from '../../CustomToast'; -import EditAbsenceModal from '../modals/edit/EditAbsenceModal'; -import AbsenceDetailsModal from './AbsenceDetailsModal'; -import AbsenceFillThanksModal from './AbsenceFillThanksModal'; -import DeleteAbsenceModal from './DeleteAbsenceModal'; -import FillAbsenceModal from './FillAbsenceModal'; +import { EventDetails } from '@utils/types'; +import { Buildings, Calendar } from 'iconsax-react'; +import { BiSolidErrorCircle } from 'react-icons/bi'; +import { FiEdit2, FiMapPin, FiTrash2, FiUser } from 'react-icons/fi'; +import { IoEyeOutline } from 'react-icons/io5'; +import AbsenceStatusTag from './AbsenceStatusTag'; +import EditableNotes from './EditableNotes'; +import LessonPlanView from './LessonPlanView'; interface AbsenceDetailsProps { - isOpen: boolean; onClose: () => void; event: EventDetails; - onDelete?: (absenceId: number) => void; - onTabChange: (tab: 'explore' | 'declared') => void; + onDelete: (absenceId: number) => void; isAdminMode: boolean; fetchAbsences: () => Promise; + onFillClick: () => void; + onEditClick: () => void; hasConflictingEvent: boolean; } +// This component is the content of the popover, not the entire popover/modal const AbsenceDetails: React.FC = ({ - isOpen, onClose, event, onDelete, - onTabChange, isAdminMode, fetchAbsences, + onFillClick, + onEditClick, hasConflictingEvent, }) => { + const theme = useTheme(); const userData = useUserData(); - const [isDeleting, setIsDeleting] = useState(false); - const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false); - const [isEditModalOpen, setIsEditModalOpen] = useState(false); - const [isFilling, setIsFilling] = useState(false); - const [isFillModalOpen, setIsFillModalOpen] = useState(false); - const [isFillThanksOpen, setIsFillThanksOpen] = useState(false); - - const showToast = useCustomToast(); if (!event) return null; const userId = userData.id; const isUserAbsentTeacher = userId === event.absentTeacher.id; const isUserSubstituteTeacher = userId === event.substituteTeacher?.id; - const isUserAdmin = userData.role === Role.ADMIN; const absenceDate = formatFullDate(event.start!!); - const handleFillThanksDone = () => { - setIsFillThanksOpen(false); - }; - - const handleFillAbsenceClick = () => { - setIsFillModalOpen(true); - }; - - const handleFillCancel = () => { - setIsFillModalOpen(false); - }; - - const handleFillConfirm = async () => { - setIsFilling(true); - - try { - const response = await fetch('/api/editAbsence', { - method: 'PUT', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - id: event.absenceId, - lessonDate: event.start, - reasonOfAbsence: event.reasonOfAbsence, - notes: event.notes, - absentTeacherId: event.absentTeacher.id, - substituteTeacherId: userData.id, - locationId: event.locationId, - subjectId: event.subjectId, - roomNumber: event.roomNumber, - }), - }); - - if (!response.ok) { - throw new Error('Failed to fill absence'); - } - - const formattedDate = formatFullDate(event.start); - - showToast({ - status: 'success', - description: ( - - You have successfully filled{' '} - - {event.absentTeacher.firstName}'s - {' '} - absence on{' '} - - {formattedDate}. - - - ), - }); - - await fetchAbsences(); - setIsFillModalOpen(false); - setIsFillThanksOpen(true); - onTabChange('declared'); - } catch { - const formattedDate = formatFullDate(event.start); - - showToast({ - status: 'error', - description: ( - - There was an error in filling{' '} - - {event.absentTeacher.firstName}'s - {' '} - absence on{' '} - - {formattedDate}. - - - ), - }); - } finally { - setIsFilling(false); - onClose(); - } - }; - - const handleEditClick = () => { - onClose(); - setIsEditModalOpen(true); - }; - - const handleDeleteClick = () => { - setIsDeleteModalOpen(true); - }; - - const handleDeleteCancel = () => { - setIsDeleteModalOpen(false); - }; + return ( + <> + + + + + {hasConflictingEvent && + !event.substituteTeacherFullName && + !isUserAbsentTeacher && + !isAdminMode && ( + + + + + + + + + + You have already filled an absence on this date. + + + + )} + - const handleDeleteConfirm = async () => { - try { - setIsDeleting(true); - const response = await fetch(`/api/deleteAbsence`, { - method: 'DELETE', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ - isUserAdmin, - absenceId: event.absenceId, - }), - }); + + {isAdminMode && ( + } + size="sm" + variant="ghost" + onClick={() => { + onClose(); + onEditClick(); + }} + /> + )} - if (!response.ok) { - throw new Error('Failed to delete absence'); - } + {(isAdminMode || + (isUserAbsentTeacher && !event.substituteTeacher)) && ( + } + size="sm" + variant="ghost" + onClick={() => onDelete(event.absenceId)} + /> + )} + + + - const formattedDate = formatFullDate(event.start); + + + {event.title} + + + + {event.location} + + + + + + {absenceDate} + + + + + + {event.absentTeacherFullName} + + + {event.roomNumber && ( + + + + Room {event.roomNumber} + + + )} + + + Lesson Plan + + + + {(isAdminMode || isUserAbsentTeacher) && ( + + + Reason of Absence + + + {event.reasonOfAbsence} + + + )} + {isUserAbsentTeacher && !isAdminMode && ( + + )} + {event.notes && (!isUserAbsentTeacher || isAdminMode) && ( + + + Notes + - showToast({ - status: 'success', - description: ( - - You have successfully deleted{' '} - - {event.absentTeacher.firstName}'s - {' '} - absence on{' '} - - {formattedDate}. - - - ), - }); + + {event.notes} + + + )} - await fetchAbsences(); - setIsDeleteModalOpen(false); - onClose(); + {/* Visibility Tag*/} + {event.substituteTeacher && + !isAdminMode && + (isUserAbsentTeacher || isUserSubstituteTeacher) && ( + + {isUserAbsentTeacher ? ( + <> + + + {' '} + Only visible to{' '} + + {event.absentTeacher.firstName} + {' '} + and{' '} + + {event.substituteTeacher.firstName} + + . + + + ) : isUserSubstituteTeacher ? ( + <> + + + {' '} + Only visible to{' '} + + {event.substituteTeacher.firstName} + {' '} + and{' '} + + {event.absentTeacher.firstName} + + . + + + ) : null} + + )} - if (onDelete) { - onDelete(event.absenceId); - } - } catch (error) { - showToast({ - description: error.message || 'Failed to delete absence', - status: 'error', - }); - } finally { - setIsDeleting(false); - } - }; - return ( - <> - - - - - {isEditModalOpen && ( - setIsEditModalOpen(false)} - initialData={event} - isAdminMode={isAdminMode} - fetchAbsences={fetchAbsences} - /> - )} + {!event.substituteTeacher && + !isUserAbsentTeacher && + !isAdminMode && ( + + )} + + + ); }; diff --git a/src/components/absences/details/LessonPlanView.tsx b/src/components/absences/details/LessonPlanView.tsx index 962f0722..86fdd595 100644 --- a/src/components/absences/details/LessonPlanView.tsx +++ b/src/components/absences/details/LessonPlanView.tsx @@ -120,7 +120,7 @@ const NoLessonPlanViewingDisplay = ({ align="center" justify="center" gap="20px" - width="302px" + width="100%" sx={{ padding: '24px', borderRadius: '10px', diff --git a/src/pages/calendar.tsx b/src/pages/calendar.tsx index 90a519cc..b78103dd 100644 --- a/src/pages/calendar.tsx +++ b/src/pages/calendar.tsx @@ -6,6 +6,11 @@ import { Text, useDisclosure, useTheme, + Popover, + PopoverTrigger, + PopoverContent, + PopoverBody, + Portal, } from '@chakra-ui/react'; import { Global } from '@emotion/react'; import { EventClickArg, EventContentArg, EventInput } from '@fullcalendar/core'; @@ -14,7 +19,7 @@ import interactionPlugin from '@fullcalendar/interaction'; import FullCalendar from '@fullcalendar/react'; import { useAbsences } from '@hooks/useAbsences'; import { useUserData } from '@hooks/useUserData'; -import { formatMonthYear } from '@utils/formatDate'; +import { formatFullDate, formatMonthYear } from '@utils/formatDate'; import { getCalendarStyles } from '@utils/getCalendarStyles'; import { getDayCellClassNames } from '@utils/getDayCellClassNames'; import { EventDetails } from '@utils/types'; @@ -24,8 +29,13 @@ import AbsenceBox from '../components/absences/AbsenceBox'; import AbsenceDetails from '../components/absences/details/AbsenceDetails'; import DeclareAbsenceModal from '../components/absences/modals/declare/DeclareAbsenceModal'; import { CalendarTabs } from '../components/calendar/CalendarTabs'; +import AbsenceFillThanksModal from '../components/absences/details/AbsenceFillThanksModal'; +import EditAbsenceModal from '../components/absences/modals/edit/EditAbsenceModal'; +import DeleteAbsenceModal from '../components/absences/details/DeleteAbsenceModal'; +import FillAbsenceModal from '../components/absences/details/FillAbsenceModal'; import CalendarSidebar from '../components/calendar/sidebar/CalendarSidebar'; import CalendarHeader from '../components/header/calendar/CalendarHeader'; +import { useCustomToast } from '../components/CustomToast'; const Calendar: React.FC = () => { const { refetchUserData, ...userData } = useUserData(); @@ -51,6 +61,7 @@ const Calendar: React.FC = () => { }, [searchParams]); const { events, fetchAbsences } = useAbsences(refetchUserData); + const [clickedEventId, setClickedEventId] = useState(null); const [filledDays, setFilledDays] = useState>(new Set()); const calendarRef = useRef(null); @@ -88,6 +99,150 @@ const Calendar: React.FC = () => { onClose: onInputFormClose, } = useDisclosure(); + const [isClosingDetails, setIsClosingDetails] = useState(false); + const [isFilling, setIsFilling] = useState(false); + const [isFillModalOpen, setIsFillModalOpen] = useState(false); + const [isFillThanksOpen, setIsFillThanksOpen] = useState(false); + const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false); + const [absenceToDelete, setAbsenceToDelete] = useState(null); + const [isEditModalOpen, setIsEditModalOpen] = useState(false); + + const showToast = useCustomToast(); + + const handleDeleteClick = useCallback((absenceId: number) => { + setAbsenceToDelete(absenceId); + setIsDeleteModalOpen(true); + }, []); + + const handleDeleteModalClose = useCallback(() => { + setIsDeleteModalOpen(false); + setAbsenceToDelete(null); + }, []); + + const handleDeleteAbsence = useCallback(async () => { + await fetchAbsences(); + setIsDeleteModalOpen(false); + }, [fetchAbsences]); + + const handleFillThanksDone = useCallback(() => { + setIsFillThanksOpen(false); + }, []); + + const handleFillAbsenceClick = useCallback(() => { + setIsFillModalOpen(true); + }, []); + + const handleFillCancel = useCallback(() => { + setIsFillModalOpen(false); + }, []); + + const handleCloseDetails = useCallback(() => { + setClickedEventId(null); + onAbsenceDetailsClose(); + setIsClosingDetails(true); + setTimeout(() => setIsClosingDetails(false), 100); + }, [onAbsenceDetailsClose]); + + const handleFillConfirm = useCallback(async () => { + if (!selectedEvent) return; + + setIsFilling(true); + + try { + const response = await fetch('/api/editAbsence', { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + id: selectedEvent.absenceId, + lessonDate: selectedEvent.start, + reasonOfAbsence: selectedEvent.reasonOfAbsence, + notes: selectedEvent.notes, + absentTeacherId: selectedEvent.absentTeacher.id, + substituteTeacherId: userData.id, + locationId: selectedEvent.locationId, + subjectId: selectedEvent.subjectId, + roomNumber: selectedEvent.roomNumber, + }), + }); + + if (!response.ok) { + throw new Error('Failed to fill absence'); + } + + const formattedDate = formatFullDate(selectedEvent.start); + + showToast({ + status: 'success', + description: ( + + You have successfully filled{' '} + + {selectedEvent.absentTeacher.firstName}'s + {' '} + absence on{' '} + + {formattedDate}. + + + ), + }); + + await fetchAbsences(); + setIsFillModalOpen(false); + setIsFillThanksOpen(true); + setActiveTab('declared'); + } catch (error) { + console.error('Error filling absence:', error); + + const formattedDate = formatFullDate(selectedEvent.start); + + showToast({ + status: 'error', + description: ( + + There was an error in filling{' '} + + {selectedEvent.absentTeacher.firstName}'s + {' '} + absence on{' '} + + {formattedDate}. + + + ), + }); + } finally { + setIsFilling(false); + handleCloseDetails(); + } + }, [ + fetchAbsences, + handleCloseDetails, + selectedEvent, + showToast, + userData.id, + ]); + + const handleEditClick = useCallback(() => { + setIsEditModalOpen(true); + }, []); + + const formatDateForFilledDays = useCallback((date: Date) => { + const year = date.getFullYear(); + const month = (date.getMonth() + 1).toString().padStart(2, '0'); + const day = date.getDate().toString().padStart(2, '0'); + return `${year}-${month}-${day}`; + }, []); + + const hasConflictingEvent = useCallback( + (event: EventDetails) => { + if (!event?.start) return false; + const dateString = formatDateForFilledDays(new Date(event.start)); + return filledDays.has(dateString); + }, + [filledDays, formatDateForFilledDays] + ); + const renderEventContent = useCallback( (eventInfo: EventContentArg) => { const { @@ -98,6 +253,7 @@ const Calendar: React.FC = () => { locationAbbreviation, subjectAbbreviation, lessonPlan, + absenceId, } = eventInfo.event.extendedProps; const eventDate = new Date(eventInfo.event.start!!); @@ -118,7 +274,27 @@ const Calendar: React.FC = () => { : `${absentTeacherDisplayName} -> Unfilled` : undefined; - return ( + const eventDetails = { + title: eventInfo.event.title, + start: eventInfo.event.start!!, + absentTeacher: eventInfo.event.extendedProps.absentTeacher, + absentTeacherFullName: + eventInfo.event.extendedProps.absentTeacherFullName, + substituteTeacher: + eventInfo.event.extendedProps.substituteTeacher || null, + substituteTeacherFullName: + eventInfo.event.extendedProps.substituteTeacherFullName || '', + location: eventInfo.event.extendedProps.location, + locationId: eventInfo.event.extendedProps.locationId, + subjectId: eventInfo.event.extendedProps.subjectId, + lessonPlan: eventInfo.event.extendedProps.lessonPlan || null, + roomNumber: eventInfo.event.extendedProps.roomNumber || '', + reasonOfAbsence: eventInfo.event.extendedProps.reasonOfAbsence, + notes: eventInfo.event.extendedProps.notes || '', + absenceId: eventInfo.event.extendedProps.absenceId, + }; + + const absenceBox = ( { opacity={opacity} /> ); + + const isCurrentEvent = absenceId === clickedEventId; + + // Use Popover component with Portal for proper positioning + return ( + + + { + setSelectedEvent(eventDetails); + setClickedEventId(absenceId); + onAbsenceDetailsOpen(); + }} + display="inline-block" + position="relative" + width="100%" + height="100%" + cursor="pointer" + > + {absenceBox} + + + {isCurrentEvent && ( + + + + + + + + )} + + ); }, - [userData?.id, isAdminMode] + [ + userData?.id, + isAdminMode, + isAbsenceDetailsOpen, + onAbsenceDetailsOpen, + clickedEventId, + fetchAbsences, + handleCloseDetails, + handleDeleteClick, + handleFillAbsenceClick, + handleEditClick, + hasConflictingEvent, + selectedEvent, + ] ); useEffect(() => { @@ -175,10 +425,15 @@ const Calendar: React.FC = () => { } }, []); - const handleDateClick = (arg: { date: Date }) => { - setSelectedDate(arg.date); - onInputFormOpen(); - }; + const handleDateClick = useCallback( + (arg: { date: Date }) => { + if (!isClosingDetails) { + setSelectedDate(arg.date); + onInputFormOpen(); + } + }, + [isClosingDetails, onInputFormOpen] + ); const handleTodayClick = useCallback(() => { if (calendarRef.current) { @@ -206,62 +461,32 @@ const Calendar: React.FC = () => { } }, [updateMonthYearTitle]); - const handleDateSelect = (date: Date) => { + const handleDateSelect = useCallback((date: Date) => { setSelectedDate(date); if (calendarRef.current) { const calendarApi = calendarRef.current.getApi(); calendarApi.gotoDate(date); } - }; + }, []); useEffect(() => { updateMonthYearTitle(); }, [updateMonthYearTitle]); - const formatDateForFilledDays = (date: Date) => { - const year = date.getFullYear(); - const month = (date.getMonth() + 1).toString().padStart(2, '0'); - const day = date.getDate().toString().padStart(2, '0'); - return `${year}-${month}-${day}`; - }; - - const hasConflictingEvent = (event: EventDetails) => { - if (!event?.start) return false; - const dateString = formatDateForFilledDays(new Date(event.start)); - return filledDays.has(dateString); - }; - - const handleAbsenceClick = (clickInfo: EventClickArg) => { - setSelectedEvent({ - title: clickInfo.event.title, - start: clickInfo.event.start!!, - absentTeacher: clickInfo.event.extendedProps.absentTeacher, - absentTeacherFullName: - clickInfo.event.extendedProps.absentTeacherFullName, - substituteTeacher: - clickInfo.event.extendedProps.substituteTeacher || null, - substituteTeacherFullName: - clickInfo.event.extendedProps.substituteTeacherFullName || '', - location: clickInfo.event.extendedProps.location, - locationId: clickInfo.event.extendedProps.locationId, - subjectId: clickInfo.event.extendedProps.subjectId, - lessonPlan: clickInfo.event.extendedProps.lessonPlan || null, - roomNumber: clickInfo.event.extendedProps.roomNumber || '', - reasonOfAbsence: clickInfo.event.extendedProps.reasonOfAbsence, - notes: clickInfo.event.extendedProps.notes || '', - absenceId: clickInfo.event.extendedProps.absenceId, - }); - onAbsenceDetailsOpen(); - }; + const handleAbsenceClick = useCallback((clickInfo: EventClickArg) => { + // Prevent default behavior so our custom handlers work + clickInfo.jsEvent.preventDefault(); + }, []); - const handleDeclareAbsenceClick = () => { + const handleDeclareAbsenceClick = useCallback(() => { if (calendarRef.current) { const calendarApi = calendarRef.current.getApi(); const today = calendarApi.getDate(); setSelectedDate(today); onInputFormOpen(); } - }; + }, [onInputFormOpen]); + useEffect(() => { const { activeAbsenceStatusIds, @@ -309,10 +534,6 @@ const Calendar: React.FC = () => { setFilteredEvents(filtered); }, [searchQuery, events, activeTab, userData.id, isAdminMode]); - const handleDeleteAbsence = async () => { - await fetchAbsences(); - }; - if (userData.isLoading) { return null; } @@ -407,7 +628,7 @@ const Calendar: React.FC = () => { return ( <> - + { height="100%" display="flex" flexDirection="column" + overflow="hidden" > { setIsAdminMode={setIsAdminMode} /> - + {!isAdminMode && ( )} @@ -441,7 +663,7 @@ const Calendar: React.FC = () => { headerToolbar={false} plugins={[dayGridPlugin, interactionPlugin]} initialView="dayGridMonth" - height="100%" + height="auto" events={filteredEvents} eventContent={renderEventContent} timeZone="local" @@ -457,16 +679,7 @@ const Calendar: React.FC = () => { - + { isAdminMode={isAdminMode} fetchAbsences={fetchAbsences} /> + + {selectedEvent && ( + + )} + {selectedEvent && ( + setIsEditModalOpen(false)} + initialData={selectedEvent} + isAdminMode={isAdminMode} + fetchAbsences={fetchAbsences} + /> + )} + {/* Delete Absence Modal */} + {absenceToDelete && ( + + )} ); }; diff --git a/utils/getCalendarStyles.ts b/utils/getCalendarStyles.ts index 73693519..609f2610 100644 --- a/utils/getCalendarStyles.ts +++ b/utils/getCalendarStyles.ts @@ -38,6 +38,17 @@ export const getCalendarStyles = (theme: CustomTheme) => css` .fc-daygrid-event-harness:first-of-type { margin-top: 0 !important; } + .fc-scroller { + overflow: auto !important; + max-height: 100% !important; + } + .fc-view-harness { + height: auto !important; + overflow: visible !important; + } + .fc .fc-scrollgrid { + border: none !important; + } .fc .fc-scrollgrid thead .fc-col-header-cell { border-left: none !important; border-right: none !important;