From 17fb736e58122091e21b0071d14fcb8159d589b4 Mon Sep 17 00:00:00 2001 From: wavehassman Date: Sun, 28 Dec 2025 16:20:16 -0500 Subject: [PATCH 01/10] #3818 first round of changes --- .../AvailabilityScheduleView.tsx | 7 +- .../pages/NewCalendarPage/CalendarDayCard.tsx | 1 + .../Components/EventAvailabilityPage.tsx | 227 ++++++++++++++++++ .../NewCalendarPage/Components/EventModal.tsx | 8 +- .../pages/NewCalendarPage/EventClickPopup.tsx | 142 ++++++++++- .../src/pages/NewCalendarPage/NewCalendar.tsx | 2 + .../pages/NewCalendarPage/NewCalendarPage.tsx | 1 - 7 files changed, 374 insertions(+), 14 deletions(-) create mode 100644 src/frontend/src/pages/NewCalendarPage/Components/EventAvailabilityPage.tsx diff --git a/src/frontend/src/pages/CalendarPage/EventDetailPage/AvailabilityScheduleView.tsx b/src/frontend/src/pages/CalendarPage/EventDetailPage/AvailabilityScheduleView.tsx index 606aa9aca2..37b31f5129 100644 --- a/src/frontend/src/pages/CalendarPage/EventDetailPage/AvailabilityScheduleView.tsx +++ b/src/frontend/src/pages/CalendarPage/EventDetailPage/AvailabilityScheduleView.tsx @@ -21,6 +21,7 @@ interface AvailabilityScheduleViewProps { onSelectedTimeslotChanged: (val: number | null, day: Date | null) => void; dateRangeTitle: string; event: Event; + displayDate?: Date; } const AvailabilityScheduleView: React.FC = ({ @@ -32,11 +33,13 @@ const AvailabilityScheduleView: React.FC = ({ setCurrentUnavailableUsers, dateRangeTitle, onSelectedTimeslotChanged, - event + event, + displayDate }) => { const totalUsers = usersToAvailabilities.size; const [selectedTimeslot, setSelectedTimeslot] = useState(null); - const initialDate = event.scheduledTimes[0]?.initialDateScheduled || new Date(); + // Use displayDate if provided, otherwise fall back to event's initial date + const initialDate = displayDate || event.scheduledTimes[0]?.initialDateScheduled || new Date(); const potentialDays = getNextSevenDays(initialDate); const handleTimeslotClick = (index: number, day: Date) => { diff --git a/src/frontend/src/pages/NewCalendarPage/CalendarDayCard.tsx b/src/frontend/src/pages/NewCalendarPage/CalendarDayCard.tsx index d8320a5938..da952352b9 100644 --- a/src/frontend/src/pages/NewCalendarPage/CalendarDayCard.tsx +++ b/src/frontend/src/pages/NewCalendarPage/CalendarDayCard.tsx @@ -475,6 +475,7 @@ const CalendarDayCard: React.FC = ({ eventTypes={eventTypes} calendars={calendars} dayOfWeek={dayOfWeek} + clickedDate={cardDate} /> ); diff --git a/src/frontend/src/pages/NewCalendarPage/Components/EventAvailabilityPage.tsx b/src/frontend/src/pages/NewCalendarPage/Components/EventAvailabilityPage.tsx new file mode 100644 index 0000000000..2adb56862e --- /dev/null +++ b/src/frontend/src/pages/NewCalendarPage/Components/EventAvailabilityPage.tsx @@ -0,0 +1,227 @@ +/* + * This file is part of NER's FinishLine and licensed under GNU AGPLv3. + * See the LICENSE file in the repository root folder for details. + */ + +import React, { useState, useEffect, useMemo } from 'react'; +import { useQuery as useQueryParam } from '../../../hooks/utils.hooks'; +import { Box, Grid, Typography, useTheme } from '@mui/material'; +import { Availability, getMostRecentAvailabilities, User, UserWithScheduleSettings } from 'shared'; +import PageLayout from '../../../components/PageLayout'; +import LoadingIndicator from '../../../components/LoadingIndicator'; +import ErrorPage from '../../ErrorPage'; +import { useCurrentUser, useUserScheduleSettings, useManyUsersWithScheduleSettings } from '../../../hooks/users.hooks'; +import { useSingleEvent, useMarkUserConfirmed } from '../../../hooks/calendar.hooks'; +import { useParams, useHistory } from 'react-router-dom'; +import { eventNamePipe } from '../../../utils/pipes'; +import NERSuccessButton from '../../../components/NERSuccessButton'; +import NERFailButton from '../../../components/NERFailButton'; +import { routes } from '../../../utils/routes'; +import { useToast } from '../../../hooks/toasts.hooks'; +import { deeplyCopy } from 'shared/src/utils'; +import { availabilityTransformer } from '../../../apis/transformers/users.transformers'; +import AvailabilityScheduleView from '../../CalendarPage/EventDetailPage/AvailabilityScheduleView'; +import SingleAvailabilityModal from '../../SettingsPage/UserScheduleSettings/Availability/SingleAvailabilityModal'; +import AvailabilityEditModal from '../../SettingsPage/UserScheduleSettings/Availability/AvailabilityEditModal'; + +const EventAvailabilityPage: React.FC = () => { + const { eventId } = useParams<{ eventId: string }>(); + const queryParams = useQueryParam(); + const dateParam = queryParams.get('date'); + const theme = useTheme(); + const history = useHistory(); + const toast = useToast(); + const currentUser = useCurrentUser(); + + const [editAvailabilityOpen, setEditAvailabilityOpen] = useState(false); + const [viewAvailabilityOpen, setViewAvailabilityOpen] = useState(false); + const [confirmedAvailabilities, setConfirmedAvailabilities] = useState>(new Map()); + //const [currentAvailableUsers, setCurrentAvailableUsers] = useState([]); + //const [currentUnavailableUsers, setCurrentUnavailableUsers] = useState([]); + + const { data: event, isError: eventError, error: eventErrorMsg, isLoading: eventLoading } = useSingleEvent(eventId); + + const { + data: userScheduleSettings, + isLoading: settingsLoading, + isError: settingsIsError, + error: settingsError + } = useUserScheduleSettings(currentUser.userId); + + // Get required and optional member IDs (use empty arrays if event not loaded yet) + const requiredUserIds = event?.requiredMembers.map((m) => m.userId) || []; + const optionalUserIds = event?.optionalMembers.map((m) => m.userId) || []; + const allRelevantUserIds = [...requiredUserIds, ...optionalUserIds]; + + const { + data: relevantUsers, + isLoading: usersLoading, + isError: usersError, + error: usersErrorMsg + } = useManyUsersWithScheduleSettings(allRelevantUserIds); + + const { mutateAsync: markUserConfirmed } = useMarkUserConfirmed(eventId); + + // Get the date to show availability for + const displayDate = useMemo(() => { + if (dateParam) { + return new Date(dateParam); + } + // Fall back to initial scheduled date + const raw = event?.scheduledTimes?.[0]?.initialDateScheduled; + return raw ? new Date(raw as any) : new Date(); + }, [dateParam, event?.scheduledTimes]); + + useEffect(() => { + if (userScheduleSettings && userScheduleSettings.availabilities.length > 0) { + const confirmed = getMostRecentAvailabilities(userScheduleSettings.availabilities, displayDate); + setConfirmedAvailabilities(new Map(confirmed.map((availability) => [availability.dateSet.getTime(), availability]))); + } else { + // Clear availabilities if no schedule settings + setConfirmedAvailabilities(new Map()); + } + }, [userScheduleSettings, displayDate]); + + // NOW do conditional returns AFTER all hooks + if (eventLoading || !event) return ; + if (eventError) return ; + + if (settingsLoading) return ; + if (settingsIsError || !userScheduleSettings) return ; + + if (usersLoading || !relevantUsers) return ; + if (usersError) return ; + + // Get work package names for the modal title + const workPackageNames = event.workPackages.map((wp) => wp.wbsElement.name).join(', ') || event.title; + const editModalTitle = `Update your availability for ${workPackageNames} on the week of ${displayDate.toLocaleDateString()}`; + + const handleConfirm = async () => { + try { + await markUserConfirmed({ availability: Array.from(confirmedAvailabilities.values()) }); + toast.success('Availability saved successfully!'); + setEditAvailabilityOpen(false); + } catch (e) { + if (e instanceof Error) { + toast.error(e.message); + } + } + }; + + const handleClose = () => { + history.push(routes.NEW_CALENDAR); + }; + + // Build maps for AvailabilityScheduleView + const availableUsers = new Map(); + const unavailableUsers = new Map(); + const usersToAvailabilities = new Map(); + const existingMeetingData = new Map(); + + relevantUsers.forEach((user: UserWithScheduleSettings) => { + const availability = getMostRecentAvailabilities(user.scheduleSettings?.availabilities ?? [], displayDate); + usersToAvailabilities.set(user, availability ?? []); + }); + + const onSelectedTimeslotChanged = (_index: number | null, _day: Date | null) => { + // This could be used to show detailed info about a specific timeslot if needed + }; + + const dateRangeTitle = `Week of ${displayDate.toLocaleDateString()}`; + + // Get display text for user's availability + const getAvailabilitySummary = () => { + if (confirmedAvailabilities.size === 0) { + return 'No availability set yet. Click "Edit My Availability" to get started.'; + } + const totalSlots = Array.from(confirmedAvailabilities.values()).reduce( + (sum, avail) => sum + avail.availability.length, + 0 + ); + return `${totalSlots} time slot${totalSlots !== 1 ? 's' : ''} marked as available`; + }; + + return ( + + + {/* My Availability Section */} + + + + + My Availability + + + setViewAvailabilityOpen(true)} + disabled={userScheduleSettings.availabilities.length === 0} + > + View My Availability + + setEditAvailabilityOpen(true)}> + Edit My Availability + + + + + {getAvailabilitySummary()} + + + + + {/* Event-Wide Availability Section */} + + + + Team Availability + + + Showing availability for all required and optional members. Darker colors indicate more people are available. + + + + + + + {/* Action Buttons */} + + Close + + + {/* View Availability Modal */} + setViewAvailabilityOpen(false)} + header="My Availability" + availabilites={userScheduleSettings.availabilities} + /> + + {/* Edit Availability Modal */} + setEditAvailabilityOpen(false)} + header={editModalTitle} + confirmedAvailabilities={confirmedAvailabilities} + setConfirmedAvailabilities={setConfirmedAvailabilities} + totalAvailabilities={deeplyCopy(userScheduleSettings.availabilities, availabilityTransformer) as Availability[]} + initialDate={displayDate} + onSubmit={handleConfirm} + canChangeDateRange={false} + /> + + ); +}; + +export default EventAvailabilityPage; diff --git a/src/frontend/src/pages/NewCalendarPage/Components/EventModal.tsx b/src/frontend/src/pages/NewCalendarPage/Components/EventModal.tsx index 1a1f2fd4b0..22da53cd89 100644 --- a/src/frontend/src/pages/NewCalendarPage/Components/EventModal.tsx +++ b/src/frontend/src/pages/NewCalendarPage/Components/EventModal.tsx @@ -272,7 +272,13 @@ const EventModal: React.FC = ({ setSelectedTeams(teamOptions); } } - }, [open, defaultFormData, reset, initialValues, users, teams]); + }, [open]); + + useEffect(() => { + if (open && initialValues?.days && initialValues.days.length > 0) { + setShowRecurringOptions(true); + } + }, [open, initialValues?.days]); const selectedEventType = useMemo( () => allowedEventTypes.find((et) => et.eventTypeId === selectedEventTypeId), diff --git a/src/frontend/src/pages/NewCalendarPage/EventClickPopup.tsx b/src/frontend/src/pages/NewCalendarPage/EventClickPopup.tsx index b30a084d4d..1d8ed7c571 100644 --- a/src/frontend/src/pages/NewCalendarPage/EventClickPopup.tsx +++ b/src/frontend/src/pages/NewCalendarPage/EventClickPopup.tsx @@ -1,4 +1,4 @@ -import React from 'react'; +import React, { useState } from 'react'; import { Box, Button, IconButton, Link, Popover, Stack, Typography, useTheme } from '@mui/material'; import { Calendar, DayOfWeek, Event, EventType } from 'shared'; @@ -26,8 +26,17 @@ import PeopleIcon from '@mui/icons-material/People'; import { getConvertedEnd, getConvertedStart } from '../../utils/datetime.utils'; import NERSuccessButton from '../../components/NERSuccessButton'; import NERFailButton from '../../components/NERFailButton'; -import { useApproveEvent, useDenyEvent } from '../../hooks/calendar.hooks'; +import { + EditEventArgs, + useApproveEvent, + useDenyEvent, + useEditEvent, + useUploadManyDocuments +} from '../../hooks/calendar.hooks'; import { convertDayToDayShorthand } from '../../utils/calendar.utils'; +import EditEventModal from './Components/EditEventModal'; +import { EventFormValues, EventRoutePayload } from './Components/EventModal'; +import { useToast } from '../../hooks/toasts.hooks'; export const getStatusIcon = (status: string, isLarge?: boolean) => { const statusIcons: Map = new Map([ @@ -51,6 +60,8 @@ interface EventClickContentProps { disable: boolean; addApprovalButtons: boolean; onClose: () => void; + onEdit: (event: Event) => void; + clickedDate?: Date; } const joinPeople = (members: { firstName: string; lastName: string }[]) => @@ -68,7 +79,9 @@ const EventClickContent: React.FC = ({ dayOfWeek, disable, addApprovalButtons, - onClose + onClose, + onEdit, + clickedDate }) => { const { mutateAsync: approveEvent } = useApproveEvent(event.eventId); const { mutateAsync: denyEvent } = useDenyEvent(event.eventId); @@ -87,8 +100,11 @@ const EventClickContent: React.FC = ({ const showAvailabilityButton = true; - const editUrl = `${routes.SETTINGS_PREFERENCES}?eventId=${event.eventId}`; - const availabilityUrl = `${routes.CALENDAR}/${event.eventId}`; + const eventDate = + clickedDate || + (event.scheduledTimes[0]?.initialDateScheduled ? new Date(event.scheduledTimes[0].initialDateScheduled) : new Date()); + + const availabilityUrl = `${routes.NEW_CALENDAR}/${event.eventId}?date=${eventDate.toISOString()}`; const requiredText = event.requiredMembers.length > 0 ? joinPeople(event.requiredMembers) : ''; const optionalText = event.optionalMembers.length > 0 ? joinPeople(event.optionalMembers) : ''; @@ -116,13 +132,13 @@ const EventClickContent: React.FC = ({ }} > - {/* Edit -> availability page */} {!disable && ( { + stopClick(e); + onEdit(event); + }} sx={{ position: 'absolute', top: 0, @@ -401,6 +417,7 @@ export interface EventClickPopupProps { dayOfWeek?: DayOfWeek; disable?: boolean; addApprovalButtons?: boolean; + clickedDate?: Date; } export const EventClickPopup: React.FC = ({ @@ -411,8 +428,99 @@ export const EventClickPopup: React.FC = ({ calendars, dayOfWeek, disable = false, - addApprovalButtons = false + addApprovalButtons = false, + clickedDate }) => { + const toast = useToast(); + const [showEditModal, setShowEditModal] = useState(false); + + const { mutateAsync: editEvent } = useEditEvent(clickedEvent?.eventId ?? ''); + const { mutateAsync: uploadDocuments } = useUploadManyDocuments(); + + const convertEventToFormValues = (event: Event): Partial => { + return { + title: event.title, + eventTypeId: event.eventTypeId, + requiredMemberIds: event.requiredMembers.map((m) => m.userId), + optionalMemberIds: event.optionalMembers.map((m) => m.userId), + teamIds: event.teams.map((t) => t.teamId), + teamTypeId: event.teamType?.teamTypeId, + location: event.location, + zoomLink: event.zoomLink, + shopIds: event.shops.map((s) => s.shopId), + machineryIds: event.machinery.map((m) => m.machineryId), + workPackageIds: event.workPackages.map((wp) => wp.workPackageId), + documentFiles: event.documents.map((doc) => ({ + name: doc.name, + googleFileId: doc.googleFileId + })), + questionDocumentLink: event.questionDocumentLink, + description: event.description, + scheduleDate: event.scheduledTimes[0]?.initialDateScheduled + ? new Date(event.scheduledTimes[0].initialDateScheduled) + : new Date(), + startTime: event.scheduledTimes[0]?.startTime ? new Date(event.scheduledTimes[0].startTime) : undefined, + endTime: event.scheduledTimes[0]?.endTime ? new Date(event.scheduledTimes[0].endTime) : undefined, + allDay: event.scheduledTimes[0]?.allDay ?? false, + recurrenceNumber: event.scheduledTimes[0]?.recurrenceNumber ?? 0, + days: event.scheduledTimes[0]?.days ?? [] + }; + }; + + const handleEdit = () => { + setShowEditModal(true); + }; + + const handleEditSubmit = async (data: EventRoutePayload) => { + if (!clickedEvent) return; + + try { + const { scheduleSlot, documentFiles, ...eventData } = data; + + if (!scheduleSlot || scheduleSlot.length === 0) { + throw new Error('Missing scheduleSlot'); + } + + // Convert EventRoutePayload to EditEventArgs format + const editArgs: EditEventArgs = { + ...eventData, + status: clickedEvent.status, // Use existing event status + documents: clickedEvent.documents.map((doc) => ({ + name: doc.name, + googleFileId: doc.googleFileId + })), + scheduleSlot: scheduleSlot.map((slot) => ({ + days: slot.days, + startTime: slot.startTime, + endTime: slot.endTime, + recurrenceNumber: slot.recurrenceNumber, + initialDateScheduled: slot.initialDateScheduled, + allDay: slot.allDay + })) + }; + + const editedEvent = await editEvent(editArgs); + + // Upload new documents if any + const filesToUpload = documentFiles.map((doc) => doc.file).filter((file): file is File => file !== undefined); + + if (filesToUpload.length > 0) { + await uploadDocuments({ + id: editedEvent.eventId, + files: filesToUpload + }); + } + + toast.success('Event updated successfully!'); + setShowEditModal(false); + onClose(); + } catch (err) { + if (err instanceof Error) { + toast.error(err.message); + } + } + }; + return ( = ({ disable={disable} addApprovalButtons={addApprovalButtons} onClose={onClose} + onEdit={handleEdit} + clickedDate={clickedDate} + /> + )} + {clickedEvent && showEditModal && ( + { + setShowEditModal(false); + onClose(); + }} + onSubmit={handleEditSubmit} + initialValues={convertEventToFormValues(clickedEvent)} + eventTypes={eventTypes} /> )} diff --git a/src/frontend/src/pages/NewCalendarPage/NewCalendar.tsx b/src/frontend/src/pages/NewCalendarPage/NewCalendar.tsx index eacbb652a7..783cf11086 100644 --- a/src/frontend/src/pages/NewCalendarPage/NewCalendar.tsx +++ b/src/frontend/src/pages/NewCalendarPage/NewCalendar.tsx @@ -6,10 +6,12 @@ import { Route, Switch } from 'react-router-dom'; import { routes } from '../../utils/routes'; import DesignReviewDetails from '../CalendarPage/EventDetailPage/EventDetails'; import CalendarTab from './CalendarTab'; +import EventAvailabilityPage from './Components/EventAvailabilityPage'; const NewCalendar: React.FC = () => { return ( + diff --git a/src/frontend/src/pages/NewCalendarPage/NewCalendarPage.tsx b/src/frontend/src/pages/NewCalendarPage/NewCalendarPage.tsx index 2fe1379a98..436036e733 100644 --- a/src/frontend/src/pages/NewCalendarPage/NewCalendarPage.tsx +++ b/src/frontend/src/pages/NewCalendarPage/NewCalendarPage.tsx @@ -434,7 +434,6 @@ const NewCalendarPage: React.FC = ({ allEventTypes, yourEv - {/* New Event Button (does not do anything yet) */}