diff --git a/.gitignore b/.gitignore index c5fc746811..f77840056a 100644 --- a/.gitignore +++ b/.gitignore @@ -64,3 +64,7 @@ eb-deploy/ # Docker *.bak *.backup + +# Claude Code Files +CLAUDE.md +.playwright-mcp/ \ No newline at end of file diff --git a/src/backend/index.ts b/src/backend/index.ts index f7449ff405..88b099ddef 100644 --- a/src/backend/index.ts +++ b/src/backend/index.ts @@ -12,7 +12,6 @@ import descriptionBulletsRouter from './src/routes/description-bullets.routes.js import tasksRouter from './src/routes/tasks.routes.js'; import reimbursementRequestsRouter from './src/routes/reimbursement-requests.routes.js'; import notificationsRouter from './src/routes/notifications.routes.js'; -import designReviewsRouter from './src/routes/design-reviews.routes.js'; import wbsElementTemplatesRouter from './src/routes/wbs-element-templates.routes.js'; import carsRouter from './src/routes/cars.routes.js'; import organizationRouter from './src/routes/organizations.routes.js'; @@ -25,6 +24,7 @@ import statisticsRouter from './src/routes/statistics.routes.js'; import retrospectiveRouter from './src/routes/retrospective.routes.js'; import partsRouter from './src/routes/parts.routes.js'; import financeRouter from './src/routes/finance.routes.js'; +import calendarRouter from './src/routes/calendar.routes.js'; const app = express(); @@ -90,7 +90,6 @@ app.use('/change-requests', changeRequestsRouter); app.use('/description-bullets', descriptionBulletsRouter); app.use('/tasks', tasksRouter); app.use('/reimbursement-requests', reimbursementRequestsRouter); -app.use('/design-reviews', designReviewsRouter); app.use('/notifications', notificationsRouter); app.use('/templates', wbsElementTemplatesRouter); app.use('/cars', carsRouter); @@ -103,6 +102,7 @@ app.use('/statistics', statisticsRouter); app.use('/retrospective', retrospectiveRouter); app.use('/parts', partsRouter); app.use('/finance', financeRouter); +app.use('/calendar', calendarRouter); app.use('/', (_req, res) => { res.status(200).json('Welcome to FinishLine'); }); diff --git a/src/backend/src/controllers/calendar.controllers.ts b/src/backend/src/controllers/calendar.controllers.ts new file mode 100644 index 0000000000..55eb510bca --- /dev/null +++ b/src/backend/src/controllers/calendar.controllers.ts @@ -0,0 +1,580 @@ +import { NextFunction, Request, Response } from 'express'; +import CalendarService from '../services/calendar.services.js'; +import { getCurrentUserWithUserSettings } from '../utils/auth.utils.js'; + +export default class CalendarController { + static async createEventType(req: Request, res: Response, next: NextFunction) { + try { + const { + name, + calendarIds, + requiredMembers, + optionalMembers, + teams, + teamType, + location, + zoomLink, + shop, + machinery, + workPackage, + questionDocument, + documents, + description, + onlyHeadsOrAbove, + requiresConfirmation, + sendSlackNotifications + } = req.body; + + const eventType = await CalendarService.createEventType( + req.currentUser, + name, + calendarIds, + req.organization, + requiredMembers, + optionalMembers, + teams, + teamType, + location, + zoomLink, + shop, + machinery, + workPackage, + questionDocument, + documents, + description, + onlyHeadsOrAbove, + requiresConfirmation, + sendSlackNotifications + ); + res.status(200).json(eventType); + } catch (error: unknown) { + next(error); + } + } + + static async createMachinery(req: Request, res: Response, next: NextFunction) { + try { + const { name } = req.body; + + const machinery = await CalendarService.createMachinery(req.currentUser, name, req.organization); + res.status(200).json(machinery); + } catch (error: unknown) { + next(error); + } + } + + static async editMachinery(req: Request, res: Response, next: NextFunction) { + try { + const { machineryId } = req.params as Record; + const { name } = req.body; + + const machinery = await CalendarService.editMachinery(req.currentUser, machineryId, name, req.organization); + res.status(200).json(machinery); + } catch (error: unknown) { + next(error); + } + } + + static async addMachineryToShop(req: Request, res: Response, next: NextFunction) { + try { + const { machineryId } = req.params as Record; + const { shopId, quantity, originalShopId } = req.body; + + const machinery = await CalendarService.addMachineryToShop( + req.currentUser, + machineryId, + shopId, + quantity, + req.organization, + originalShopId + ); + res.status(200).json(machinery); + } catch (error: unknown) { + next(error); + } + } + + static async createShop(req: Request, res: Response, next: NextFunction) { + try { + const { name, description } = req.body; + + const shop = await CalendarService.createShop(req.currentUser, name, description, req.organization); + + res.status(200).json(shop); + } catch (error: unknown) { + next(error); + } + } + + static async getAllShops(req: Request, res: Response, next: NextFunction) { + try { + const shops = await CalendarService.getAllShops(req.organization); + res.status(200).json(shops); + } catch (error: unknown) { + next(error); + } + } + + static async getAllMachinery(req: Request, res: Response, next: NextFunction) { + try { + const machinery = await CalendarService.getAllMachinery(req.organization); + res.status(200).json(machinery); + } catch (error: unknown) { + next(error); + } + } + + static async editShop(req: Request, res: Response, next: NextFunction) { + try { + const { shopId } = req.params as Record; + const { name, description } = req.body; + + const updatedShop = await CalendarService.editShop(req.currentUser, shopId, name, description, req.organization); + res.status(200).json(updatedShop); + } catch (error: unknown) { + next(error); + } + } + + static async createCalendar(req: Request, res: Response, next: NextFunction) { + try { + const { name, description, colorHexCode } = req.body; + + const calendar = await CalendarService.createCalendar( + req.currentUser, + name, + description, + colorHexCode, + req.organization + ); + + res.status(200).json(calendar); + } catch (error: unknown) { + next(error); + } + } + + static async editCalendar(req: Request, res: Response, next: NextFunction) { + try { + const { calendarId } = req.params as Record; + const { name, colorHexCode, description } = req.body; + + const updatedCalendar = await CalendarService.editCalendar( + req.currentUser, + calendarId, + name, + description, + colorHexCode, + req.organization + ); + + res.status(200).json(updatedCalendar); + } catch (error: unknown) { + next(error); + } + } + + static async deleteCalendar(req: Request, res: Response, next: NextFunction) { + try { + const { calendarId } = req.params as Record; + + const updatedCalendar = await CalendarService.deleteCalendar(req.currentUser, calendarId, req.organization); + + res.status(200).json(updatedCalendar); + } catch (error: unknown) { + next(error); + } + } + + static async editEventType(req: Request, res: Response, next: NextFunction) { + try { + const { eventTypeId } = req.params as Record; + const { + name, + calendarIds, + requiredMembers, + optionalMembers, + teams, + teamType, + location, + zoomLink, + shop, + machinery, + workPackage, + questionDocument, + documents, + description, + onlyHeadsOrAbove, + requiresConfirmation, + sendSlackNotifications + } = req.body; + + const eventType = await CalendarService.editEventType( + eventTypeId, + req.currentUser, + calendarIds, + req.organization, + name, + requiredMembers, + optionalMembers, + teams, + teamType, + location, + zoomLink, + shop, + machinery, + workPackage, + questionDocument, + documents, + description, + onlyHeadsOrAbove, + requiresConfirmation, + sendSlackNotifications + ); + res.status(200).json(eventType); + } catch (error: unknown) { + next(error); + } + } + + static async deleteEventType(req: Request, res: Response, next: NextFunction) { + try { + const { eventTypeId } = req.params as Record; + + const deletedEventType = await CalendarService.deleteEventType(req.currentUser, eventTypeId, req.organization); + + res.status(200).json(deletedEventType); + } catch (error: unknown) { + next(error); + } + } + + static async deleteShop(req: Request, res: Response, next: NextFunction) { + try { + const { shopId } = req.params as Record; + + const shop = await CalendarService.deleteShop(req.currentUser, shopId, req.organization); + + res.status(200).json(shop); + } catch (error: unknown) { + next(error); + } + } + + static async createEvent(req: Request, res: Response, next: NextFunction) { + try { + const { + title, + eventTypeId, + requiredMemberIds, + optionalMemberIds, + teamIds, + teamTypeId, + shopIds, + machineryIds, + workPackageIds, + scheduleSlots, + initialDateScheduled, + questionDocumentLink, + location, + zoomLink, + description + } = req.body; + + const parsedScheduleSlots = scheduleSlots.map((slot: any) => ({ + startTime: slot.startTime ? new Date(slot.startTime) : undefined, + endTime: slot.endTime ? new Date(slot.endTime) : undefined, + allDay: slot.allDay + })); + + const parsedInitialDateScheduled = initialDateScheduled ? new Date(initialDateScheduled) : undefined; + + const event = await CalendarService.createEvent( + req.currentUser, + title, + eventTypeId, + req.organization, + requiredMemberIds, + optionalMemberIds, + teamIds, + shopIds, + machineryIds, + workPackageIds, + parsedScheduleSlots, + parsedInitialDateScheduled, + teamTypeId, + questionDocumentLink, + location, + zoomLink, + description + ); + res.status(200).json(event); + } catch (error: unknown) { + next(error); + } + } + + static async editEvent(req: Request, res: Response, next: NextFunction) { + try { + const { eventId } = req.params as Record; + + const { + title, + requiredMemberIds, + optionalMemberIds, + teamIds, + teamTypeId, + status, + shopIds, + machineryIds, + workPackageIds, + documents, + questionDocumentLink, + location, + zoomLink, + description + } = req.body; + + const event = await CalendarService.editEvent( + req.currentUser, + eventId, + title, + req.organization, + requiredMemberIds, + optionalMemberIds, + status, + teamIds, + shopIds, + machineryIds, + workPackageIds, + documents, + teamTypeId, + questionDocumentLink, + location, + zoomLink, + description + ); + res.status(200).json(event); + } catch (error: unknown) { + next(error); + } + } + + static async editScheduleSlot(req: Request, res: Response, next: NextFunction) { + try { + const { scheduleSlotId } = req.params as Record; + const { startTime, endTime, allDay, editAllInSeries } = req.body; + + const event = await CalendarService.editScheduleSlot( + req.currentUser, + scheduleSlotId, + new Date(startTime), + new Date(endTime), + allDay, + editAllInSeries ?? false, + req.organization + ); + res.status(200).json(event); + } catch (error: unknown) { + next(error); + } + } + + static async previewScheduleSlotRecurringEdits(req: Request, res: Response, next: NextFunction) { + try { + const { scheduleSlotId } = req.params as Record; + + const affectedSlots = await CalendarService.previewScheduleSlotRecurringEdits( + req.currentUser, + scheduleSlotId, + req.organization + ); + res.status(200).json(affectedSlots); + } catch (error: unknown) { + next(error); + } + } + + static async deleteScheduleSlot(req: Request, res: Response, next: NextFunction) { + try { + const { scheduleSlotId } = req.params as Record; + + const event = await CalendarService.deleteScheduleSlot(req.currentUser, scheduleSlotId, req.organization); + res.status(200).json(event); + } catch (error: unknown) { + next(error); + } + } + + static async uploadDocument(req: Request, res: Response, next: NextFunction) { + try { + const { file } = req; + const { eventId } = req.params as Record; + const document = await CalendarService.uploadDocument(eventId, file!, req.currentUser, req.organization); + + res.status(200).json(document); + } catch (error: unknown) { + next(error); + } + } + + static async downloadDocument(req: Request, res: Response, next: NextFunction) { + try { + const { fileId } = req.params as Record; + + const imageData = await CalendarService.downloadDocument(fileId); + + // Set the appropriate headers for the HTTP response + res.setHeader('content-type', String(imageData.type)); + res.setHeader('content-length', imageData.buffer.length); + + // Send the Buffer as the response body + res.status(200).send(imageData.buffer); + } catch (error: unknown) { + next(error); + } + } + + static async approveEvent(req: Request, res: Response, next: NextFunction) { + try { + const { eventId } = req.params as Record; + + const event = await CalendarService.approveEvent(req.currentUser, eventId, req.organization); + res.status(200).json(event); + } catch (error: unknown) { + next(error); + } + } + + static async denyEvent(req: Request, res: Response, next: NextFunction) { + try { + const { eventId } = req.params as Record; + + const event = await CalendarService.denyEvent(req.currentUser, eventId, req.organization); + res.status(200).json(event); + } catch (error: unknown) { + next(error); + } + } + + // Mark the current user as confirmed for the given event + static async markUserConfirmed(req: Request, res: Response, next: NextFunction) { + try { + const { availability } = req.body; + const { eventId } = req.params as Record; + const user = await getCurrentUserWithUserSettings(res); + + const updatedEvent = await CalendarService.markUserConfirmed(eventId, availability, user, req.organization); + res.status(200).json(updatedEvent); + } catch (error: unknown) { + next(error); + } + } + + // Set a new status for the event + static async setStatus(req: Request, res: Response, next: NextFunction) { + try { + const { eventId } = req.params as Record; + const { status } = req.body; + + const updatedEvent = await CalendarService.setStatus(req.currentUser, eventId, status, req.organization); + res.status(200).json(updatedEvent); + } catch (error: unknown) { + next(error); + } + } + + //overall filtering for events + static async getFilteredEvents(req: Request, res: Response, next: NextFunction) { + try { + const filteredEvents = await CalendarService.getFilteredEvents(req.body, req.organization); + res.status(200).json(filteredEvents); + } catch (error: unknown) { + next(error); + } + } + + static async getAllCalendars(req: Request, res: Response, next: NextFunction) { + try { + const calendars = await CalendarService.getAllCalendars(req.organization); + res.status(200).json(calendars); + } catch (error: unknown) { + next(error); + } + } + + static async deleteEvent(req: Request, res: Response, next: NextFunction) { + try { + const { eventId } = req.params as Record; + + const event = await CalendarService.deleteEvent(req.currentUser, eventId, req.organization); + + res.status(200).json(event); + } catch (error: unknown) { + next(error); + } + } + + static async deleteMachinery(req: Request, res: Response, next: NextFunction) { + try { + const { machineryId } = req.params as Record; + + const machinery = await CalendarService.deleteMachinery(req.currentUser, machineryId, req.organization); + + res.status(200).json(machinery); + } catch (error: unknown) { + next(error); + } + } + + static async getSingleEvent(req: Request, res: Response, next: NextFunction) { + try { + const { eventId } = req.params as Record; + + const event = await CalendarService.getSingleEvent(req.currentUser, eventId, req.organization); + res.status(200).json(event); + } catch (error: unknown) { + next(error); + } + } + + static async getSingleEventWithMembers(req: Request, res: Response, next: NextFunction) { + try { + const { eventId } = req.params as Record; + + const event = await CalendarService.getSingleEventWithMembers(req.currentUser, eventId, req.organization); + res.status(200).json(event); + } catch (error: unknown) { + next(error); + } + } + + static async getConflictingEvent(req: Request, res: Response, next: NextFunction) { + try { + const { eventId } = req.params as Record; + + const event = await CalendarService.getConflictingEvent(req.currentUser, eventId, req.organization); + res.status(200).json(event); + } catch (error: unknown) { + next(error); + } + } + + static async getAllEvents(req: Request, res: Response, next: NextFunction) { + try { + const events = await CalendarService.getAllEvents(req.organization); + res.status(200).json(events); + } catch (error: unknown) { + next(error); + } + } + + static async getAllEventTypes(req: Request, res: Response, next: NextFunction) { + try { + const eventTypes = await CalendarService.getAllEventTypes(req.organization); + res.status(200).json(eventTypes); + } catch (error: unknown) { + next(error); + } + } +} diff --git a/src/backend/src/controllers/design-reviews.controllers.ts b/src/backend/src/controllers/design-reviews.controllers.ts index 0458de5144..d18601d7b0 100644 --- a/src/backend/src/controllers/design-reviews.controllers.ts +++ b/src/backend/src/controllers/design-reviews.controllers.ts @@ -1,3 +1,4 @@ +/* import { NextFunction, Request, Response } from 'express'; import DesignReviewsService from '../services/design-reviews.services.js'; import { getCurrentUserWithUserSettings } from '../utils/auth.utils.js'; @@ -133,3 +134,4 @@ export default class DesignReviewsController { } } } +*/ diff --git a/src/backend/src/controllers/teams.controllers.ts b/src/backend/src/controllers/teams.controllers.ts index aeba300506..4e71f67ffe 100644 --- a/src/backend/src/controllers/teams.controllers.ts +++ b/src/backend/src/controllers/teams.controllers.ts @@ -3,6 +3,16 @@ import TeamsService from '../services/teams.services.js'; import { HttpException } from '../utils/errors.utils.js'; export default class TeamsController { + static async getAllTeamPreviews(req: Request, res: Response, next: NextFunction) { + try { + const teams = await TeamsService.getAllTeamPreviews(req.organization); + + res.status(200).json(teams); + } catch (error: unknown) { + next(error); + } + } + static async getAllTeams(req: Request, res: Response, next: NextFunction) { try { const teams = await TeamsService.getAllTeams(req.organization); diff --git a/src/backend/src/controllers/work-packages.controllers.ts b/src/backend/src/controllers/work-packages.controllers.ts index 9b64d5537d..2c0b34ddf9 100644 --- a/src/backend/src/controllers/work-packages.controllers.ts +++ b/src/backend/src/controllers/work-packages.controllers.ts @@ -17,6 +17,22 @@ export default class WorkPackagesController { } } + // Fetch all work packages in preview format (minimal data for dropdowns/lists) + static async getAllWorkPackagesPreview(req: Request, res: Response, next: NextFunction) { + try { + const { status } = req.query as { status?: string }; + + const outputWorkPackages: WorkPackagePreview[] = await WorkPackagesService.getAllWorkPackagesPreview( + status, + req.organization + ); + + res.status(200).json(outputWorkPackages); + } catch (error: unknown) { + next(error); + } + } + // Fetch the work package for the specified WBS number static async getSingleWorkPackage(req: Request, res: Response, next: NextFunction) { try { diff --git a/src/backend/src/prisma-query-args/calendar.query-args.ts b/src/backend/src/prisma-query-args/calendar.query-args.ts new file mode 100644 index 0000000000..09bde4f839 --- /dev/null +++ b/src/backend/src/prisma-query-args/calendar.query-args.ts @@ -0,0 +1,13 @@ +import { Prisma } from '@prisma/client'; +import { getEventTypeQueryArgs } from './event-type.query-args.js'; +import { getUserQueryArgs } from './user.query-args.js'; + +export type CalendarQueryArgs = ReturnType; + +export const getCalendarQueryArgs = (organizationId: string) => + Prisma.validator()({ + include: { + userCreated: getUserQueryArgs(organizationId), + eventTypes: getEventTypeQueryArgs(organizationId) + } + }); diff --git a/src/backend/src/prisma-query-args/design-reviews.query-args.ts b/src/backend/src/prisma-query-args/design-reviews.query-args.ts index ec3819ae30..1c37b33db8 100644 --- a/src/backend/src/prisma-query-args/design-reviews.query-args.ts +++ b/src/backend/src/prisma-query-args/design-reviews.query-args.ts @@ -1,3 +1,4 @@ +/* import { Prisma } from '@prisma/client'; import { getUserQueryArgs, getUserWithSettingsQueryArgs } from './user.query-args.js'; @@ -33,3 +34,4 @@ export const getDesignReviewPreviewQueryArgs = (organizationId: string) => userCreated: getUserWithSettingsQueryArgs(organizationId) } }); +*/ diff --git a/src/backend/src/prisma-query-args/event-type.query-args.ts b/src/backend/src/prisma-query-args/event-type.query-args.ts new file mode 100644 index 0000000000..0d350d0901 --- /dev/null +++ b/src/backend/src/prisma-query-args/event-type.query-args.ts @@ -0,0 +1,16 @@ +import { Prisma } from '@prisma/client'; +import { getUserQueryArgs } from './user.query-args.js'; + +export type EventTypeQueryArgs = ReturnType; + +export const getEventTypeQueryArgs = (organizationId: string) => + Prisma.validator()({ + include: { + userCreated: getUserQueryArgs(organizationId), + calendars: { + select: { + calendarId: true + } + } + } + }); diff --git a/src/backend/src/prisma-query-args/event.query-args.ts b/src/backend/src/prisma-query-args/event.query-args.ts new file mode 100644 index 0000000000..cdd0042195 --- /dev/null +++ b/src/backend/src/prisma-query-args/event.query-args.ts @@ -0,0 +1,128 @@ +import { Prisma } from '@prisma/client'; +import { getUserQueryArgs, getUserWithSettingsQueryArgs } from './user.query-args.js'; + +export type EventQueryArgs = ReturnType; + +export type EventWithMembersQueryArgs = ReturnType; + +export const getEventQueryArgs = (organizationId: string) => + Prisma.validator()({ + include: { + userCreated: getUserWithSettingsQueryArgs(organizationId), + requiredMembers: getUserQueryArgs(organizationId), + optionalMembers: getUserQueryArgs(organizationId), + confirmedMembers: getUserWithSettingsQueryArgs(organizationId), + deniedMembers: getUserQueryArgs(organizationId), + teams: { + select: { + teamName: true, + teamId: true + } + }, + teamType: { + select: { + teamTypeId: true, + name: true + } + }, + shops: { + select: { + name: true, + shopId: true + } + }, + machinery: { + select: { + name: true, + machineryId: true + } + }, + workPackages: { + select: { + wbsElement: { + select: { + name: true, + carNumber: true, + projectNumber: true, + workPackageNumber: true + } + }, + project: { + include: { + wbsElement: true, + teams: true + } + }, + workPackageId: true + } + }, + approvalRequiredBy: getUserQueryArgs(organizationId), + scheduledTimes: true, + notificationSlackThreads: true, + documents: true + } + }); + +export const getEventWithMembersQueryArgs = (organizationId: string) => + Prisma.validator()({ + include: { + userCreated: getUserWithSettingsQueryArgs(organizationId), + requiredMembers: getUserQueryArgs(organizationId), + optionalMembers: getUserQueryArgs(organizationId), + confirmedMembers: getUserWithSettingsQueryArgs(organizationId), + deniedMembers: getUserQueryArgs(organizationId), + teams: { + include: { + members: getUserQueryArgs(organizationId), + leads: getUserQueryArgs(organizationId), + head: getUserQueryArgs(organizationId) + } + }, + teamType: { + include: { + teams: { + include: { + members: getUserQueryArgs(organizationId), + leads: getUserQueryArgs(organizationId), + head: getUserQueryArgs(organizationId) + } + } + } + }, + shops: { + select: { + name: true, + shopId: true + } + }, + machinery: { + select: { + name: true, + machineryId: true + } + }, + workPackages: { + select: { + wbsElement: { + select: { + name: true, + carNumber: true, + projectNumber: true, + workPackageNumber: true + } + }, + project: { + include: { + wbsElement: true, + teams: true + } + }, + workPackageId: true + } + }, + approvalRequiredBy: getUserQueryArgs(organizationId), + scheduledTimes: true, + notificationSlackThreads: true, + documents: true + } + }); diff --git a/src/backend/src/prisma-query-args/machinery.query-args.ts b/src/backend/src/prisma-query-args/machinery.query-args.ts new file mode 100644 index 0000000000..9a851ed9ea --- /dev/null +++ b/src/backend/src/prisma-query-args/machinery.query-args.ts @@ -0,0 +1,21 @@ +import { Prisma } from '@prisma/client'; +import { getUserQueryArgs } from './user.query-args.js'; +import { getShopQueryArgs } from './shop.query-args.js'; + +export type ShopMachineryQueryArgs = ReturnType; +export type MachineryQueryArgs = ReturnType; + +export const getShopMachineryQueryArgs = (organizationId: string) => + Prisma.validator()({ + include: { + shop: getShopQueryArgs(organizationId) + } + }); + +export const getMachineryQueryArgs = (organizationId: string) => + Prisma.validator()({ + include: { + shops: getShopMachineryQueryArgs(organizationId), + userCreated: getUserQueryArgs(organizationId) + } + }); diff --git a/src/backend/src/prisma-query-args/shop.query-args.ts b/src/backend/src/prisma-query-args/shop.query-args.ts new file mode 100644 index 0000000000..c445ac0215 --- /dev/null +++ b/src/backend/src/prisma-query-args/shop.query-args.ts @@ -0,0 +1,11 @@ +import { Prisma } from '@prisma/client'; +import { getUserQueryArgs } from './user.query-args.js'; + +export type ShopQueryArgs = ReturnType; + +export const getShopQueryArgs = (organizationId: string) => + Prisma.validator()({ + include: { + userCreated: getUserQueryArgs(organizationId) + } + }); diff --git a/src/backend/src/prisma-query-args/teams.query-args.ts b/src/backend/src/prisma-query-args/teams.query-args.ts index e8c80742a9..3c22b23b0c 100644 --- a/src/backend/src/prisma-query-args/teams.query-args.ts +++ b/src/backend/src/prisma-query-args/teams.query-args.ts @@ -3,7 +3,7 @@ import { getUserQueryArgs } from './user.query-args.js'; import { getProjectGanttQueryArgs } from './projects.query-args.js'; export type TeamQueryArgs = ReturnType; - +export type TeamBaseQueryArgs = ReturnType; export type TeamPreviewQueryArgs = ReturnType; export const getTeamQueryArgs = (organizationId: string) => @@ -25,6 +25,14 @@ export const getTeamQueryArgs = (organizationId: string) => } }); +export const getTeamBaseQueryArgs = () => { + return Prisma.validator()({ + include: { + teamType: true + } + }); +}; + export const getTeamPreviewQueryArgs = (organizationId: string) => Prisma.validator()({ include: { diff --git a/src/backend/src/prisma-query-args/work-packages.query-args.ts b/src/backend/src/prisma-query-args/work-packages.query-args.ts index d08865e9a1..36ffe33223 100644 --- a/src/backend/src/prisma-query-args/work-packages.query-args.ts +++ b/src/backend/src/prisma-query-args/work-packages.query-args.ts @@ -1,8 +1,8 @@ import { Prisma } from '@prisma/client'; import { getUserPreviewQueryArgs, getUserQueryArgs } from './user.query-args.js'; import { getDescriptionBulletQueryArgs } from './description-bullets.query-args.js'; -import { getDesignReviewPreviewQueryArgs } from './design-reviews.query-args.js'; import { getLinkQueryArgs } from './links.query-args.js'; +import { getEventQueryArgs } from './event.query-args.js'; export type WorkPackageQueryArgs = ReturnType; export type WorkPackagePreviewQueryArgs = ReturnType; @@ -30,11 +30,11 @@ export const getWorkPackageQueryArgs = (organizationId: string) => orderBy: { dateImplemented: 'asc' } }, blocking: { where: { wbsElement: { dateDeleted: null } }, include: { wbsElement: true } }, - descriptionBullets: { where: { dateDeleted: null }, ...getDescriptionBulletQueryArgs(organizationId) }, - designReviews: { where: { dateDeleted: null }, ...getDesignReviewPreviewQueryArgs(organizationId) } + descriptionBullets: { where: { dateDeleted: null }, ...getDescriptionBulletQueryArgs(organizationId) } } }, - blockedBy: { where: { dateDeleted: null } } + blockedBy: { where: { dateDeleted: null } }, + events: { where: { dateDeleted: null }, ...getEventQueryArgs(organizationId) } } }); diff --git a/src/backend/src/prisma/migrations/20260111001906_calendar/migration.sql b/src/backend/src/prisma/migrations/20260111001906_calendar/migration.sql new file mode 100644 index 0000000000..3833d48c7c --- /dev/null +++ b/src/backend/src/prisma/migrations/20260111001906_calendar/migration.sql @@ -0,0 +1,615 @@ +-- CreateEnum +CREATE TYPE "public"."DayOfWeek" AS ENUM ('MONDAY', 'TUESDAY', 'WEDNESDAY', 'THURSDAY', 'FRIDAY', 'SATURDAY', 'SUNDAY'); + +-- CreateEnum +CREATE TYPE "Conflict_Status" AS ENUM ('PENDING', 'APPROVED', 'DENIED', 'NO_CONFLICT'); + +-- CreateTable +CREATE TABLE "public"."Shop" ( + "shopId" TEXT NOT NULL, + "name" TEXT NOT NULL, + "dateCreated" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "dateDeleted" TIMESTAMP(3), + "userCreatedId" TEXT NOT NULL, + "userDeletedId" TEXT, + "description" TEXT NOT NULL, + "organizationId" TEXT NOT NULL, + + CONSTRAINT "Shop_pkey" PRIMARY KEY ("shopId") +); + +-- CreateTable +CREATE TABLE "public"."Machinery" ( + "machineryId" TEXT NOT NULL, + "name" TEXT NOT NULL, + "dateCreated" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "dateDeleted" TIMESTAMP(3), + "userCreatedId" TEXT NOT NULL, + "userDeletedId" TEXT, + "organizationId" TEXT NOT NULL, + + CONSTRAINT "Machinery_pkey" PRIMARY KEY ("machineryId") +); + +-- CreateTable +CREATE TABLE "public"."Shop_Machinery" ( + "shopMachineryId" TEXT NOT NULL, + "shopId" TEXT NOT NULL, + "machineryId" TEXT NOT NULL, + "quantity" INTEGER NOT NULL DEFAULT 1, + + CONSTRAINT "Shop_Machinery_pkey" PRIMARY KEY ("shopMachineryId") +); + +-- CreateTable +CREATE TABLE "public"."Schedule_Slot" ( + "scheduleSlotId" TEXT NOT NULL, + "startTime" TIMESTAMP(3) NOT NULL, + "endTime" TIMESTAMP(3) NOT NULL, + "allDay" BOOLEAN NOT NULL DEFAULT false, + "eventId" TEXT NOT NULL, + + CONSTRAINT "Schedule_Slot_pkey" PRIMARY KEY ("scheduleSlotId") +); + +-- CreateTable +CREATE TABLE "Document" ( + "documentId" TEXT NOT NULL, + "googleFileId" TEXT NOT NULL, + "name" TEXT NOT NULL, + "deletedByUserId" TEXT, + "dateDeleted" TIMESTAMP(3), + "createdByUserId" TEXT NOT NULL, + "dateCreated" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "documentEventId" TEXT NOT NULL, + + CONSTRAINT "Document_pkey" PRIMARY KEY ("documentId") +); + +-- CreateTable +CREATE TABLE "public"."Event" ( + "eventId" TEXT NOT NULL, + "dateCreated" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "dateDeleted" TIMESTAMP(3), + "title" TEXT NOT NULL, + "userCreatedId" TEXT NOT NULL, + "userDeletedId" TEXT, + "eventTypeId" TEXT NOT NULL, + "approved" "public"."Conflict_Status" NOT NULL, + "approvalRequiredFromUserId" TEXT, + "location" TEXT, + "zoomLink" TEXT, + "initialDateScheduled" TIMESTAMP(3), + "questionDocumentLink" TEXT, + "description" TEXT, + "teamTypeId" TEXT, + "calendarEventIds" TEXT[], + + CONSTRAINT "Event_pkey" PRIMARY KEY ("eventId") +); + +-- CreateTable +CREATE TABLE "public"."Calendar" ( + "calendarId" TEXT NOT NULL, + "name" TEXT NOT NULL, + "dateCreated" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "dateDeleted" TIMESTAMP(3), + "userCreatedId" TEXT NOT NULL, + "userDeletedId" TEXT, + "description" TEXT NOT NULL, + "colorHexCode" TEXT NOT NULL, + "organizationId" TEXT NOT NULL, + + CONSTRAINT "Calendar_pkey" PRIMARY KEY ("calendarId") +); + +-- CreateTable +CREATE TABLE "public"."Event_Type" ( + "eventTypeId" TEXT NOT NULL, + "name" TEXT NOT NULL, + "dateCreated" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "dateDeleted" TIMESTAMP(3), + "userCreatedId" TEXT NOT NULL, + "userDeletedId" TEXT, + "optionalMembers" BOOLEAN NOT NULL DEFAULT FALSE, + "requiredMembers" BOOLEAN NOT NULL DEFAULT FALSE, + "teams" BOOLEAN NOT NULL DEFAULT FALSE, + "teamType" BOOLEAN NOT NULL DEFAULT FALSE, + "location" BOOLEAN NOT NULL DEFAULT FALSE, + "zoomLink" BOOLEAN NOT NULL DEFAULT FALSE, + "shop" BOOLEAN NOT NULL DEFAULT FALSE, + "machinery" BOOLEAN NOT NULL DEFAULT FALSE, + "workPackage" BOOLEAN NOT NULL DEFAULT FALSE, + "questionDocument" BOOLEAN NOT NULL DEFAULT FALSE, + "documents" BOOLEAN NOT NULL DEFAULT FALSE, + "description" BOOLEAN NOT NULL DEFAULT FALSE, + "onlyHeadsOrAboveForEventCreation" BOOLEAN NOT NULL DEFAULT FALSE, + "requiresConfirmation" BOOLEAN NOT NULL DEFAULT FALSE, + "sendSlackNotifications" BOOLEAN NOT NULL DEFAULT FALSE, + "organizationId" TEXT NOT NULL, + + CONSTRAINT "Event_Type_pkey" PRIMARY KEY ("eventTypeId") +); + +-- CreateEnum +CREATE TYPE "public"."Event_Status" AS ENUM ('UNCONFIRMED', 'CONFIRMED', 'SCHEDULED', 'DONE'); + +-- AlterTable +ALTER TABLE "public"."Event" ADD COLUMN "status" "public"."Event_Status" NOT NULL; + +-- CreateTable +CREATE TABLE "public"."_affiliatedTeam" ( + "A" TEXT NOT NULL, + "B" TEXT NOT NULL, + + CONSTRAINT "_affiliatedTeam_AB_pkey" PRIMARY KEY ("A","B") +); + +-- CreateTable +CREATE TABLE "public"."_EventToShop" ( + "A" TEXT NOT NULL, + "B" TEXT NOT NULL, + + CONSTRAINT "_EventToShop_AB_pkey" PRIMARY KEY ("A","B") +); + +-- CreateTable +CREATE TABLE "public"."_EventToMachinery" ( + "A" TEXT NOT NULL, + "B" TEXT NOT NULL, + + CONSTRAINT "_EventToMachinery_AB_pkey" PRIMARY KEY ("A","B") +); + +-- CreateTable +CREATE TABLE "public"."_EventToWork_Package" ( + "A" TEXT NOT NULL, + "B" TEXT NOT NULL, + + CONSTRAINT "_EventToWork_Package_AB_pkey" PRIMARY KEY ("A","B") +); + +-- CreateTable +CREATE TABLE "public"."_CalendarToEvent_Type" ( + "A" TEXT NOT NULL, + "B" TEXT NOT NULL, + + CONSTRAINT "_CalendarToEvent_Type_AB_pkey" PRIMARY KEY ("A","B") +); + +-- CreateTable +CREATE TABLE "public"."_requiredEventAttendee" ( + "A" TEXT NOT NULL, + "B" TEXT NOT NULL, + + CONSTRAINT "_requiredEventAttendee_AB_pkey" PRIMARY KEY ("A","B") +); + +-- CreateTable +CREATE TABLE "public"."_optionalEventAttendee" ( + "A" TEXT NOT NULL, + "B" TEXT NOT NULL, + + CONSTRAINT "_optionalEventAttendee_AB_pkey" PRIMARY KEY ("A","B") +); + +-- CreateTable +CREATE TABLE "public"."_confirmedEventAttendee" ( + "A" TEXT NOT NULL, + "B" TEXT NOT NULL, + + CONSTRAINT "_confirmedEventAttendee_AB_pkey" PRIMARY KEY ("A","B") +); + +-- CreateTable +CREATE TABLE "public"."_deniedEventAttendee" ( + "A" TEXT NOT NULL, + "B" TEXT NOT NULL, + + CONSTRAINT "_deniedEventAttendee_AB_pkey" PRIMARY KEY ("A","B") +); + +-- CreateIndex +CREATE INDEX "_requiredEventAttendee_B_index" ON "public"."_requiredEventAttendee"("B"); + +-- CreateIndex +CREATE INDEX "_optionalEventAttendee_B_index" ON "public"."_optionalEventAttendee"("B"); + +-- CreateIndex +CREATE INDEX "_confirmedEventAttendee_B_index" ON "public"."_confirmedEventAttendee"("B"); + +-- CreateIndex +CREATE INDEX "_deniedEventAttendee_B_index" ON "public"."_deniedEventAttendee"("B"); + +-- CreateIndex +CREATE UNIQUE INDEX "Document_googleFileId_key" ON "Document"("googleFileId"); + +-- CreateIndex +CREATE INDEX "Document_documentEventId_idx" ON "Document"("documentEventId"); + +-- AddForeignKey +ALTER TABLE "Document" ADD CONSTRAINT "Document_deletedByUserId_fkey" FOREIGN KEY ("deletedByUserId") REFERENCES "User"("userId") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Document" ADD CONSTRAINT "Document_createdByUserId_fkey" FOREIGN KEY ("createdByUserId") REFERENCES "User"("userId") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Document" ADD CONSTRAINT "Document_documentEventId_fkey" FOREIGN KEY ("documentEventId") REFERENCES "Event"("eventId") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "public"."_requiredEventAttendee" ADD CONSTRAINT "_requiredEventAttendee_A_fkey" FOREIGN KEY ("A") REFERENCES "public"."Event"("eventId") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "public"."_requiredEventAttendee" ADD CONSTRAINT "_requiredEventAttendee_B_fkey" FOREIGN KEY ("B") REFERENCES "public"."User"("userId") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "public"."_optionalEventAttendee" ADD CONSTRAINT "_optionalEventAttendee_A_fkey" FOREIGN KEY ("A") REFERENCES "public"."Event"("eventId") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "public"."_optionalEventAttendee" ADD CONSTRAINT "_optionalEventAttendee_B_fkey" FOREIGN KEY ("B") REFERENCES "public"."User"("userId") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "public"."_confirmedEventAttendee" ADD CONSTRAINT "_confirmedEventAttendee_A_fkey" FOREIGN KEY ("A") REFERENCES "public"."Event"("eventId") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "public"."_confirmedEventAttendee" ADD CONSTRAINT "_confirmedEventAttendee_B_fkey" FOREIGN KEY ("B") REFERENCES "public"."User"("userId") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "public"."_deniedEventAttendee" ADD CONSTRAINT "_deniedEventAttendee_A_fkey" FOREIGN KEY ("A") REFERENCES "public"."Event"("eventId") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "public"."_deniedEventAttendee" ADD CONSTRAINT "_deniedEventAttendee_B_fkey" FOREIGN KEY ("B") REFERENCES "public"."User"("userId") ON DELETE CASCADE ON UPDATE CASCADE; + +-- CreateIndex +CREATE INDEX "Shop_Machinery_machineryId_idx" ON "public"."Shop_Machinery"("machineryId"); + +-- CreateIndex +CREATE UNIQUE INDEX "Shop_Machinery_shopId_machineryId_key" ON "public"."Shop_Machinery"("shopId", "machineryId"); + +-- CreateIndex +CREATE INDEX "Schedule_Slot_endTime_idx" ON "public"."Schedule_Slot"("endTime"); + +-- CreateIndex +CREATE INDEX "Schedule_Slot_startTime_idx" ON "public"."Schedule_Slot"("startTime"); + +-- CreateIndex +CREATE INDEX "_affiliatedTeam_B_index" ON "public"."_affiliatedTeam"("B"); + +-- CreateIndex +CREATE INDEX "_EventToShop_B_index" ON "public"."_EventToShop"("B"); + +-- CreateIndex +CREATE INDEX "_EventToMachinery_B_index" ON "public"."_EventToMachinery"("B"); + +-- CreateIndex +CREATE INDEX "_EventToWork_Package_B_index" ON "public"."_EventToWork_Package"("B"); + +-- CreateIndex +CREATE INDEX "_CalendarToEvent_Type_B_index" ON "public"."_CalendarToEvent_Type"("B"); + +-- AddForeignKey +ALTER TABLE "public"."Shop" ADD CONSTRAINT "Shop_userCreatedId_fkey" FOREIGN KEY ("userCreatedId") REFERENCES "public"."User"("userId") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "public"."Shop" ADD CONSTRAINT "Shop_userDeletedId_fkey" FOREIGN KEY ("userDeletedId") REFERENCES "public"."User"("userId") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "public"."Shop" ADD CONSTRAINT "Shop_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "public"."Organization"("organizationId") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "public"."Machinery" ADD CONSTRAINT "Machinery_userCreatedId_fkey" FOREIGN KEY ("userCreatedId") REFERENCES "public"."User"("userId") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "public"."Machinery" ADD CONSTRAINT "Machinery_userDeletedId_fkey" FOREIGN KEY ("userDeletedId") REFERENCES "public"."User"("userId") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "public"."Machinery" ADD CONSTRAINT "Machinery_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "public"."Organization"("organizationId") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "public"."Shop_Machinery" ADD CONSTRAINT "Shop_Machinery_shopId_fkey" FOREIGN KEY ("shopId") REFERENCES "public"."Shop"("shopId") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "public"."Shop_Machinery" ADD CONSTRAINT "Shop_Machinery_machineryId_fkey" FOREIGN KEY ("machineryId") REFERENCES "public"."Machinery"("machineryId") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "public"."Event" ADD CONSTRAINT "Event_userCreatedId_fkey" FOREIGN KEY ("userCreatedId") REFERENCES "public"."User"("userId") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "public"."Event" ADD CONSTRAINT "Event_userDeletedId_fkey" FOREIGN KEY ("userDeletedId") REFERENCES "public"."User"("userId") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "public"."Event" ADD CONSTRAINT "Event_eventTypeId_fkey" FOREIGN KEY ("eventTypeId") REFERENCES "public"."Event_Type"("eventTypeId") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "public"."Event" ADD CONSTRAINT "Event_approvalRequiredFromUserId_fkey" FOREIGN KEY ("approvalRequiredFromUserId") REFERENCES "public"."User"("userId") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "public"."Event" ADD CONSTRAINT "Event_teamTypeId_fkey" FOREIGN KEY ("teamTypeId") REFERENCES "public"."Team_Type"("teamTypeId") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "public"."Calendar" ADD CONSTRAINT "Calendar_userCreatedId_fkey" FOREIGN KEY ("userCreatedId") REFERENCES "public"."User"("userId") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "public"."Calendar" ADD CONSTRAINT "Calendar_userDeletedId_fkey" FOREIGN KEY ("userDeletedId") REFERENCES "public"."User"("userId") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "public"."Calendar" ADD CONSTRAINT "Calendar_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "public"."Organization"("organizationId") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "public"."Event_Type" ADD CONSTRAINT "Event_Type_userCreatedId_fkey" FOREIGN KEY ("userCreatedId") REFERENCES "public"."User"("userId") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "public"."Event_Type" ADD CONSTRAINT "Event_Type_userDeletedId_fkey" FOREIGN KEY ("userDeletedId") REFERENCES "public"."User"("userId") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "public"."Event_Type" ADD CONSTRAINT "Event_Type_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "public"."Organization"("organizationId") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "public"."Schedule_Slot" ADD CONSTRAINT "Schedule_Slot_EventId_fkey" FOREIGN KEY ("eventId") REFERENCES "Event"("eventId") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "public"."_affiliatedTeam" ADD CONSTRAINT "_affiliatedTeam_A_fkey" FOREIGN KEY ("A") REFERENCES "public"."Event"("eventId") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "public"."_affiliatedTeam" ADD CONSTRAINT "_affiliatedTeam_B_fkey" FOREIGN KEY ("B") REFERENCES "public"."Team"("teamId") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "public"."_EventToShop" ADD CONSTRAINT "_EventToShop_A_fkey" FOREIGN KEY ("A") REFERENCES "public"."Event"("eventId") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "public"."_EventToShop" ADD CONSTRAINT "_EventToShop_B_fkey" FOREIGN KEY ("B") REFERENCES "public"."Shop"("shopId") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "public"."_EventToMachinery" ADD CONSTRAINT "_EventToMachinery_A_fkey" FOREIGN KEY ("A") REFERENCES "public"."Event"("eventId") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "public"."_EventToMachinery" ADD CONSTRAINT "_EventToMachinery_B_fkey" FOREIGN KEY ("B") REFERENCES "public"."Machinery"("machineryId") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "public"."_EventToWork_Package" ADD CONSTRAINT "_EventToWork_Package_A_fkey" FOREIGN KEY ("A") REFERENCES "public"."Event"("eventId") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "public"."_EventToWork_Package" ADD CONSTRAINT "_EventToWork_Package_B_fkey" FOREIGN KEY ("B") REFERENCES "public"."Work_Package"("workPackageId") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "public"."_CalendarToEvent_Type" ADD CONSTRAINT "_CalendarToEvent_Type_A_fkey" FOREIGN KEY ("A") REFERENCES "public"."Calendar"("calendarId") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "public"."_CalendarToEvent_Type" ADD CONSTRAINT "_CalendarToEvent_Type_B_fkey" FOREIGN KEY ("B") REFERENCES "public"."Event_Type"("eventTypeId") ON DELETE CASCADE ON UPDATE CASCADE; + +-- Create Event_Types for Design Review (one per organization) +INSERT INTO "public"."Event_Type" ( + "eventTypeId", + "name", + "dateCreated", + "userCreatedId", + "requiredMembers", + "optionalMembers", + "teams", + "teamType", + "location", + "zoomLink", + "shop", + "machinery", + "workPackage", + "questionDocument", + "documents", + "description", + "onlyHeadsOrAboveForEventCreation", + "requiresConfirmation", + "organizationId" +) +SELECT DISTINCT ON (org."organizationId") + gen_random_uuid(), + 'Design Review', + NOW(), + org."userCreatedId", + true, -- requiredMembers + true, -- optionalMembers + false, -- teams + true, -- team type + true, -- location + true, -- zoomLink + false, -- shop + false, -- machinery + true, -- workPackage (based on wbsElementId) + true, -- questionDocument (docTemplateLink) + true, -- documents + false, -- description + true, -- onlyHeadsOrAboveForEventCreation + true, -- requiresConfirmation + org."organizationId" +FROM "public"."Organization" org +WHERE EXISTS ( + SELECT 1 FROM "public"."Design_Review" dr + JOIN "public"."WBS_Element" w ON dr."wbsElementId" = w."wbsElementId" + WHERE w."organizationId" = org."organizationId" + AND dr."dateDeleted" IS NULL +); + +-- Create Event_Types for Meeting (one per organization) +INSERT INTO "public"."Event_Type" ( + "eventTypeId", + "name", + "dateCreated", + "userCreatedId", + "requiredMembers", + "optionalMembers", + "teams", + "teamType", + "location", + "zoomLink", + "shop", + "machinery", + "workPackage", + "questionDocument", + "documents", + "description", + "onlyHeadsOrAboveForEventCreation", + "requiresConfirmation", + "organizationId" +) +SELECT DISTINCT ON (org."organizationId") + gen_random_uuid(), + 'Meeting', + NOW(), + org."userCreatedId", + false, -- requiredMembers + false, -- optionalMembers + true, -- teams + false, -- team type + false, -- location + false, -- zoomLink + false, -- shop + false, -- machinery + false, -- workPackage + false, -- questionDocument + false, -- documents + false, -- description + false, -- onlyHeadsOrAboveForEventCreation + false, -- requiresConfirmation + org."organizationId" +FROM "public"."Organization" org +WHERE EXISTS ( + SELECT 1 FROM "public"."Meeting" m + JOIN "public"."Team" t ON m."teamId" = t."teamId" + WHERE t."organizationId" = org."organizationId" +); + +-- Migrate Design_Review records to Event table +INSERT INTO "public"."Event" ( + "eventId", + "dateCreated", + "dateDeleted", + "title", + "userCreatedId", + "userDeletedId", + "eventTypeId", + "approved", + "approvalRequiredFromUserId", + "location", + "zoomLink", + "questionDocumentLink", + "description", + "initialDateScheduled", + "status", + "teamTypeId", + "calendarEventIds" +) +SELECT + dr."designReviewId", + dr."dateCreated", + dr."dateDeleted", + 'Design Review - ' || w."name", -- Generate title from WBS element name + dr."userCreatedId", + dr."userDeletedId", + (SELECT et."eventTypeId" + FROM "public"."Event_Type" et + WHERE et."name" = 'Design Review' + AND et."organizationId" = w."organizationId" + LIMIT 1), + 'NO_CONFLICT'::public."Conflict_Status" , + NULL, -- approvalRequiredFromUserId (not in Design_Review) + dr."location", + dr."zoomLink", + dr."docTemplateLink", -- questionDocument uses docTemplateLink + NULL, -- description (not in Design_Review) + dr."initialDateScheduled", + dr."status"::"text"::"public"."Event_Status", + dr."teamTypeId", + CASE WHEN dr."calendarEventId" IS NOT NULL THEN ARRAY[dr."calendarEventId"] ELSE ARRAY[]::TEXT[] END +FROM "public"."Design_Review" dr +JOIN "public"."WBS_Element" w ON dr."wbsElementId" = w."wbsElementId"; + +-- Create Schedule_Slot records for Design Reviews +-- This creates one slot per time per design review +CREATE TEMP TABLE temp_dr_schedule_slots AS +SELECT + dr."designReviewId" as event_id, + gen_random_uuid() as slot_id, + dr."dateScheduled" + ((10 + time_slot) * INTERVAL '1 hour') as start_time, + dr."dateScheduled" + ((11 + time_slot) * INTERVAL '1 hour') as end_time, + false as all_day +FROM "public"."Design_Review" dr +CROSS JOIN LATERAL unnest(dr."meetingTimes") AS time_slot; + +-- Insert schedule slots from temp table +INSERT INTO "public"."Schedule_Slot" ( + "scheduleSlotId", + "startTime", + "endTime", + "allDay", + "eventId" +) +SELECT + slot_id, + start_time, + end_time, + all_day, + event_id +FROM temp_dr_schedule_slots; + +-- Drop temp table +DROP TABLE temp_dr_schedule_slots; + +-- Migrate Design Review member relationships +INSERT INTO "public"."_requiredEventAttendee" ("A", "B") +SELECT dr."designReviewId", ra."B" +FROM "public"."Design_Review" dr +JOIN "public"."_requiredAttendee" ra ON dr."designReviewId" = ra."A"; + +INSERT INTO "public"."_optionalEventAttendee" ("A", "B") +SELECT dr."designReviewId", oa."B" +FROM "public"."Design_Review" dr +JOIN "public"."_optionalAttendee" oa ON dr."designReviewId" = oa."A"; + +INSERT INTO "public"."_confirmedEventAttendee" ("A", "B") +SELECT dr."designReviewId", ca."B" +FROM "public"."Design_Review" dr +JOIN "public"."_confirmedAttendee" ca ON dr."designReviewId" = ca."A"; + +INSERT INTO "public"."_deniedEventAttendee" ("A", "B") +SELECT dr."designReviewId", da."B" +FROM "public"."Design_Review" dr +JOIN "public"."_deniedAttendee" da ON dr."designReviewId" = da."A"; + +-- Link Design Reviews to Work Packages (via wbsElementId) +INSERT INTO "public"."_EventToWork_Package" ("A", "B") +SELECT dr."designReviewId", wp."workPackageId" +FROM "public"."Design_Review" dr +JOIN "public"."Work_Package" wp ON dr."wbsElementId" = wp."wbsElementId"; + +-- Skip Meetings migration because there are no existing meetings + +ALTER TABLE "Message_Info" ADD COLUMN "eventId" TEXT; + +UPDATE "Message_Info" +SET "eventId" = "designReviewId" +WHERE "designReviewId" IS NOT NULL; + +DROP INDEX IF EXISTS "Message_Info_designReviewId_idx"; + +ALTER TABLE "Message_Info" DROP COLUMN IF EXISTS "designReviewId"; + +CREATE INDEX IF NOT EXISTS "Message_Info_eventId_idx" ON "Message_Info"("eventId"); + +ALTER TABLE "Message_Info" +ADD CONSTRAINT "Message_Info_eventId_fkey" +FOREIGN KEY ("eventId") +REFERENCES "Event"("eventId") +ON DELETE SET NULL +ON UPDATE CASCADE; + +-- Drop old relation tables for Design_Review +DROP TABLE "public"."_requiredAttendee" CASCADE; +DROP TABLE "public"."_optionalAttendee" CASCADE; +DROP TABLE "public"."_confirmedAttendee" CASCADE; +DROP TABLE "public"."_deniedAttendee" CASCADE; +DROP TABLE "public"."_userAttended" CASCADE; + +-- Drop the old Meeting and Design_Review tables +DROP TABLE "public"."Meeting" CASCADE; +DROP TABLE "public"."Design_Review" CASCADE; + +-- DropEnum +DROP TYPE "public"."Design_Review_Status"; \ No newline at end of file diff --git a/src/backend/src/prisma/schema.prisma b/src/backend/src/prisma/schema.prisma index e5bd720945..2a35b18cc1 100644 --- a/src/backend/src/prisma/schema.prisma +++ b/src/backend/src/prisma/schema.prisma @@ -93,7 +93,7 @@ enum Material_Status { READY_TO_ORDER } -enum Design_Review_Status { +enum Event_Status { UNCONFIRMED CONFIRMED SCHEDULED @@ -206,13 +206,6 @@ model User { archivedTeams Team[] @relation(name: "userArchived") createdMaterialTypes Material_Type[] @relation(name: "materialTypeCreator") createdManufacturers Manufacturer[] @relation(name: "manufacturerCreator") - requiredDesignReviews Design_Review[] @relation(name: "requiredAttendee") - optionalDesignReviews Design_Review[] @relation(name: "optionalAttendee") - userConfirmedDesignReviews Design_Review[] @relation(name: "confirmedAttendee") - userDeniedDesignReviews Design_Review[] @relation(name: "deniedAttendee") - attendedDesignReviews Design_Review[] @relation(name: "userAttended") - createdDesignReviews Design_Review[] @relation(name: "designReviewCreator") - deletedDesignReviews Design_Review[] @relation(name: "designReviewDeleter") drScheduleSettings Schedule_Settings? wbsProposedlead Wbs_Proposed_Changes[] @relation(name: "wbslead") wbsProposedmanager Wbs_Proposed_Changes[] @relation(name: "wbsmanager") @@ -263,9 +256,26 @@ model User { deletedOtherReimbursementProductReasons Reimbursement_Product_Other_Reason[] @relation(name: "deletedOtherReimbursementProductReasons") reimbursementRequestComments Reimbursement_Request_Comment[] @relation(name: "createdReimbursementRequestComments") deletedReimbursementRequestComments Reimbursement_Request_Comment[] @relation(name: "deletedReimbursementRequestComments") + createdMachinery Machinery[] @relation(name: "machineryCreator") + deletedMachinery Machinery[] @relation(name: "machineryDeleter") + createdShops Shop[] @relation(name: "shopCreator") + deletedShops Shop[] @relation(name: "shopDeleter") + approvedEvents Event[] @relation(name: "eventApprovalRequiredBy") + createdEvents Event[] @relation(name: "eventCreator") + deletedEvents Event[] @relation(name: "eventDeleter") + createdCalendars Calendar[] @relation(name: "calendarCreator") + deletedCalendars Calendar[] @relation(name: "calendarDeleter") + createdEventTypes Event_Type[] @relation(name: "eventTypeCreator") + deletedEventTypes Event_Type[] @relation(name: "eventTypeDeleter") deletedSponsorTiers Sponsor_Tier[] financeDelegateForOrganizations Organization[] @relation(name: "financeDelegates") assignedReimbursementRequests Reimbursement_Request[] @relation(name: "reimbursementRequestAssignee") + requiredEvents Event[] @relation(name: "requiredEventAttendee") + optionalEvents Event[] @relation(name: "optionalEventAttendee") + confirmedEvents Event[] @relation(name: "confirmedEventAttendee") + deniedEvents Event[] @relation(name: "deniedEventAttendee") + deletedDocuments Document[] @relation(name: "deletedDocuments") + createdDocuments Document[] @relation(name: "documentsCreatedBy") } model Role { @@ -289,12 +299,12 @@ model Team { projects Project[] @relation(name: "assignedBy") members User[] @relation(name: "teamsAsMember") leads User[] @relation(name: "teamsAsLead") + events Event[] @relation(name: "affiliatedTeam") headId String head User @relation(name: "teamsAsHead", fields: [headId], references: [userId]) dateArchived DateTime? userArchivedId String? userArchived User? @relation(name: "userArchived", fields: [userArchivedId], references: [userId]) - meetings Meeting[] proposedProjectChanges Project_Proposed_Changes[] @relation(name: "proposedProjectTeams") teamType Team_Type? @relation(fields: [teamTypeId], references: [teamTypeId]) teamTypeId String? @@ -369,13 +379,13 @@ model Message_Info { changeRequestId String? changeRequest Change_Request? @relation(fields: [changeRequestId], references: [crId]) - designReviewId String? - designReview Design_Review? @relation(fields: [designReviewId], references: [designReviewId]) + eventId String? + event Event? @relation(fields: [eventId], references: [eventId]) reimbursementRequestId String? reimbursementRequest Reimbursement_Request? @relation(fields: [reimbursementRequestId], references: [reimbursementRequestId]) @@index([reimbursementRequestId]) - @@index([designReviewId]) + @@index([eventId]) @@index([changeRequestId]) } @@ -504,7 +514,6 @@ model WBS_Element { assemblies Assembly[] materials Material[] reimbursementProductReasons Reimbursement_Product_Reason[] - designReviews Design_Review[] proposedBlockedByChanges Work_Package_Proposed_Changes[] @relation(name: "proposedBlockedBy") descriptionBullets Description_Bullet[] organizationId String @@ -544,6 +553,7 @@ model Work_Package { duration Int blockedBy WBS_Element[] @relation(name: "blockedBy") stage Work_Package_Stage? + events Event[] @@index([projectId]) } @@ -959,62 +969,199 @@ model Manufacturer { @@index([organizationId]) } +model Shop { + shopId String @id @default(uuid()) + name String @unique + dateCreated DateTime @default(now()) + dateDeleted DateTime? + userCreatedId String + userCreated User @relation(name: "shopCreator", fields: [userCreatedId], references: [userId]) + userDeletedId String? + userDeleted User? @relation(fields: [userDeletedId], references: [userId], name: "shopDeleter") + machinery Shop_Machinery[] + description String + events Event[] + organizationId String + organization Organization @relation(fields: [organizationId], references: [organizationId]) +} + +model Machinery { + machineryId String @id @default(uuid()) + name String + dateCreated DateTime @default(now()) + dateDeleted DateTime? + userCreatedId String + userCreated User @relation(name: "machineryCreator", fields: [userCreatedId], references: [userId]) + userDeletedId String? + userDeleted User? @relation(fields: [userDeletedId], references: [userId], name: "machineryDeleter") + shops Shop_Machinery[] + events Event[] + organizationId String + organization Organization @relation(fields: [organizationId], references: [organizationId]) +} + +model Shop_Machinery { + shopMachineryId String @id @default(uuid()) + shopId String + machineryId String + quantity Int @default(1) + shop Shop @relation(fields: [shopId], references: [shopId]) + machinery Machinery @relation(fields: [machineryId], references: [machineryId]) + + @@unique([shopId, machineryId], name: "uniqueShopMachinery") + @@index([machineryId]) +} + +enum DayOfWeek { + MONDAY + TUESDAY + WEDNESDAY + THURSDAY + FRIDAY + SATURDAY + SUNDAY +} + +model Schedule_Slot { + scheduleSlotId String @id @default(uuid()) + startTime DateTime + endTime DateTime + allDay Boolean @default(false) + eventId String + event Event @relation(fields: [eventId], references: [eventId]) + + @@index([endTime]) + @@index([startTime]) +} + +enum Conflict_Status { + PENDING + APPROVED + DENIED + NO_CONFLICT +} + +model Document { + documentId String @id @default(uuid()) + googleFileId String @unique + name String + deletedByUserId String? + deletedBy User? @relation(name: "deletedDocuments", fields: [deletedByUserId], references: [userId]) + dateDeleted DateTime? + createdBy User @relation(name: "documentsCreatedBy", fields: [createdByUserId], references: [userId]) + createdByUserId String + dateCreated DateTime @default(now()) + documentEventId String + documentEvent Event @relation(fields: [documentEventId], references: [eventId]) + + @@index([documentEventId]) +} + +model Event { + eventId String @id @default(uuid()) + dateCreated DateTime @default(now()) + dateDeleted DateTime? + title String + userCreatedId String + userCreated User @relation(name: "eventCreator", fields: [userCreatedId], references: [userId]) + userDeletedId String? + userDeleted User? @relation(fields: [userDeletedId], references: [userId], name: "eventDeleter") + eventTypeId String + eventType Event_Type @relation(fields: [eventTypeId], references: [eventTypeId]) + approved Conflict_Status + approvalRequiredFromUserId String? + approvalRequiredBy User? @relation(fields: [approvalRequiredFromUserId], references: [userId], name: "eventApprovalRequiredBy") + scheduledTimes Schedule_Slot[] + requiredMembers User[] @relation(name: "requiredEventAttendee") + optionalMembers User[] @relation(name: "optionalEventAttendee") + confirmedMembers User[] @relation(name: "confirmedEventAttendee") + deniedMembers User[] @relation(name: "deniedEventAttendee") + teams Team[] @relation(name: "affiliatedTeam") + teamType Team_Type? @relation(fields: [teamTypeId], references: [teamTypeId]) + teamTypeId String? + location String? + zoomLink String? + shops Shop[] + machinery Machinery[] + workPackages Work_Package[] + documents Document[] + status Event_Status + initialDateScheduled DateTime? + questionDocumentLink String? + description String? + notificationSlackThreads Message_Info[] + calendarEventIds String[] +} + +model Calendar { + calendarId String @id @default(uuid()) + name String + dateCreated DateTime @default(now()) + dateDeleted DateTime? + userCreatedId String + userCreated User @relation(name: "calendarCreator", fields: [userCreatedId], references: [userId]) + userDeletedId String? + userDeleted User? @relation(fields: [userDeletedId], references: [userId], name: "calendarDeleter") + description String + colorHexCode String + eventTypes Event_Type[] + organizationId String + organization Organization @relation(fields: [organizationId], references: [organizationId]) +} + +model Event_Type { + eventTypeId String @id @default(uuid()) + name String + dateCreated DateTime @default(now()) + dateDeleted DateTime? + userCreatedId String + userCreated User @relation(name: "eventTypeCreator", fields: [userCreatedId], references: [userId]) + userDeletedId String? + userDeleted User? @relation(fields: [userDeletedId], references: [userId], name: "eventTypeDeleter") + calendars Calendar[] + requiredMembers Boolean + optionalMembers Boolean + teams Boolean + teamType Boolean + location Boolean + zoomLink Boolean + shop Boolean + machinery Boolean + workPackage Boolean + questionDocument Boolean + documents Boolean + description Boolean + onlyHeadsOrAboveForEventCreation Boolean + requiresConfirmation Boolean + sendSlackNotifications Boolean + events Event[] + organizationId String + organization Organization @relation(fields: [organizationId], references: [organizationId]) +} + model Team_Type { - teamTypeId String @id @default(uuid()) + teamTypeId String @id @default(uuid()) name String iconName String - designReviews Design_Review[] teams Team[] - description String @default("") + description String @default("") imageFileId String? organizationId String - organization Organization @relation(fields: [organizationId], references: [organizationId]) + organization Organization @relation(fields: [organizationId], references: [organizationId]) calendarId String? checklists Checklist[] - usersOnboarding User[] @relation(name: "onboardingTeamTypes") - usersOnboarded User[] @relation(name: "onboardedTeamTypes") + usersOnboarding User[] @relation(name: "onboardingTeamTypes") + usersOnboarded User[] @relation(name: "onboardedTeamTypes") dateDeleted DateTime? deletedById String? deletedBy User? @relation(name: "teamTypeDeleter", fields: [deletedById], references: [userId]) + events Event[] @@unique([name, organizationId], name: "uniqueTeamType") @@index([organizationId]) } -model Design_Review { - designReviewId String @id @default(uuid()) - dateScheduled DateTime @db.Date - // Meeting times are an integer between 0 and 11 from 10am - 10pm for the date scheduled at hour intervals - meetingTimes Int[] - initialDateScheduled DateTime @db.Date - dateCreated DateTime @default(now()) @db.Timestamp(3) - userCreated User @relation(fields: [userCreatedId], references: [userId], name: "designReviewCreator") - userCreatedId String - status Design_Review_Status - teamType Team_Type @relation(fields: [teamTypeId], references: [teamTypeId]) - teamTypeId String - requiredMembers User[] @relation(name: "requiredAttendee") - optionalMembers User[] @relation(name: "optionalAttendee") - confirmedMembers User[] @relation(name: "confirmedAttendee") - deniedMembers User[] @relation(name: "deniedAttendee") - location String? - isOnline Boolean - isInPerson Boolean - zoomLink String? - attendees User[] @relation(name: "userAttended") - dateDeleted DateTime? @db.Timestamp(3) - userDeleted User? @relation(fields: [userDeletedId], references: [userId], name: "designReviewDeleter") - userDeletedId String? - docTemplateLink String? - wbsElementId String - wbsElement WBS_Element @relation(fields: [wbsElementId], references: [wbsElementId]) - notificationSlackThreads Message_Info[] - calendarEventId String? - - @@index([teamTypeId]) -} - model Availability { availabilityId String @id @default(uuid()) scheduleSettingsId String @@ -1036,18 +1183,6 @@ model Schedule_Settings { availabilities Availability[] } -model Meeting { - meetingId String @id @default(uuid()) - title String - - dateSet DateTime - recurringInterval Int // the number of days between each meeting (0 = no recurring) - // meetingTimes are integers between 0 and 11 representing time from 10am - 10pm on the date the meeting is set for at hour intervals see meetingTime field in Design_Review - meetingTimes Int[] - team Team? @relation(fields: [teamId], references: [teamId]) - teamId String -} - model Wbs_Proposed_Changes { wbsProposedChangesId String @id @default(uuid()) name String @@ -1218,6 +1353,10 @@ model Organization { sponsorTiers Sponsor_Tier[] indexCodes Index_Code[] financeDelegates User[] @relation(name: "financeDelegates") + shops Shop[] + machineries Machinery[] + calendars Calendar[] + eventTypes Event_Type[] } model FrequentlyAskedQuestion { diff --git a/src/backend/src/prisma/seed.ts b/src/backend/src/prisma/seed.ts index ee467865be..8b641d8659 100644 --- a/src/backend/src/prisma/seed.ts +++ b/src/backend/src/prisma/seed.ts @@ -22,23 +22,13 @@ import { dbSeedAllTeams } from './seed-data/teams.seed.js'; import { seedReimbursementRequests } from './seed-data/reimbursement-requests.seed.js'; import ChangeRequestsService from '../services/change-requests.services.js'; import TeamsService from '../services/teams.services.js'; -import { - DesignReviewStatus, - MaterialStatus, - RoleEnum, - SpecialPermission, - StandardChangeRequest, - User, - WbsElementStatus, - WorkPackageStage -} from 'shared'; +import { DayOfWeek, MaterialStatus, RoleEnum, StandardChangeRequest, WbsElementStatus, WorkPackageStage } from 'shared'; import TasksService from '../services/tasks.services.js'; import { seedProject } from './seed-data/projects.seed.js'; import { seedWorkPackage } from './seed-data/work-packages.seed.js'; import ReimbursementRequestService from '../services/reimbursement-requests.services.js'; import ProjectsService from '../services/projects.services.js'; import { Decimal } from 'decimal.js'; -import DesignReviewsService from '../services/design-reviews.services.js'; import BillOfMaterialsService from '../services/boms.services.js'; import UsersService from '../services/users.services.js'; import { transformDate } from '../utils/datetime.utils.js'; @@ -50,6 +40,7 @@ import AnnouncementService from '../services/announcement.services.js'; import OnboardingServices from '../services/onboarding.services.js'; import { dbSeedAllParts, dbSeedAllPartTags } from './seed-data/parts.seed.js'; import FinanceServices from '../services/finance.services.js'; +import CalendarService from '../services/calendar.services.js'; const prisma = new PrismaClient(); @@ -2352,6 +2343,7 @@ const performSeed: () => Promise = async () => { const nextDay = new Date(); nextDay.setDate(nextDay.getDate() + 1); + /* const designReview1 = await DesignReviewsService.createDesignReview( batman, nextDay.toDateString(), @@ -2384,6 +2376,7 @@ const performSeed: () => Promise = async () => { [1, 2, 3, 4, 5, 6, 7], ner ); + */ const newWorkPackageChangeRequest = await ChangeRequestsService.createStandardChangeRequest( batman, @@ -3061,6 +3054,426 @@ const performSeed: () => Promise = async () => { daysAgo(60), thomasEmrax.userId ); + + // Create shops for machinery + const advancedShop = await prisma.shop.create({ + data: { + name: 'Advanced CNC Manufacturing Center', + description: 'CNC machining and precision manufacturing facility', + userCreatedId: thomasEmrax.userId, + organizationId + } + }); + + const electronicsLab = await prisma.shop.create({ + data: { + name: 'Electronics Development Lab', + description: 'Electronics testing and development workspace', + userCreatedId: thomasEmrax.userId, + organizationId + } + }); + + const testingFacility = await prisma.shop.create({ + data: { + name: 'Testing & Validation Facility', + description: 'Component and system testing laboratory', + userCreatedId: thomasEmrax.userId, + organizationId + } + }); + + // Create machineries and assign to shops + const ironMachineCreated = await CalendarService.createMachinery(thomasEmrax, 'Iron Man CNC Mill', ner); + const ironMachine = await CalendarService.addMachineryToShop( + thomasEmrax, + ironMachineCreated.machineryId, + advancedShop.shopId, + 1, + ner + ); + const hammerCreated = await CalendarService.createMachinery(thomasEmrax, 'Thor Hammer Lathe', ner); + const hammer = await CalendarService.addMachineryToShop( + thomasEmrax, + hammerCreated.machineryId, + advancedShop.shopId, + 2, + ner + ); + const printerCreated = await CalendarService.createMachinery(thomasEmrax, 'Spider-Man 3D Printer', ner); + const printer = await CalendarService.addMachineryToShop( + thomasEmrax, + printerCreated.machineryId, + electronicsLab.shopId, + 1, + ner + ); + const captainAmericaCreated = await CalendarService.createMachinery(thomasEmrax, 'Captain America Oscilloscope', ner); + await CalendarService.addMachineryToShop(thomasEmrax, captainAmericaCreated.machineryId, electronicsLab.shopId, 3, ner); + const hulkCreated = await CalendarService.createMachinery(thomasEmrax, 'Hulk Dynamometer', ner); + await CalendarService.addMachineryToShop(thomasEmrax, hulkCreated.machineryId, testingFacility.shopId, 1, ner); + const blackWidowCreated = await CalendarService.createMachinery(thomasEmrax, 'Black Widow Thermal Camera', ner); + await CalendarService.addMachineryToShop(thomasEmrax, blackWidowCreated.machineryId, testingFacility.shopId, 2, ner); + + // various calendars for testing + const calendar = await CalendarService.createCalendar( + thomasEmrax, + 'Engineering Team Calendar', + 'Tracks all engineering team events, meetings, and deadlines.', + '#3498db', + ner + ); + + const calendarFinishline = await CalendarService.createCalendar( + thomasEmrax, + 'Finishline Projects Calendar', + 'Tracks all ongoing projects currently being developed for Finishline', + '#911111ff', + ner + ); + + const calendarMeta = await CalendarService.createCalendar( + thomasEmrax, + 'Calendar Improvements Calendar', + 'Tracks all current improvements and schedulings for the improvement of the Finishline Calendar', + '#bf40e6ff', + ner + ); + + // meeting event type + const meetingEventType = await CalendarService.createEventType( + thomasEmrax, + 'Meeting', + [calendar.calendarId], + ner, + false, + false, + true, + false, + true, + true, + false, + false, + false, + false, + false, + true, + false, + false, + false + ); + + // design review event type + const designReviewEventType = await CalendarService.createEventType( + thomasEmrax, + 'Design Review', + [calendar.calendarId], + ner, + true, + true, + true, + false, + true, + true, + true, + false, + true, + true, + true, + true, + false, + true, + true + ); + + // manufacturing event type + const manufacturingEventType = await CalendarService.createEventType( + thomasEmrax, + 'Manufacturing', + [], + ner, + true, + true, + true, + false, + false, + false, + true, + true, + true, + true, + true, + false, + false, + false, + false + ); + + // bay time event type + const bayTimeEventType = await CalendarService.createEventType( + thomasEmrax, + 'Bay Time', + [], + ner, + true, + true, + false, + false, + false, + false, + false, + true, + false, + false, + false, + false, + false, + false, + false + ); + + await CalendarService.createEvent( + thomasEmrax, + 'Weekly Team Sync', + meetingEventType.eventTypeId, + ner, + [], + [], + [justiceLeague.teamId], + [], + [], + [], + [ + { + startTime: new Date(), + endTime: new Date(new Date().getTime() + 60 * 60 * 1000), + allDay: false + } + ], + undefined, + mechanical.teamTypeId, + undefined, + 'Conference Room A', + 'https://zoom.us/j/123456789', + 'Test meeting' + ); + + await CalendarService.createEvent( + thomasEmrax, + 'Weekly Team Sync Late', + meetingEventType.eventTypeId, + ner, + [], + [], + [justiceLeague.teamId], + [], + [], + [], + [ + { + startTime: new Date(new Date().getTime() + 105 * 60 * 60 * 1000), + endTime: new Date(new Date().getTime() + 106 * 60 * 60 * 1000), + allDay: false + }, + { + startTime: new Date(new Date().getTime() + 24 * 60 * 60 * 1000), + endTime: new Date(new Date().getTime() + 25 * 60 * 60 * 1000), + allDay: false + }, + { + startTime: new Date(new Date().getTime() + 50 * 60 * 60 * 1000), + endTime: new Date(new Date().getTime() + 51 * 60 * 60 * 1000), + allDay: false + }, + { + startTime: new Date(new Date().getTime() + 85 * 60 * 60 * 1000), + endTime: new Date(new Date().getTime() + 87 * 60 * 60 * 1000), + allDay: false + } + ], + undefined, + mechanical.teamTypeId, + undefined, + 'Conference Room A', + 'https://zoom.us/j/123456789', + 'December Cheer' + ); + + await CalendarService.createEvent( + thomasEmrax, + 'Weekly Team Sync 2', + meetingEventType.eventTypeId, + ner, + [], + [], + [justiceLeague.teamId], + [], + [], + [], + [ + { + startTime: new Date('2025-10-21T10:00:00.000Z'), + endTime: new Date('2025-10-21T11:00:00.000Z'), + allDay: false + } + ], + undefined, + mechanical.teamTypeId, + undefined, + 'Conference Room A', + 'https://zoom.us/j/123456789', + 'This is the second Weekly Sync in our database. Please come and join to get vital information! Thank you for reading.' + ); + + await CalendarService.createEvent( + thomasEmrax, + 'Weekly Team Sync 3', + meetingEventType.eventTypeId, + ner, + [], + [], + [justiceLeague.teamId], + [], + [], + [], + [ + { + startTime: new Date('2025-10-21T10:00:00.000Z'), + endTime: new Date('2025-10-21T11:00:00.000Z'), + allDay: false + } + ], + undefined, + mechanical.teamTypeId, + undefined, + 'Conference Room A', + 'https://zoom.us/j/123456789', + 'This is the third test meeting! Glad to say hi.' + ); + + await CalendarService.createEvent( + thomasEmrax, + 'Weekly Team Sync 4', + meetingEventType.eventTypeId, + ner, + [], + [], + [justiceLeague.teamId], + [], + [], + [], + [ + { + startTime: new Date('2025-10-21T10:00:00.000Z'), + endTime: new Date('2025-10-21T11:00:00.000Z'), + allDay: false + } + ], + undefined, + mechanical.teamTypeId, + undefined, + 'Conference Room A', + 'https://zoom.us/j/123456789', + 'This is the fourth meeting! Please come anyway, we have a lot to say.' + ); + + await CalendarService.createEvent( + thomasEmrax, + 'Weekly Team Sync 5', + meetingEventType.eventTypeId, + ner, + [], + [], + [justiceLeague.teamId], + [], + [], + [], + [ + { + startTime: new Date('2025-10-21T10:00:00.000Z'), + endTime: new Date('2025-10-21T11:00:00.000Z'), + allDay: false + } + ], + undefined, + mechanical.teamTypeId, + undefined, + 'Conference Room A', + 'https://zoom.us/j/123456789', + "This one is optional, up to you if you want to show up, we won't judge" + ); + + await CalendarService.createEvent( + thomasEmrax, + 'Impact Attenuator Design Review', + designReviewEventType.eventTypeId, + ner, + [joeShmoe.userId, joeBlow.userId], + [batman.userId], + [], + [], + [], + [workPackage1.id], + [], + weeksFromNow(1), + software.teamTypeId, + 'https://docs.google.com/document/d/2_example', + 'Conference Room B', + 'https://zoom.us/j/987654321', + undefined + ); + + await CalendarService.createEvent( + batman, + 'Wiring Harness Manufacturing', + manufacturingEventType.eventTypeId, + ner, + [regina.userId, janis.userId], + [cady.userId], + [], + [electronicsLab.shopId], + [printer.machineryId], + [workPackage3.id], + [ + { + startTime: new Date('2025-10-23T09:00:00.000Z'), + endTime: new Date('2025-10-23T12:00:00.000Z'), + allDay: false + } + ], + undefined, + electrical.teamTypeId, + 'https://docs.google.com/document/d/3_example', + undefined, + undefined, + undefined + ); + + await CalendarService.createEvent( + aang, + 'Composite Layup Bay Time', + bayTimeEventType.eventTypeId, + ner, + [katara.userId, sokka.userId], + [], + [], + [], + [ironMachine.machineryId], + [], + [ + { + startTime: new Date('2025-10-24T13:00:00.000Z'), + endTime: new Date('2025-10-24T17:00:00.000Z'), + allDay: false + } + ], + undefined, + mechanical.teamTypeId, + undefined, + undefined, + undefined, + undefined + ); }; performSeed() diff --git a/src/backend/src/routes/calendar.routes.ts b/src/backend/src/routes/calendar.routes.ts new file mode 100644 index 0000000000..c1c32e6687 --- /dev/null +++ b/src/backend/src/routes/calendar.routes.ts @@ -0,0 +1,292 @@ +import express from 'express'; +import { body, param } from 'express-validator'; +import { + intMinZero, + isDate, + nonEmptyString, + validateInputs, + isEventStatus, + isConflictStatus, + requireFile +} from '../utils/validation.utils.js'; +import CalendarController from '../controllers/calendar.controllers.js'; +import multer, { memoryStorage } from 'multer'; +import { MAX_FILE_SIZE } from 'shared'; + +const calendarRouter = express.Router(); + +const upload = multer({ + limits: { fileSize: MAX_FILE_SIZE }, + storage: memoryStorage(), + fileFilter: (_req, file, cb) => { + const allowedMimeTypes = [ + 'application/pdf', + 'application/msword', + 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', + 'image/jpeg', + 'image/png' + ]; + + if (allowedMimeTypes.includes(file.mimetype)) { + cb(null, true); + } else { + cb(new Error('Invalid file type')); + } + } +}); + +calendarRouter.post( + '/create', + nonEmptyString(body('name')), + nonEmptyString(body('description')), + nonEmptyString(body('colorHexCode')), + validateInputs, + CalendarController.createCalendar +); + +calendarRouter.post( + '/event-type/create', + nonEmptyString(body('name')), + body('calendarIds').isArray(), + body('calendarIds.*').isString(), + body('requiredMembers').isBoolean(), + body('optionalMembers').isBoolean(), + body('teams').isBoolean(), + body('teamType').isBoolean(), + body('location').isBoolean(), + body('zoomLink').isBoolean(), + body('shop').isBoolean(), + body('machinery').isBoolean(), + body('workPackage').isBoolean(), + body('questionDocument').isBoolean(), + body('documents').isBoolean(), + body('description').isBoolean(), + body('onlyHeadsOrAbove').isBoolean(), + body('requiresConfirmation').isBoolean(), + body('sendSlackNotifications').isBoolean(), + validateInputs, + CalendarController.createEventType +); + +calendarRouter.post( + '/event-type/:eventTypeId/edit', + nonEmptyString(body('name')), + body('calendarIds').isArray(), + body('calendarIds.*').isString(), + body('requiredMembers').isBoolean(), + body('optionalMembers').isBoolean(), + body('teams').isBoolean(), + body('teamType').isBoolean(), + body('location').isBoolean(), + body('zoomLink').isBoolean(), + body('shop').isBoolean(), + body('machinery').isBoolean(), + body('workPackage').isBoolean(), + body('questionDocument').isBoolean(), + body('documents').isBoolean(), + body('description').isBoolean(), + body('onlyHeadsOrAbove').isBoolean(), + body('requiresConfirmation').isBoolean(), + body('sendSlackNotifications').isBoolean(), + validateInputs, + CalendarController.editEventType +); + +calendarRouter.post( + '/event/create', + nonEmptyString(body('title')), + body('eventTypeId').isString(), + body('requiredMemberIds').isArray(), + nonEmptyString(body('requiredMemberIds.*')), + body('optionalMemberIds').isArray(), + nonEmptyString(body('optionalMemberIds.*')), + body('teamIds').isArray(), + body('teamIds.*').isString(), + body('teamTypeId').optional().isString(), + body('location').optional().isString(), + body('zoomLink').optional().isURL(), + body('shopIds').isArray(), + body('shopIds.*').isString(), + body('machineryIds').isArray(), + body('machineryIds.*').isString(), + body('workPackageIds').isArray(), + body('workPackageIds.*').isString(), + body('questionDocumentLink').optional().isString(), + body('description').optional().isString(), + isDate(body('initialDateScheduled')), + body('scheduleSlots').isArray(), + isDate(body('scheduleSlots.*.startTime')), + isDate(body('scheduleSlots.*.endTime')), + body('scheduleSlots.*.allDay').isBoolean(), + validateInputs, + CalendarController.createEvent +); + +calendarRouter.post( + '/event/:eventId/edit', + nonEmptyString(body('title')), + body('requiredMemberIds').isArray(), + nonEmptyString(body('requiredMemberIds.*')), + body('optionalMemberIds').isArray(), + nonEmptyString(body('optionalMemberIds.*')), + body('teamIds').isArray(), + body('teamIds.*').isString(), + body('teamTypeId').optional().isString(), + isEventStatus(body('status')), + body('location').optional().isString(), + body('zoomLink').optional().isURL(), + body('shopIds').isArray(), + body('shopIds.*').isString(), + body('machineryIds').isArray(), + body('machineryIds.*').isString(), + body('workPackageIds').isArray(), + body('workPackageIds.*').isString(), + body('documents').isArray(), + nonEmptyString(body('documents.*.name')), + nonEmptyString(body('documents.*.googleFileId')), + body('questionDocumentLink').optional().isString(), + body('description').optional().isString(), + validateInputs, + CalendarController.editEvent +); + +calendarRouter.post( + '/event/:eventId/schedule-slot/:scheduleSlotId/edit', + isDate(body('startTime')), + isDate(body('endTime')), + body('allDay').isBoolean(), + validateInputs, + CalendarController.editScheduleSlot +); + +calendarRouter.get( + '/event/:eventId/schedule-slot/:scheduleSlotId/preview-recurring-edits', + CalendarController.previewScheduleSlotRecurringEdits +); + +calendarRouter.post('/event/:eventId/schedule-slot/:scheduleSlotId/delete', CalendarController.deleteScheduleSlot); + +calendarRouter.get('/document/:fileId', CalendarController.downloadDocument); + +calendarRouter.post( + '/event/:eventId/upload-document', + upload.single('pdf'), + requireFile(body('file')), + validateInputs, + CalendarController.uploadDocument +); + +calendarRouter.post('/event/:eventId/approve', CalendarController.approveEvent); + +calendarRouter.post('/event/:eventId/deny', CalendarController.denyEvent); + +calendarRouter.post( + '/event/:eventId/confirm-schedule', + body('availability').isArray(), + body('availability.*.availability').isArray(), + intMinZero(body('availability.*.availability.*')), + isDate(body('availability.*.dateSet')), + validateInputs, + CalendarController.markUserConfirmed +); + +calendarRouter.post( + '/event/:eventId/set-status', + isEventStatus(body('status')), + validateInputs, + CalendarController.setStatus +); + +calendarRouter.post('/event/:eventId/delete', CalendarController.deleteEvent); + +calendarRouter.get('/event/:eventId/conflict', CalendarController.getConflictingEvent); + +calendarRouter.get('/event/:eventId', CalendarController.getSingleEvent); + +calendarRouter.get('/event-members/:eventId', CalendarController.getSingleEventWithMembers); + +calendarRouter.get('/events', CalendarController.getAllEvents); + +calendarRouter.get('/event-types', CalendarController.getAllEventTypes); + +calendarRouter.post('/machinery/create', nonEmptyString(body('name')), validateInputs, CalendarController.createMachinery); + +calendarRouter.post( + '/machinery/:machineryId/edit', + nonEmptyString(body('name')), + validateInputs, + CalendarController.editMachinery +); + +calendarRouter.post( + '/machinery/:machineryId/add-to-shop', + nonEmptyString(body('shopId')), + body('quantity').isInt({ min: 0 }), + body('originalShopId').optional().isString(), + validateInputs, + CalendarController.addMachineryToShop +); + +calendarRouter.post('/machinery/:machineryId/delete', CalendarController.deleteMachinery); + +calendarRouter.post( + '/:calendarId/edit', + nonEmptyString(body('name')), + nonEmptyString(body('description')), + nonEmptyString(body('colorHexCode')), + validateInputs, + CalendarController.editCalendar +); + +calendarRouter.post( + '/shop/create', + nonEmptyString(body('name')), + nonEmptyString(body('description')), + validateInputs, + CalendarController.createShop +); + +calendarRouter.post( + '/shop/:shopId/edit', + nonEmptyString(body('name')), + nonEmptyString(body('description')), + validateInputs, + CalendarController.editShop +); + +calendarRouter.post('/event-type/:eventTypeId/delete', CalendarController.deleteEventType); + +calendarRouter.post('/:calendarId/delete', CalendarController.deleteCalendar); + +calendarRouter.post('/shop/:shopId/delete', nonEmptyString(param('shopId')), validateInputs, CalendarController.deleteShop); + +calendarRouter.get('/shops', CalendarController.getAllShops); + +calendarRouter.get('/machinery', CalendarController.getAllMachinery); + +// no restrictions filtering, in case multiple filters need to be sent +calendarRouter.post( + '/events/filter', + body('memberIds').optional().isArray(), + body('memberIds.*').optional().isString(), + body('teamIds').optional().isArray(), + body('teamIds.*').optional().isString(), + body('calendarIds').isArray().optional(), + body('calendarIds.*').isString().optional(), + body('eventTypeIds').optional().isArray(), + body('eventTypeIds.*').optional().isString(), + body('eventIds').isArray().optional(), + body('eventIds.*').isString().optional(), + body('approvalIds').isArray().optional(), + body('approvalIds.*').isString().optional(), + body('statuses').isArray().optional(), + isConflictStatus(body('statuses.*')), + isDate(body('startPeriod')), + isDate(body('endPeriod')), + validateInputs, + CalendarController.getFilteredEvents +); + +calendarRouter.get('/calendars', CalendarController.getAllCalendars); + +export default calendarRouter; diff --git a/src/backend/src/routes/design-reviews.routes.ts b/src/backend/src/routes/design-reviews.routes.ts index 40d8f860fe..595429fbd9 100644 --- a/src/backend/src/routes/design-reviews.routes.ts +++ b/src/backend/src/routes/design-reviews.routes.ts @@ -1,3 +1,4 @@ +/* import express from 'express'; import { body } from 'express-validator'; import { intMinZero, nonEmptyString, isDate, isDesignReviewStatus, validateInputs } from '../utils/validation.utils.js'; @@ -39,7 +40,7 @@ designReviewsRouter.post( nonEmptyString(body('zoomLink')).isURL().optional(), nonEmptyString(body('location')).optional(), nonEmptyString(body('docTemplateLink')).optional(), - isDesignReviewStatus(body('status')), + isEventStatus(body('status')), body('attendees').isArray(), nonEmptyString(body('attendees.*')), body('meetingTimes').isArray(), @@ -60,9 +61,10 @@ designReviewsRouter.post( designReviewsRouter.post( '/:designReviewId/set-status', - isDesignReviewStatus(body('status')), + isEventStatus(body('status')), validateInputs, DesignReviewsController.setStatus ); export default designReviewsRouter; +*/ diff --git a/src/backend/src/routes/teams.routes.ts b/src/backend/src/routes/teams.routes.ts index 24c134608f..ef67393067 100644 --- a/src/backend/src/routes/teams.routes.ts +++ b/src/backend/src/routes/teams.routes.ts @@ -9,6 +9,7 @@ const teamsRouter = express.Router(); const upload = multer({ limits: { fileSize: MAX_FILE_SIZE }, storage: memoryStorage() }); teamsRouter.get('/', TeamsController.getAllTeams); +teamsRouter.get('/previews/', TeamsController.getAllTeamPreviews); teamsRouter.get('/archive', TeamsController.getAllArchivedTeams); teamsRouter.get('/users-teams', TeamsController.getUsersTeams); teamsRouter.get('/my-team-as-head', TeamsController.getMyTeamAsHead); diff --git a/src/backend/src/routes/work-packages.routes.ts b/src/backend/src/routes/work-packages.routes.ts index dbb2f2abdf..bd072b9a64 100644 --- a/src/backend/src/routes/work-packages.routes.ts +++ b/src/backend/src/routes/work-packages.routes.ts @@ -1,5 +1,5 @@ import express from 'express'; -import { body, param } from 'express-validator'; +import { body, param, query } from 'express-validator'; import WorkPackagesController from '../controllers/work-packages.controllers.js'; import { blockedByValidators, @@ -10,10 +10,16 @@ import { nonEmptyString, validateInputs } from '../utils/validation.utils.js'; -import { WorkPackageSelection } from 'shared'; +import { WorkPackageSelection, WbsElementStatus } from 'shared'; const workPackagesRouter = express.Router(); workPackagesRouter.get('/', WorkPackagesController.getAllWorkPackages); +workPackagesRouter.get( + '/all-preview', + query('status').optional().isIn(Object.values(WbsElementStatus)), + validateInputs, + WorkPackagesController.getAllWorkPackagesPreview +); workPackagesRouter.post( '/get-many', body('wbsNums').isArray(), diff --git a/src/backend/src/services/calendar.services.ts b/src/backend/src/services/calendar.services.ts new file mode 100644 index 0000000000..f716a5b001 --- /dev/null +++ b/src/backend/src/services/calendar.services.ts @@ -0,0 +1,2642 @@ +import { + User, + isAdmin, + isHead, + isGuest, + AvailabilityCreateArgs, + Event, + EventType, + ScheduleSlotCreateArgs, + EventDocumentCreateArgs, + EventStatus, + Shop, + Calendar, + FilterArgs, + Machinery, + ScheduleSlot +} from 'shared'; +import { getCalendarQueryArgs } from '../prisma-query-args/calendar.query-args.js'; +import { getEventTypeQueryArgs } from '../prisma-query-args/event-type.query-args.js'; +import { getEventQueryArgs, getEventWithMembersQueryArgs } from '../prisma-query-args/event.query-args.js'; +import { getMachineryQueryArgs } from '../prisma-query-args/machinery.query-args.js'; +import { getShopQueryArgs } from '../prisma-query-args/shop.query-args.js'; +import { getUserScheduleSettingsQueryArgs } from '../prisma-query-args/user.query-args.js'; +import prisma from '../prisma/prisma.js'; +import { + eventTypeTransformer, + machineryTransformer, + eventTransformer, + eventWithMembersTransformer, + shopTransformer, + calendarTransformer +} from '../transformers/calendar.transformer.js'; +import { UserWithSettings } from '../utils/auth.utils.js'; +import { + validateEventTypeConfiguration, + checkEventConflicts, + removeDeletedEventDocuments, + isUserOnEvent, + buildScheduledTimesOverlap, + findMatchingTimeOfDaySlots +} from '../utils/calendar.utils.js'; +import { + AccessDeniedAdminOnlyException, + NotFoundException, + InvalidOrganizationException, + HttpException, + DeletedException, + AccessDeniedException, + AccessDeniedGuestException +} from '../utils/errors.utils.js'; +import { createCalendarEvent, uploadFile, downloadFile, deleteCalendarEvents } from '../utils/google-integration.utils.js'; +import { sendEventPopUp } from '../utils/pop-up.utils.js'; +import { + sendSlackEventConfirmNotification, + sendEventConfirmationToThread, + sendSlackEventNotifications, + sendEventScheduledSlackNotif, + sendEventUserConfirmationToThread +} from '../utils/slack.utils.js'; +import { + userHasPermission, + getPrismaQueryUserIds, + getUsers, + updateUserAvailability, + areUsersinList +} from '../utils/users.utils.js'; +import { Conflict_Status, Event_Status, Organization } from '@prisma/client'; + +export default class CalendarService { + /** + * Creates a new event type. + * + * @param submitter The user submitting the request, who must be an admin. + * @param name The name of the event type. + * @param calendarIds An array of the calendars this event type is associated with. + * @param organization The organization for which the event type is being created. + * @param requiredMembers Determines if this event type has required members. + * @param optionalMembers Determines if this event type has optional members. + * @param teams Determines if this event type has teams. + * @param teamType Determines if this event type has a team type. + * @param location Determines if this event type has a location. + * @param zoomLink Determines if this event type has a zoom link. + * @param shop Determines if a shop is associated with this event type. + * @param machinery Determines if machinery is associated with this event type. + * @param workPackage Determines if a work package is associated with this event type. + * @param questionDocument Determines if a question document is associated with this event type. + * @param documents Determines if documents are associates with this event type. + * @param description Determines if a description is associated with this event type. + * @param onlyHeadsOrAbove Determines if events under this event type can only be created by heads or above. + * @param requiredConfirmation Determines if events under this event type need to be confirmed. + * @param sendSlackNotifications Determines if users will be notified via slack + * + * @returns The created event type. + * + * @throws AccessDeniedAdminOnlyException If the submitter is not an admin. + * @throws NotFoundException If the given calendarIds are not found. + * @throws InvalidOrganizationException If the given calendarIds are not part of the same organization. + */ + static async createEventType( + submitter: User, + name: string, + calendarIds: string[], + organization: Organization, + requiredMembers: boolean, + optionalMembers: boolean, + teams: boolean, + teamType: boolean, + location: boolean, + zoomLink: boolean, + shop: boolean, + machinery: boolean, + workPackage: boolean, + questionDocument: boolean, + documents: boolean, + description: boolean, + onlyHeadsOrAbove: boolean, + requiresConfirmation: boolean, + sendSlackNotifications: boolean + ): Promise { + if (!(await userHasPermission(submitter.userId, organization.organizationId, isAdmin))) { + throw new AccessDeniedAdminOnlyException('create event type'); + } + + // Check if calendars with ids exist and belong to the same organization + const existingCalendars = await prisma.calendar.findMany({ + where: { + calendarId: { in: calendarIds } + } + }); + + // Ensure all provided calendars exist + if (existingCalendars.length !== calendarIds.length) { + const foundIds = existingCalendars.map((c) => c.calendarId); + const missingIds = calendarIds.filter((id) => !foundIds.includes(id)); + throw new NotFoundException('Calendar', missingIds.join(', ')); + } + + // Ensure all calendars belong to the given organization + for (const calendar of existingCalendars) { + if (calendar.organizationId !== organization.organizationId) { + throw new InvalidOrganizationException('Calendar'); + } + } + + const duplicate = await prisma.event_Type.findFirst({ + where: { + organizationId: organization.organizationId, + dateDeleted: null, + name: { equals: name, mode: 'insensitive' } + } + }); + if (duplicate) { + throw new HttpException(409, "Can't have two event types with the same name"); + } + + const newEventType = await prisma.event_Type.create({ + data: { + name, + calendars: { + connect: calendarIds.map((calendarId) => ({ calendarId })) + }, + userCreatedId: submitter.userId, + requiredMembers, + optionalMembers, + teams, + teamType, + location, + zoomLink, + shop, + machinery, + workPackage, + questionDocument, + documents, + description, + onlyHeadsOrAboveForEventCreation: onlyHeadsOrAbove, + requiresConfirmation, + sendSlackNotifications, + organizationId: organization.organizationId + }, + ...getEventTypeQueryArgs(organization.organizationId) + }); + + return eventTypeTransformer(newEventType); + } + + /** + * Creates a new machinery and associates it with shops. + * + * @param submitter The user submitting the request, who must be an admin. + * @param name The name of the machinery. + * @param shopMachineryData Array of shop machinery data containing shopId, quantity, and optional description. + * @param organization The organization for which the machinery is being created. + * @param description The description of the machinery (optional). + * + * @returns The created machinery object with associated shop machinery. + * + * @throws AccessDeniedAdminOnlyException If the submitter is not an admin. + * @throws NotFoundException If the shop with the given shopId does not exist. + */ + static async createMachinery(submitter: User, name: string, organization: Organization) { + if (!(await userHasPermission(submitter.userId, organization.organizationId, isAdmin))) { + throw new AccessDeniedAdminOnlyException('create machinery'); + } + + const duplicate = await prisma.machinery.findFirst({ + where: { + organizationId: organization.organizationId, + dateDeleted: null, + name: { equals: name, mode: 'insensitive' } + } + }); + if (duplicate) { + throw new HttpException(409, "Can't have two machinery with the same name"); + } + + const created = await prisma.machinery.create({ + data: { + name, + userCreatedId: submitter.userId, + organizationId: organization.organizationId + }, + ...getMachineryQueryArgs(organization.organizationId) + }); + + return machineryTransformer(created); + } + + /** + * Creates a new event. + * + * @param submitter The user submitting the request, who must be an admin. + * @param title The title of the event. + * @param eventTypeId The event type id the event is associated with. + * @param organization The organization for which the event type is being created. + * @param requiredMemberIds An array of required member ids that are invited to the event. + * @param optionalMemberIds An array of optional member ids that are invited to the event. + * @param teamIds An array of team ids that are invited to the event. + * @param teamTypeId The team type id invited to the event. + * @param shopIds An array of shops associated with the event. + * @param machineryIds An array of machinery associated with the event. + * @param workPackageIds An array of work packages associated with the event. + * @param scheduleSlots An array of schedule slots associated with the event. + * @param questionDocumentLink The link to the question document. + * @param location Location of the event. + * @param zoomLink Zoom Link if the event is online. + * @param description Describes the event. + * + * @returns The created event. + * + * @throws NotFoundException If the given event type, member IDs, shop IDs, machinery IDs, work package IDs, document IDs, or approvedByUserId are not found. + * @throws InvalidOrganizationException If the given event type, members, shops, machinery, work packages, or approvedByUserId are not part of the same organization. + */ + static async createEvent( + submitter: User, + title: string, + eventTypeId: string, + organization: Organization, + requiredMemberIds: string[], + optionalMemberIds: string[], + teamIds: string[], + shopIds: string[], + machineryIds: string[], + workPackageIds: string[], + scheduleSlots: ScheduleSlotCreateArgs[], + initialDateScheduled: Date | undefined, + teamTypeId?: string, + questionDocumentLink?: string, + location?: string, + zoomLink?: string, + description?: string + ): Promise { + // Validate eventTypeId + const foundEventType = await prisma.event_Type.findUnique({ + where: { eventTypeId } + }); + if (!foundEventType) throw new NotFoundException('Event Type', eventTypeId); + if (foundEventType.dateDeleted) throw new DeletedException('Event Type', eventTypeId); + if (foundEventType.organizationId !== organization.organizationId) { + throw new InvalidOrganizationException('Event Type'); + } + + if (foundEventType.onlyHeadsOrAboveForEventCreation) { + const hasPermission = await userHasPermission(submitter.userId, organization.organizationId, isHead); + + if (!hasPermission) { + throw new AccessDeniedException('Only admins and heads can create events under this event type'); + } + } + + // Validate event follows event type configuration + validateEventTypeConfiguration(foundEventType, { + requiredMemberIds, + optionalMemberIds, + teamIds, + shopIds, + machineryIds, + workPackageIds, + documents: [], + scheduleSlots, + initialDateScheduled, + teamTypeId, + location, + zoomLink, + questionDocumentLink, + description + }); + + // Validate required memberIds + if (requiredMemberIds.length > 0) { + const foundMembers = await prisma.user.findMany({ + where: { + userId: { in: requiredMemberIds }, + organizations: { some: { organizationId: organization.organizationId } } + } + }); + if (foundMembers.length !== requiredMemberIds.length) { + const missingIds = requiredMemberIds.filter((id) => !foundMembers.some((user) => user.userId === id)); + throw new NotFoundException('User', missingIds.join(', ')); + } + } + + // Validate optionals memberIds + if (optionalMemberIds.length > 0) { + const foundMembers = await prisma.user.findMany({ + where: { + userId: { in: optionalMemberIds }, + organizations: { some: { organizationId: organization.organizationId } } + } + }); + if (foundMembers.length !== optionalMemberIds.length) { + const missingIds = optionalMemberIds.filter((id) => !foundMembers.some((user) => user.userId === id)); + throw new NotFoundException('User', missingIds.join(', ')); + } + } + + // Validate teamIds + if (teamIds.length > 0) { + const foundteams = await prisma.team.findMany({ + where: { + teamId: { in: teamIds }, + organization: { organizationId: organization.organizationId } + } + }); + if (foundteams.length !== teamIds.length) { + const missingIds = teamIds.filter((id) => !foundteams.some((team) => team.teamId === id)); + throw new NotFoundException('Team', missingIds.join(', ')); + } + } + + // Validate shopIds + if (shopIds.length > 0) { + const foundShops = await prisma.shop.findMany({ + where: { + shopId: { in: shopIds }, + organizationId: organization.organizationId, + dateDeleted: null + } + }); + if (foundShops.length !== shopIds.length) { + const missingIds = shopIds.filter((id) => !foundShops.some((shop) => shop.shopId === id)); + throw new NotFoundException('Shop', missingIds.join(', ')); + } + } + + // Validate machineryIds + if (machineryIds.length > 0) { + const foundMachinery = await prisma.machinery.findMany({ + where: { + machineryId: { in: machineryIds }, + organizationId: organization.organizationId, + dateDeleted: null + }, + include: { + shops: { + include: { + shop: true + } + } + } + }); + if (foundMachinery.length !== machineryIds.length) { + const missingIds = machineryIds.filter((id) => !foundMachinery.some((m) => m.machineryId === id)); + throw new NotFoundException('Machinery', missingIds.join(', ')); + } + + // Automatically add machinery's shops to shopIds if not already included + const machineryShopIds = foundMachinery.flatMap((m) => m.shops.map((sm) => sm.shopId)); + const uniqueShopIds = new Set([...shopIds, ...machineryShopIds]); + shopIds = Array.from(uniqueShopIds); + } + + // Validate workPackageIds + if (workPackageIds.length > 0) { + const foundWorkPackages = await prisma.work_Package.findMany({ + where: { + workPackageId: { in: workPackageIds } + } + }); + if (foundWorkPackages.length !== workPackageIds.length) { + const missingIds = workPackageIds.filter((id) => !foundWorkPackages.some((wp) => wp.workPackageId === id)); + throw new NotFoundException('Work Package', missingIds.join(', ')); + } + } + + if (teamTypeId) { + // Validate team type + const foundTeamType = await prisma.team_Type.findUnique({ + where: { + teamTypeId + } + }); + if (!foundTeamType) { + throw new NotFoundException('Team Type', teamTypeId); + } + } + + // Check for conflicts using expanded slots + const { hasConflict, conflictingEvent } = await checkEventConflicts(scheduleSlots, organization, location, undefined); + + const newEvent = await prisma.event.create({ + data: { + userCreatedId: submitter.userId, + dateCreated: new Date(), + title, + eventTypeId, + requiredMembers: { + connect: requiredMemberIds.map((userId) => ({ userId })) + }, + optionalMembers: { + connect: optionalMemberIds.map((userId) => ({ userId })) + }, + teams: { + connect: teamIds.map((teamId) => ({ teamId })) + }, + teamTypeId, + shops: { + connect: shopIds.map((shopId) => ({ shopId })) + }, + machinery: { + connect: machineryIds.map((machineryId) => ({ machineryId })) + }, + workPackages: { + connect: workPackageIds.map((workPackageId) => ({ workPackageId })) + }, + scheduledTimes: { + create: scheduleSlots.map((s) => ({ + startTime: s.startTime ?? null, + endTime: s.endTime ?? null, + allDay: s.allDay + })) + }, + initialDateScheduled, + status: foundEventType.requiresConfirmation ? Event_Status.UNCONFIRMED : Event_Status.CONFIRMED, + approved: hasConflict ? Conflict_Status.PENDING : Conflict_Status.NO_CONFLICT, + approvalRequiredFromUserId: hasConflict ? conflictingEvent?.userCreated.userId : null, + location, + zoomLink, + questionDocumentLink, + description + }, + ...getEventQueryArgs(organization.organizationId) + }); + + let calendarEventIds: string[] = []; + if (process.env.NODE_ENV === 'production') { + try { + const allMemberIds = [...requiredMemberIds, ...optionalMemberIds]; + const isInPerson = !!location; + + calendarEventIds = await createCalendarEvent( + process.env.GOOGLE_CALENDAR_ID!, + allMemberIds, + newEvent.scheduledTimes, + isInPerson, + zoomLink ?? null, + location ?? null, + title + ); + + // Update event with calendar IDs + await prisma.event.update({ + where: { eventId: newEvent.eventId }, + data: { calendarEventIds } + }); + } catch (error) { + console.error('Failed to create Google Calendar events:', error); + } + } + + if (foundEventType.sendSlackNotifications) { + const members = await prisma.user.findMany({ + where: { userId: { in: optionalMemberIds.concat(requiredMemberIds) } } + }); + + if (!members) { + throw new NotFoundException('User', 'Cannot find members who are invited to the design review'); + } + + // get the user settings for all the members invited, who are leaderingship + const memberUserSettings = await prisma.user_Settings.findMany({ + where: { userId: { in: members.map((member) => member.userId) } } + }); + + if (!memberUserSettings) { + throw new NotFoundException('User Settings', 'Cannot find settings of members'); + } + + const workPackageNames = newEvent.workPackages.map((wp) => wp.wbsElement.name).join(', '); + + const projects = newEvent.workPackages.map((wp) => wp.project); + + // Send a slack message to all members invited to the event + for (const memberUserSetting of memberUserSettings) { + if (memberUserSetting.slackId) { + try { + // For each project associated with this event + for (const project of projects) { + await sendSlackEventConfirmNotification( + memberUserSetting.slackId, + newEvent.eventId, + newEvent.title, + project.wbsElement.name + ); + } + } catch (err: unknown) { + if (err instanceof Error) { + throw new HttpException(500, `Failed to send slack notification: ${err.message}`); + } + } + } + } + + if (newEvent.status === Event_Status.CONFIRMED) { + await sendEventConfirmationToThread(newEvent.notificationSlackThreads, newEvent.userCreated); + } + + // Send popup notification + await sendEventPopUp(newEvent, members, submitter, workPackageNames, organization.organizationId); + + const createdEvent = eventTransformer(newEvent); + + for (const project of projects) { + const projectTeams = project.teams; + if (projectTeams.length > 0) { + await sendSlackEventNotifications( + projectTeams, + createdEvent, + submitter, + workPackageNames, + project.wbsElement.name + ); + } + } + return createdEvent; + } + + return eventTransformer(newEvent); + } + + /** + * Edits an event (excluding schedule slots - use editScheduleSlot for that). + * + * @param submitter The user submitting the request, who must be an admin. + * @param eventId The id of the event to edit. + * @param title The title of the event. + * @param organization The organization for which the event type is being created. + * @param requiredMemberIds An array of required member ids that are invited to the event. + * @param optionalMemberIds An array of optional member ids that are invited to the event. + * @param status see Event_Status enum + * @param teamIds An array of teams invited to the event. + * @param teamType Team type Id invited to the event. + * @param shopIds An array of shops associated with the event. + * @param machineryIds An array of machinery associated with the event. + * @param workPackageIds An array of work packages associated with the event. + * @param documents An array of documents associated with the event. + * @param questionDocumentLink The link to the question document. + * @param location Location of the event. + * @param zoomLink Zoom Link if the event is online. + * @param description Describes the event. + * + * @returns The edited event. + * + * @throws NotFoundException If the given event type, member IDs, shop IDs, machinery IDs, work package IDs, document IDs, or approvedByUserId are not found. + * @throws InvalidOrganizationException If the given event type, members, shops, machinery, work packages, or approvedByUserId are not part of the same organization. + */ + static async editEvent( + submitter: User, + eventId: string, + title: string, + organization: Organization, + requiredMemberIds: string[], + optionalMemberIds: string[], + status: Event_Status, + teamIds: string[], + shopIds: string[], + machineryIds: string[], + workPackageIds: string[], + documents: EventDocumentCreateArgs[], + teamTypeId?: string, + questionDocumentLink?: string, + location?: string, + zoomLink?: string, + description?: string + ): Promise { + // validate eventId + const foundEvent = await prisma.event.findUnique({ + where: { eventId }, + ...getEventQueryArgs(organization.organizationId) + }); + + if (!foundEvent) throw new NotFoundException('Event', eventId); + if (foundEvent.dateDeleted) throw new DeletedException('Event', eventId); + + const { eventTypeId } = foundEvent; + const foundEventType = await prisma.event_Type.findUnique({ + where: { eventTypeId } + }); + + if (!foundEventType) throw new NotFoundException('Event Type', eventTypeId); + if (foundEventType.dateDeleted) throw new DeletedException('Event Type', eventTypeId); + + // Note: Schedule validation is removed since editEvent doesn't modify schedules + // Use editScheduleSlot to modify individual schedule slots + + // question document is required if the status is scheduled or done + if (foundEventType.requiresConfirmation) { + if (foundEvent.status === Event_Status.SCHEDULED || foundEvent.status === Event_Status.DONE) { + if (questionDocumentLink == null) { + throw new HttpException(400, 'doc template link is required for scheduled and done design reviews'); + } + } + } + + if (requiredMemberIds.length > 0 && requiredMemberIds.some((rMemberId) => optionalMemberIds.includes(rMemberId))) { + throw new HttpException(400, 'required members cannot be in optional members'); + } + + if (foundEventType.onlyHeadsOrAboveForEventCreation) { + const hasPermission = await userHasPermission(submitter.userId, organization.organizationId, isHead); + + if (!hasPermission) { + throw new AccessDeniedException('Only admins and heads can edit this event!'); + } + } else { + const hasPermission = + (await userHasPermission(submitter.userId, organization.organizationId, isHead)) || + submitter.userId === foundEvent.userCreatedId; + + if (!hasPermission) { + throw new AccessDeniedException('Only admins and heads and creators can edit this event!'); + } + } + + // Validate required memberIds + if (requiredMemberIds.length > 0) { + const foundMembers = await prisma.user.findMany({ + where: { + userId: { in: requiredMemberIds }, + organizations: { some: { organizationId: organization.organizationId } } + } + }); + if (foundMembers.length !== requiredMemberIds.length) { + const missingIds = requiredMemberIds.filter((id) => !foundMembers.some((user) => user.userId === id)); + throw new NotFoundException('User', missingIds.join(', ')); + } + } + + // Validate optional memberIds + if (optionalMemberIds.length > 0) { + const foundMembers = await prisma.user.findMany({ + where: { + userId: { in: optionalMemberIds }, + organizations: { some: { organizationId: organization.organizationId } } + } + }); + if (foundMembers.length !== optionalMemberIds.length) { + const missingIds = optionalMemberIds.filter((id) => !foundMembers.some((user) => user.userId === id)); + throw new NotFoundException('User', missingIds.join(', ')); + } + } + + // Validate teamIds + if (teamIds.length > 0) { + const foundteams = await prisma.team.findMany({ + where: { + teamId: { in: teamIds }, + organization: { organizationId: organization.organizationId } + } + }); + if (foundteams.length !== teamIds.length) { + const missingIds = teamIds.filter((id) => !foundteams.some((team) => team.teamId === id)); + throw new NotFoundException('Team', missingIds.join(', ')); + } + } + + // Validate shopIds + if (shopIds.length > 0) { + const foundShops = await prisma.shop.findMany({ + where: { + shopId: { in: shopIds }, + organizationId: organization.organizationId, + dateDeleted: null + } + }); + if (foundShops.length !== shopIds.length) { + const missingIds = shopIds.filter((id) => !foundShops.some((shop) => shop.shopId === id)); + throw new NotFoundException('Shop', missingIds.join(', ')); + } + } + + // Validate machineryIds + if (machineryIds.length > 0) { + const foundMachinery = await prisma.machinery.findMany({ + where: { + machineryId: { in: machineryIds }, + organizationId: organization.organizationId, + dateDeleted: null + }, + include: { + shops: { + include: { + shop: true + } + } + } + }); + if (foundMachinery.length !== machineryIds.length) { + const missingIds = machineryIds.filter((id) => !foundMachinery.some((m) => m.machineryId === id)); + throw new NotFoundException('Machinery', missingIds.join(', ')); + } + + // Automatically add machinery's shops to shopIds if not already included + const machineryShopIds = foundMachinery.flatMap((m) => m.shops.map((sm) => sm.shopId)); + const uniqueShopIds = new Set([...shopIds, ...machineryShopIds]); + shopIds = Array.from(uniqueShopIds); + } + + // Validate workPackageIds + if (workPackageIds.length > 0) { + const foundWorkPackages = await prisma.work_Package.findMany({ + where: { + workPackageId: { in: workPackageIds } + } + }); + if (foundWorkPackages.length !== workPackageIds.length) { + const missingIds = workPackageIds.filter((id) => !foundWorkPackages.some((wp) => wp.workPackageId === id)); + throw new NotFoundException('Work Package', missingIds.join(', ')); + } + } + + if (teamTypeId) { + // Validate team type + const foundTeamType = await prisma.team_Type.findMany({ + where: { + teamTypeId + } + }); + if (!foundTeamType) { + throw new NotFoundException('Team Type', teamTypeId); + } + } + + // throw if a user isn't found, then build prisma queries for connecting userIds + const updatedRequiredMembers = getPrismaQueryUserIds(await getUsers(requiredMemberIds)); + const updatedOptionalMembers = getPrismaQueryUserIds(await getUsers(optionalMemberIds)); + + let newStatus = status; + + // If all required members are confirmed, set the status to SCHEDULED + const allRequiredMembersConfirmed = updatedRequiredMembers.every((member) => + foundEvent.confirmedMembers.map((user: { userId: string }) => user.userId).includes(member.userId) + ); + + if (status === Event_Status.CONFIRMED && allRequiredMembersConfirmed) { + newStatus = Event_Status.SCHEDULED; + } + + // Update the event with new data (excluding schedule slots) + const updatedEvent = await prisma.event.update({ + where: { eventId }, + data: { + title, + eventTypeId, + requiredMembers: { + set: updatedRequiredMembers + }, + optionalMembers: { + set: updatedOptionalMembers + }, + teams: { + set: teamIds.map((teamId) => ({ teamId })) + }, + ...(teamTypeId !== undefined && { teamTypeId }), + status: newStatus, + shops: { + set: shopIds.map((shopId) => ({ shopId })) + }, + machinery: { + set: machineryIds.map((machineryId) => ({ machineryId })) + }, + workPackages: { + set: workPackageIds.map((workPackageId) => ({ workPackageId })) + }, + location, + zoomLink, + questionDocumentLink, + description + }, + ...getEventQueryArgs(organization.organizationId) + }); + + //set any deleted documents with a dateDeleted + await removeDeletedEventDocuments(documents, foundEvent.documents || [], submitter); + + const edittedEvent = eventTransformer(updatedEvent); + + if (status === Event_Status.SCHEDULED && foundEventType.sendSlackNotifications) { + await sendEventScheduledSlackNotif(updatedEvent.notificationSlackThreads, edittedEvent); + } + + if (status === Event_Status.CONFIRMED && foundEventType.sendSlackNotifications) { + await sendEventConfirmationToThread(updatedEvent.notificationSlackThreads, updatedEvent.userCreated); + } + + return edittedEvent; + } + + /** + * Previews which schedule slots would be affected when editing a slot with "edit all in series". + * Returns only the OTHER slots that would be edited (excludes the current slot being edited). + * + * @param submitter The user submitting the request. + * @param scheduleSlotId The id of the specific schedule slot being edited. + * @param organization The organization context. + * + * @returns Array of schedule slots (excluding the current one) that have the same time-of-day pattern. + * + * @throws NotFoundException If the schedule slot or event is not found. + * @throws AccessDeniedException If the user doesn't have permission. + */ + static async previewScheduleSlotRecurringEdits( + submitter: User, + scheduleSlotId: string, + organization: Organization + ): Promise { + // Validate schedule slot exists and get its eventId + const scheduleSlot = await prisma.schedule_Slot.findUnique({ + where: { scheduleSlotId }, + select: { + scheduleSlotId: true, + eventId: true, + startTime: true, + endTime: true, + allDay: true + } + }); + + if (!scheduleSlot) throw new NotFoundException('Schedule Slot', scheduleSlotId); + + // Now fetch the event separately to check permissions + const event = await prisma.event.findUnique({ + where: { eventId: scheduleSlot.eventId }, + select: { + eventId: true, + userCreatedId: true, + location: true, + dateDeleted: true + } + }); + + if (!event) throw new NotFoundException('Event', scheduleSlot.eventId); + if (event.dateDeleted) throw new DeletedException('Event', scheduleSlot.eventId); + + // Check permissions + const hasPermission = + (await userHasPermission(submitter.userId, organization.organizationId, isHead)) || + submitter.userId === event.userCreatedId; + + if (!hasPermission) { + throw new AccessDeniedException( + 'Only admins, heads, or the event creator can see how editing this schedule slot will affect the event.' + ); + } + + // Fetch all schedule slots for this event + const allSlots = await prisma.schedule_Slot.findMany({ + where: { eventId: scheduleSlot.eventId }, + select: { + scheduleSlotId: true, + startTime: true, + endTime: true, + allDay: true + } + }); + + // Find all slots with matching time-of-day pattern + const matchingSlots = findMatchingTimeOfDaySlots(allSlots, scheduleSlotId); + + // Get today's date at midnight for comparison (day portion only) + const today = new Date(); + today.setHours(0, 0, 0, 0); + + // Exclude the current slot and any slots in the past (based on day only) + const otherAffectedSlots = matchingSlots.filter((slot) => { + if (slot.scheduleSlotId === scheduleSlotId) return false; + + const slotDate = new Date(slot.startTime); + slotDate.setHours(0, 0, 0, 0); + return slotDate >= today; + }); + + return otherAffectedSlots; + } + + /** + * Edits a specific schedule slot of an event. + * Used when a user wants to change the time/date of a single occurrence in a recurring event. + * + * @param submitter The user submitting the request. + * @param scheduleSlotId The id of the specific schedule slot to edit. + * @param startTime The new start time. + * @param endTime The new end time. + * @param allDay Whether this is an all-day event. + * @param editAllInSeries If true, edits all schedule slots with matching time-of-day. + * @param organization The organization context. + * + * @returns The updated event. + * + * @throws NotFoundException If the event or schedule slot is not found. + * @throws AccessDeniedException If the user doesn't have permission to edit. + */ + static async editScheduleSlot( + submitter: User, + scheduleSlotId: string, + startTime: Date, + endTime: Date, + allDay: boolean, + editAllInSeries: boolean, + organization: Organization + ): Promise { + // Validate schedule slot exists and get its eventId + const scheduleSlot = await prisma.schedule_Slot.findUnique({ + where: { scheduleSlotId }, + select: { + scheduleSlotId: true, + eventId: true + } + }); + + if (!scheduleSlot) throw new NotFoundException('Schedule Slot', scheduleSlotId); + + // Now fetch the event separately to check permissions + const event = await prisma.event.findUnique({ + where: { eventId: scheduleSlot.eventId }, + select: { + eventId: true, + userCreatedId: true, + location: true, + dateDeleted: true + } + }); + + if (!event) throw new NotFoundException('Event', scheduleSlot.eventId); + if (event.dateDeleted) throw new DeletedException('Event', scheduleSlot.eventId); + + // Check permissions + const hasPermission = + (await userHasPermission(submitter.userId, organization.organizationId, isHead)) || + submitter.userId === event.userCreatedId; + + if (!hasPermission) { + throw new AccessDeniedException('Only admins, heads, or the event creator can edit the times of an event!'); + } + + // Check for conflicts with the new time + const newSlotData: ScheduleSlotCreateArgs = { + startTime, + endTime, + allDay + }; + + const { hasConflict, conflictingEvent } = await checkEventConflicts( + [newSlotData], + organization, + event.location ?? undefined, + event.eventId + ); + + // Determine which slots to update + let slotsToUpdate: string[] = [scheduleSlotId]; + + if (editAllInSeries) { + // Fetch all schedule slots for this event + const allSlots = await prisma.schedule_Slot.findMany({ + where: { eventId: scheduleSlot.eventId }, + select: { + scheduleSlotId: true, + startTime: true, + endTime: true, + allDay: true + } + }); + + // Use helper function to find all slots with matching time-of-day + const matchingSlots = findMatchingTimeOfDaySlots(allSlots, scheduleSlotId); + slotsToUpdate = matchingSlots.map((slot) => slot.scheduleSlotId); + } + + // Calculate the time offset from the original slot to the new time + const originalSlot = await prisma.schedule_Slot.findUnique({ + where: { scheduleSlotId }, + select: { startTime: true, endTime: true } + }); + + if (!originalSlot || !originalSlot.startTime) { + throw new NotFoundException('Schedule Slot', scheduleSlotId); + } + + // Update all matching schedule slots + for (const slotId of slotsToUpdate) { + const currentSlot = await prisma.schedule_Slot.findUnique({ + where: { scheduleSlotId: slotId }, + select: { startTime: true, endTime: true } + }); + + if (!currentSlot || !currentSlot.startTime || !currentSlot.endTime) continue; + + // For the clicked slot, use the exact new times provided + if (slotId === scheduleSlotId) { + await prisma.schedule_Slot.update({ + where: { scheduleSlotId: slotId }, + data: { + startTime, + endTime, + allDay + } + }); + } else { + // For other slots in the series, preserve their date but update time-of-day + const newSlotStart = new Date(currentSlot.startTime); + newSlotStart.setHours( + startTime.getHours(), + startTime.getMinutes(), + startTime.getSeconds(), + startTime.getMilliseconds() + ); + + const newSlotEnd = new Date(currentSlot.endTime); + newSlotEnd.setHours(endTime.getHours(), endTime.getMinutes(), endTime.getSeconds(), endTime.getMilliseconds()); + + await prisma.schedule_Slot.update({ + where: { scheduleSlotId: slotId }, + data: { + startTime: newSlotStart, + endTime: newSlotEnd, + allDay + } + }); + } + } + + // Update conflict status if needed + if (hasConflict) { + await prisma.event.update({ + where: { eventId: event.eventId }, + data: { + approved: Conflict_Status.PENDING, + approvalRequiredFromUserId: conflictingEvent?.userCreated.userId + } + }); + } + + // Fetch and return the updated event + const updatedEvent = await prisma.event.findUnique({ + where: { eventId: event.eventId }, + ...getEventQueryArgs(organization.organizationId) + }); + + if (!updatedEvent) throw new NotFoundException('Event', event.eventId); + + return eventTransformer(updatedEvent); + } + + /** + * Deletes a specific schedule slot from an event. + * If this is the last schedule slot, the entire event is deleted instead. + * + * @param submitter The user submitting the request. + * @param scheduleSlotId The id of the specific schedule slot to delete. + * @param organization The organization context. + * + * @returns The updated event (or the deleted event if it was the last slot). + * + * @throws NotFoundException If the event or schedule slot is not found. + * @throws DeletedException If the event has already been deleted. + * @throws AccessDeniedException If the user doesn't have permission to delete. + */ + static async deleteScheduleSlot(submitter: User, scheduleSlotId: string, organization: Organization): Promise { + // Validate schedule slot exists and get its eventId + const scheduleSlot = await prisma.schedule_Slot.findUnique({ + where: { scheduleSlotId }, + select: { + scheduleSlotId: true, + eventId: true + } + }); + + if (!scheduleSlot) throw new NotFoundException('Schedule Slot', scheduleSlotId); + + // Fetch the event to check permissions and slot count + const event = await prisma.event.findUnique({ + where: { eventId: scheduleSlot.eventId }, + include: { + scheduledTimes: true + } + }); + + if (!event) throw new NotFoundException('Event', scheduleSlot.eventId); + if (event.dateDeleted) throw new DeletedException('Event', scheduleSlot.eventId); + + // Check permissions - same as deleteEvent + const hasPermission = + (await userHasPermission(submitter.userId, organization.organizationId, isAdmin)) || + submitter.userId === event.userCreatedId; + + if (!hasPermission) { + throw new AccessDeniedException('Only admins or the event creator can delete schedule slots!'); + } + + // If this is the last schedule slot, delete the entire event instead + if (event.scheduledTimes.length <= 1) { + return this.deleteEvent(submitter, event.eventId, organization); + } + + // Delete the schedule slot (hard delete for schedule slots) + await prisma.schedule_Slot.delete({ + where: { scheduleSlotId } + }); + + // Fetch and return the updated event + const updatedEvent = await prisma.event.findUnique({ + where: { eventId: event.eventId }, + ...getEventQueryArgs(organization.organizationId) + }); + + if (!updatedEvent) throw new NotFoundException('Event', event.eventId); + + return eventTransformer(updatedEvent); + } + + /** + * Service function to upload a picture to the event documents folder in the NER google drive + * @param eventId id for the event we're tying the document to + * @param file The file data for the image + * @param submitter user who is uploading the document + * @param organizationId the organization the user is currently in + * @returns the google drive id for the file + */ + static async uploadDocument(eventId: string, file: Express.Multer.File, submitter: User, organization: Organization) { + if (await userHasPermission(submitter.userId, organization.organizationId, isGuest)) + throw new AccessDeniedGuestException('Guests cannot upload documents'); + + const event = await prisma.event.findUnique({ + where: { eventId } + }); + + const numDocuments = await prisma.document.count({ + where: { + documentEvent: { + eventType: { + organizationId: organization.organizationId + } + } + } + }); + + if (!event) throw new NotFoundException('Event', eventId); + if (event.dateDeleted) { + throw new DeletedException('Event', eventId); + } + if (event.userCreatedId !== submitter.userId && !isHead) { + throw new AccessDeniedException('You do not have access to upload a document for this event'); + } + + file.filename = 'document' + numDocuments; + const documentData = await uploadFile(file); + + if (!documentData?.name) { + throw new HttpException(500, 'Document Name not found'); + } + + const document = await prisma.document.create({ + data: { + googleFileId: documentData.id, + name: documentData.name, + documentEventId: eventId, + createdByUserId: submitter.userId + } + }); + + return document; + } + + /** + * Downloads the document file with the given google file id + * + * @param fileId the google file id of the document + * @returns a buffer of the image data and the image type + */ + static async downloadDocument(fileId: string) { + const fileData = await downloadFile(fileId); + + if (!fileData) throw new NotFoundException('Image File', fileId); + return fileData; + } + + /** + * Approve event in the database + * @param submitter The user submitting the request who must be a head or above. + * @param eventId The id of the given event. + * @param organization The organization for which the event is being approved. + * + * @returns The approved event. + * + * @throws NotFoundException If the given eventId is not found. + * @throws InvalidOrganizationException If the given eventId is not part of the same organization. + * @throws DeletedException If the event has already been deleted. + * @throws AccessDeniedAdminOnlyException If the submitter is not an admin or head. + */ + static async approveEvent(submitter: User, eventId: string, organization: Organization): Promise { + const event = await prisma.event.findUnique({ + where: { eventId } + }); + + if (!event) throw new NotFoundException('Event', eventId); + if (event.dateDeleted) throw new DeletedException('Event', eventId); + + const hasPermission = + (await userHasPermission(submitter.userId, organization.organizationId, isHead)) || + event.approvalRequiredFromUserId === submitter.userId; + + if (!hasPermission) { + throw new AccessDeniedException('Only admins or heads or the owner of the conflicting event can approve this event!'); + } + + const approvedEvent = await prisma.event.update({ + where: { eventId }, + data: { + approved: Conflict_Status.APPROVED, + approvalRequiredFromUserId: submitter.userId + }, + ...getEventQueryArgs(organization.organizationId) + }); + + return eventTransformer(approvedEvent); + } + + /** + * Denies an event in the database + * @param submitter The user submitting the request who must be a head or above. + * @param eventId The id of the given event. + * @param organization The organization for which the event is being denied. + * + * @returns The denied event. + * + * @throws NotFoundException If the given eventId is not found. + * @throws InvalidOrganizationException If the given eventId is not part of the same organization. + * @throws DeletedException If the event has already been deleted. + * @throws AccessDeniedAdminOnlyException If the submitter is not an admin or head. + */ + static async denyEvent(submitter: User, eventId: string, organization: Organization): Promise { + const event = await prisma.event.findUnique({ + where: { eventId } + }); + + if (!event) throw new NotFoundException('Event', eventId); + if (event.dateDeleted) throw new DeletedException('Event', eventId); + + const hasPermission = + (await userHasPermission(submitter.userId, organization.organizationId, isHead)) || + event.approvalRequiredFromUserId === submitter.userId; + + if (!hasPermission) { + throw new AccessDeniedException('Only admins or heads or the owner of the conflicting event can deny this event!'); + } + + const deniedEvent = await prisma.event.update({ + where: { eventId }, + data: { + approved: Conflict_Status.DENIED, + approvalRequiredFromUserId: submitter.userId + }, + ...getEventQueryArgs(organization.organizationId) + }); + + return eventTransformer(deniedEvent); + } + + /** + * Edits an event by confirming a given user's availability and also updating their schedule settings with the given availability + * @param submitter the member that is being confirmed + * @param eventId the id of the event + * @param availabilities the given member's availabilities + * @param organizationId the organization that the user is currently in + * @returns the modified event with its updated confirmed members + */ + static async markUserConfirmed( + eventId: string, + availabilities: AvailabilityCreateArgs[], + submitter: UserWithSettings, + organization: Organization + ): Promise { + const event = await prisma.event.findUnique({ + where: { eventId }, + ...getEventWithMembersQueryArgs(organization.organizationId) + }); + + if (!event) throw new NotFoundException('Event', eventId); + if (event.dateDeleted) throw new DeletedException('Event', eventId); + + if (!isUserOnEvent(submitter, eventWithMembersTransformer(event))) + throw new HttpException(400, 'Current user is not in the list of this events members'); + + let userSettings = await prisma.schedule_Settings.findUnique({ + where: { userId: submitter.userId }, + ...getUserScheduleSettingsQueryArgs() + }); + + if (!userSettings) { + userSettings = await prisma.schedule_Settings.create({ + data: { + userId: submitter.userId, + availabilities: { + createMany: { + data: availabilities.map((availability) => ({ + availability: availability.availability, + dateSet: availability.dateSet + })) + } + }, + personalGmail: '', + personalZoomLink: '' + }, + ...getUserScheduleSettingsQueryArgs() + }); + } + + await updateUserAvailability(availabilities, userSettings, submitter); + + // set submitter as confirmed if they're not already + if (!event.confirmedMembers.map((user: { userId: string }) => user.userId).includes(submitter.userId)) { + const updatedEvent = await prisma.event.update({ + where: { eventId }, + ...getEventQueryArgs(organization.organizationId), + data: { + confirmedMembers: { + connect: { + userId: submitter.userId + } + } + } + }); + + const { eventTypeId } = updatedEvent; + const foundEventType = await prisma.event_Type.findUnique({ + where: { eventTypeId } + }); + + if (!foundEventType) throw new NotFoundException('Event Type', eventTypeId); + if (foundEventType.dateDeleted) throw new DeletedException('Event Type', eventTypeId); + + if (foundEventType.sendSlackNotifications) { + await sendEventUserConfirmationToThread(updatedEvent.notificationSlackThreads, submitter); + } + + // If all required attendees have confirmed their schedule and this member was a required attendee, mark design review as confirmed + if ( + areUsersinList(event.requiredMembers, updatedEvent.confirmedMembers) && + areUsersinList([submitter], event.requiredMembers) + ) { + await prisma.event.update({ + where: { eventId }, + ...getEventQueryArgs(organization.organizationId), + data: { + status: Event_Status.CONFIRMED + } + }); + if (foundEventType.sendSlackNotifications) { + await sendEventConfirmationToThread(updatedEvent.notificationSlackThreads, updatedEvent.userCreated); + } + } + + return eventTransformer(updatedEvent); + } + return eventTransformer(event); + } + + /** + * Sets the status of an event, only admin or the user who created the event can set the status. + * @param user the user trying to set the status + * @param eventId the id of the event + * @param status the status to set the event to + * @param organizationId the organization that the user is currently in + * @returns the modified event + */ + static async setStatus(user: User, eventId: string, status: EventStatus, organization: Organization): Promise { + // validate the design review exists and is not deleted + const originalEvent = await prisma.event.findUnique({ + where: { eventId } + }); + if (!originalEvent) throw new NotFoundException('Event', eventId); + if (originalEvent.dateDeleted) throw new DeletedException('Event', eventId); + + // verify user is allowed to set the status of the event + if ( + !(await userHasPermission(user.userId, organization.organizationId, isAdmin)) && + user.userId !== originalEvent.userCreatedId + ) { + throw new AccessDeniedAdminOnlyException('set the status of an event'); + } + + // actually try to update the event + const updatedEvent = await prisma.event.update({ + where: { eventId }, + ...getEventQueryArgs(organization.organizationId), + data: { + status + } + }); + + return eventTransformer(updatedEvent); + } + + /** + * Delete event in the database + * @param submitter The user submitting the request, who must be an admin. + * @param eventId The id of the given event. + * @param organization The organization for which the event is being deleted. + * + * @returns The deleted event. + * + * @throws NotFoundException If the given eventId is not found. + * @throws InvalidOrganizationException If the given eventId is not part of the same organization. + * @throws DeletedException If the event has already been deleted. + * @throws AccessDeniedAdminOnlyException If the submitter is not an admin. + */ + static async deleteEvent(submitter: User, eventId: string, organization: Organization): Promise { + const event = await prisma.event.findUnique({ + where: { eventId } + }); + + if (!event) throw new NotFoundException('Event', eventId); + if (event.dateDeleted) throw new DeletedException('Event', eventId); + + const hasPermission = + (await userHasPermission(submitter.userId, organization.organizationId, isAdmin)) || + submitter.userId === event.userCreatedId; + + if (!hasPermission) { + throw new AccessDeniedException('Only admins can delete events!'); + } + + // Delete from Google Calendar + if (event.calendarEventIds && event.calendarEventIds.length > 0 && process.env.NODE_ENV === 'production') { + try { + await deleteCalendarEvents(process.env.GOOGLE_CALENDAR_ID!, event.calendarEventIds); + } catch (error) { + console.error('Failed to delete Google Calendar events:', error); + } + } + + const deletedEvent = await prisma.event.update({ + where: { eventId }, + data: { dateDeleted: new Date(), userDeletedId: submitter.userId }, + ...getEventQueryArgs(organization.organizationId) + }); + + return eventTransformer(deletedEvent); + } + + /** + * Edits an existing machinery and its associated shop machinery. + * + * @param submitter The user submitting the request, who must be a head or above. + * @param machineryId The ID of the machinery to edit. + * @param name The new name of the machinery. + * @param shopId The shop ID to associate with the machinery. + * @param quantity The quantity of machinery in the shop. + * @param organization The organization for which the machinery is being edited. + * @param description The description of the machinery (optional). + * + * @returns The updated machinery object with associated shop machinery. + * + * @throws AccessDeniedException If the submitter is not a head or above. + * @throws NotFoundException If the machinery or shop with the given IDs do not exist. + * @throws InvalidOrganizationException If the machinery or shop does not belong to the same organization. + */ + static async editMachinery(submitter: User, machineryId: string, name: string, organization: Organization) { + if (!(await userHasPermission(submitter.userId, organization.organizationId, isHead))) { + throw new AccessDeniedException('Only heads and above can edit machinery'); + } + + const existing = await prisma.machinery.findFirst({ where: { machineryId } }); + if (!existing) throw new NotFoundException('Machinery', machineryId); + if (existing.organizationId !== organization.organizationId) { + throw new InvalidOrganizationException('Machinery'); + } + if (existing.dateDeleted) { + throw new NotFoundException('Machinery', machineryId); + } + + // manual uniqueness excluding current record + const duplicate = await prisma.machinery.findFirst({ + where: { + organizationId: organization.organizationId, + dateDeleted: null, + name: { equals: name, mode: 'insensitive' }, + NOT: { machineryId } + } + }); + if (duplicate) { + throw new HttpException(409, "Can't have two machinery with the same name"); + } + + const updated = await prisma.machinery.update({ + where: { machineryId }, + data: { name }, + ...getMachineryQueryArgs(organization.organizationId) + }); + + return machineryTransformer(updated); + } + + /** + * Adds or updates a machinery to a shop. Handles consolidation when machinery name matches existing machinery. + * If quantity is 0, deletes the shop-machinery relationship (only applicable to editing the machinery modal). + * + * @param submitter The user submitting the request, who must be a head or above. + * @param machineryId The ID of the machinery to add/update. + * @param shopId The ID of the shop to add/update the machinery in. + * @param quantity The quantity of machinery. If 0, the relationship is deleted. + * @param organization The organization context. + * @param originalShopId Optional: The original shop ID when moving/updating an existing relationship. If not provided, this is treated as an "add" operation and quantities are incremented. + * @returns The machinery object with updated shop relationships. + * @throws AccessDeniedException If the submitter is not a head or above. + * @throws NotFoundException If the machinery or shop with the given IDs do not exist. + * @throws InvalidOrganizationException If the machinery or shop does not belong to the same organization. + */ + static async addMachineryToShop( + submitter: User, + machineryId: string, + shopId: string, + quantity: number, + organization: Organization, + originalShopId?: string + ) { + if (!(await userHasPermission(submitter.userId, organization.organizationId, isHead))) { + throw new AccessDeniedException('Only heads and above can manage shop-machinery relationships'); + } + + const existingMachinery = await prisma.machinery.findUnique({ + where: { machineryId } + }); + + if (!existingMachinery) { + throw new NotFoundException('Machinery', machineryId); + } + + if (existingMachinery.organizationId !== organization.organizationId) { + throw new InvalidOrganizationException('Machinery'); + } + + const existingShop = await prisma.shop.findUnique({ + where: { shopId } + }); + + if (!existingShop) { + throw new NotFoundException('Shop', shopId); + } + + if (existingShop.organizationId !== organization.organizationId) { + throw new InvalidOrganizationException('Shop'); + } + + // Use a transaction to ensure all database operations complete atomically. + // This is critical for consolidation logic where we may delete one machinery and merge into another. + const updatedMachinery = await prisma.$transaction(async (tx) => { + // Find the specific shop-machinery relationship being edited (if updating existing) + // This identifies which shop's quantity/relationship we're modifying + let shopMachineryToUpdate; + if (originalShopId) { + shopMachineryToUpdate = await tx.shop_Machinery.findFirst({ + where: { + machineryId, + shopId: originalShopId + } + }); + } + + // Get the machinery name to check for consolidation + const machineryName = existingMachinery.name; + + // Check if another machinery with the same name already exists + const existingMachineryWithSameName = await tx.machinery.findFirst({ + where: { + name: machineryName, + organizationId: organization.organizationId, + machineryId: { not: existingMachinery.machineryId } + }, + include: { + shops: { + where: { shopId } + } + } + }); + + // Case 1: Same name + same shop as existing machinery (consolidation) + // Consolidate by deleting current relationship and adding quantity to existing one + if (existingMachineryWithSameName && existingMachineryWithSameName.shops.length > 0) { + const [existingShopMachinery] = existingMachineryWithSameName.shops; + + // If we're consolidating into a different shop-machinery relationship + if ( + shopMachineryToUpdate && + (shopMachineryToUpdate.shopMachineryId !== existingShopMachinery.shopMachineryId || + existingMachineryWithSameName.machineryId !== machineryId) + ) { + await tx.shop_Machinery.delete({ + where: { shopMachineryId: shopMachineryToUpdate.shopMachineryId } + }); + + // Handle quantity: if 0, delete; otherwise add to existing + if (quantity === 0) { + await tx.shop_Machinery.delete({ + where: { shopMachineryId: existingShopMachinery.shopMachineryId } + }); + } else { + const newQuantity = existingShopMachinery.quantity + quantity; + await tx.shop_Machinery.update({ + where: { shopMachineryId: existingShopMachinery.shopMachineryId }, + data: { quantity: newQuantity } + }); + } + + // Note: Machinery is kept even if it has no more shops + // Only shop-machinery relationships are deleted, not the machinery itself + } else if ( + shopMachineryToUpdate && + shopMachineryToUpdate.shopMachineryId === existingShopMachinery.shopMachineryId + ) { + // Same relationship - just update the quantity (edit operation) + if (quantity === 0) { + await tx.shop_Machinery.delete({ + where: { shopMachineryId: existingShopMachinery.shopMachineryId } + }); + } else { + await tx.shop_Machinery.update({ + where: { shopMachineryId: existingShopMachinery.shopMachineryId }, + data: { quantity } + }); + } + } else if (shopMachineryToUpdate) { + // Different relationship - delete old and add to existing + await tx.shop_Machinery.delete({ + where: { shopMachineryId: shopMachineryToUpdate.shopMachineryId } + }); + + if (quantity === 0) { + // If quantity is 0, just delete the existing relationship too + await tx.shop_Machinery.delete({ + where: { shopMachineryId: existingShopMachinery.shopMachineryId } + }); + } else { + // Add quantity to existing relationship + const newQuantity = existingShopMachinery.quantity + quantity; + await tx.shop_Machinery.update({ + where: { shopMachineryId: existingShopMachinery.shopMachineryId }, + data: { quantity: newQuantity } + }); + } + } else if (quantity === 0) { + // Add operation - if quantity is 0, delete the relationship + await tx.shop_Machinery.delete({ + where: { shopMachineryId: existingShopMachinery.shopMachineryId } + }); + } else { + // Add operation - increment quantity + const newQuantity = existingShopMachinery.quantity + quantity; + await tx.shop_Machinery.update({ + where: { shopMachineryId: existingShopMachinery.shopMachineryId }, + data: { quantity: newQuantity } + }); + } + + const resultMachinery = await tx.machinery.findUnique({ + where: { machineryId: existingMachineryWithSameName.machineryId }, + ...getMachineryQueryArgs(organization.organizationId) + }); + if (!resultMachinery) { + throw new NotFoundException('Machinery', existingMachineryWithSameName.machineryId); + } + return resultMachinery; + } + + // Case 2: Name matches existing machinery but different shop + // Move relationship to the existing machinery but with different shop + if (existingMachineryWithSameName && existingMachineryWithSameName.machineryId !== machineryId) { + if (shopMachineryToUpdate) { + await tx.shop_Machinery.delete({ + where: { shopMachineryId: shopMachineryToUpdate.shopMachineryId } + }); + } + + // Check if existing machinery already has this shop + const existingShopMachinery = await tx.shop_Machinery.findUnique({ + where: { + uniqueShopMachinery: { + shopId, + machineryId: existingMachineryWithSameName.machineryId + } + } + }); + + if (quantity === 0) { + // If quantity is 0 and relationship exists, delete it + if (existingShopMachinery) { + await tx.shop_Machinery.delete({ + where: { shopMachineryId: existingShopMachinery.shopMachineryId } + }); + } + } else if (existingShopMachinery) { + // Add quantity to existing relationship + const newQuantity = existingShopMachinery.quantity + quantity; + await tx.shop_Machinery.update({ + where: { shopMachineryId: existingShopMachinery.shopMachineryId }, + data: { quantity: newQuantity } + }); + } else { + // Create new relationship for existing machinery + await tx.shop_Machinery.create({ + data: { + shopId, + machineryId: existingMachineryWithSameName.machineryId, + quantity + } + }); + } + + // Note: Machinery is kept even if it has no more shops + // Only shop-machinery relationships are deleted, not the machinery itself + + const resultMachinery = await tx.machinery.findUnique({ + where: { machineryId: existingMachineryWithSameName.machineryId }, + ...getMachineryQueryArgs(organization.organizationId) + }); + if (!resultMachinery) { + throw new NotFoundException('Machinery', existingMachineryWithSameName.machineryId); + } + return resultMachinery; + } + + // Case 3: Normal update - no consolidation needed + if (shopMachineryToUpdate) { + if (shopMachineryToUpdate.shopId === shopId) { + // Same shop, update quantity or delete if 0 + if (quantity === 0) { + await tx.shop_Machinery.delete({ + where: { shopMachineryId: shopMachineryToUpdate.shopMachineryId } + }); + } else { + await tx.shop_Machinery.update({ + where: { shopMachineryId: shopMachineryToUpdate.shopMachineryId }, + data: { quantity } + }); + } + } else { + // Different shop - check if target shop already has this machinery + const existingRelationship = await tx.shop_Machinery.findUnique({ + where: { + uniqueShopMachinery: { + shopId, + machineryId + } + } + }); + + if (existingRelationship) { + // Target shop already has this machinery, update it and delete old relationship + if (quantity === 0) { + await tx.shop_Machinery.delete({ + where: { shopMachineryId: existingRelationship.shopMachineryId } + }); + } else { + await tx.shop_Machinery.update({ + where: { shopMachineryId: existingRelationship.shopMachineryId }, + data: { quantity } + }); + } + await tx.shop_Machinery.delete({ + where: { shopMachineryId: shopMachineryToUpdate.shopMachineryId } + }); + } else if (quantity === 0) { + // Move relationship to new shop, but quantity is 0 so delete + await tx.shop_Machinery.delete({ + where: { shopMachineryId: shopMachineryToUpdate.shopMachineryId } + }); + } else { + // Move relationship to new shop + await tx.shop_Machinery.update({ + where: { shopMachineryId: shopMachineryToUpdate.shopMachineryId }, + data: { + shopId, + quantity + } + }); + } + } + } else { + // No originalShopId - this is a create operation (adding machine to shop) + // Check if relationship already exists + const existingRelationship = await tx.shop_Machinery.findUnique({ + where: { + uniqueShopMachinery: { + shopId, + machineryId + } + } + }); + + if (existingRelationship) { + // Relationship exists - add quantities together when creating + if (quantity === 0) { + await tx.shop_Machinery.delete({ + where: { shopMachineryId: existingRelationship.shopMachineryId } + }); + } else { + const newQuantity = existingRelationship.quantity + quantity; + await tx.shop_Machinery.update({ + where: { shopMachineryId: existingRelationship.shopMachineryId }, + data: { quantity: newQuantity } + }); + } + } else if (quantity > 0) { + // Create new relationship only if quantity > 0 + await tx.shop_Machinery.create({ + data: { + shopId, + machineryId, + quantity + } + }); + } + } + + // Note: Machinery is kept even if quantity is 0 and it has no more shops + // Only shop-machinery relationships are deleted, not the machinery itself + + const updatedMachineryResult = await tx.machinery.findUnique({ + where: { machineryId }, + ...getMachineryQueryArgs(organization.organizationId) + }); + if (!updatedMachineryResult) throw new NotFoundException('Machinery', machineryId); + return updatedMachineryResult; + }); + + return machineryTransformer(updatedMachinery); + } + + /** + * Creates a new shop + * requires the submitter to be Admin + */ + static async createShop(submitter: User, name: string, description: string, organization: Organization): Promise { + if (!(await userHasPermission(submitter.userId, organization.organizationId, isAdmin))) { + throw new AccessDeniedAdminOnlyException('create shop'); + } + + const existing = await prisma.shop.findFirst({ + where: { + organizationId: organization.organizationId, + dateDeleted: null, + name: { equals: name, mode: 'insensitive' } + } + }); + + if (existing) { + throw new HttpException(409, "Can't have two shops with the same name"); + } + + const newShop = await prisma.shop.create({ + data: { + name, + description, + userCreatedId: submitter.userId, + organizationId: organization.organizationId + }, + ...getShopQueryArgs(organization.organizationId) + }); + + return shopTransformer(newShop); + } + + /** + * Edits an existing shop + * @param submitter The user submitting the request, who must be a admin. + * @param shopId The id of the shop to edit + * @param name The name of the shop + * @param description The description of the shop + * @param organization The organization for which the shop is being edited + * @returns Updated shop + * @throws AccessDeniedAdminOnlyException If the submitter is not an admin. + * @throws NotFoundException If the shop with the given ID does not exist. + * @throws DeletedException If the shop has already been deleted. + * @throws InvalidOrganizationException If the shop does not belong to the given organization. + */ + static async editShop( + submitter: User, + shopId: string, + name: string, + description: string, + organization: Organization + ): Promise { + if (!(await userHasPermission(submitter.userId, organization.organizationId, isAdmin))) { + throw new AccessDeniedAdminOnlyException('create shop'); + } + + const existing = await prisma.shop.findUnique({ where: { shopId } }); + if (!existing) throw new NotFoundException('Shop', shopId); + if (existing.dateDeleted) throw new DeletedException('Shop', shopId); + if (existing.organizationId !== organization.organizationId) throw new InvalidOrganizationException('Shop'); + + const duplicate = await prisma.shop.findFirst({ + where: { + organizationId: organization.organizationId, + dateDeleted: null, + name: { equals: name, mode: 'insensitive' }, + NOT: { shopId } + } + }); + + if (duplicate) { + throw new HttpException(409, "Can't have two shops with the same name"); + } + + const updatedShop = await prisma.shop.update({ + where: { shopId }, + data: { name, description }, + ...getShopQueryArgs(organization.organizationId) + }); + + return shopTransformer(updatedShop); + } + + /** + * @param submitter The user submitting the request, who must be an admin + * @param name The name of the calendar + * @param description A summary of what the calendar is used for + * @param colorHexCode The color of the calendar + * @param organization The organization for which the calendar is being created + * + * @returns The created calendar + * + * @throws AccessDeniedAdminOnlyException If the submitter is not an admin. + */ + static async createCalendar( + submitter: User, + name: string, + description: string, + colorHexCode: string, + organization: Organization + ): Promise { + if (!(await userHasPermission(submitter.userId, organization.organizationId, isAdmin))) { + throw new AccessDeniedAdminOnlyException('create calendar'); + } + + const duplicate = await prisma.calendar.findFirst({ + where: { + organizationId: organization.organizationId, + dateDeleted: null, + name: { equals: name, mode: 'insensitive' } + } + }); + if (duplicate) { + throw new HttpException(409, "Can't have two calendars with the same name"); + } + + const newCalendar = await prisma.calendar.create({ + data: { + name, + description, + colorHexCode, + userCreatedId: submitter.userId, + organizationId: organization.organizationId + }, + ...getCalendarQueryArgs(organization.organizationId) + }); + + return calendarTransformer(newCalendar); + } + + /** + * @param submitter The user submitting the request, who must be an admin. + * @param calendarId The id of the given calendar. + * @param name The name of the calendar. + * @param description The summary of what the calendar is used for. + * @param colorHexCode The color of the calendar. + * @param organization The organization for which the calendar is being edited. + * + * @returns The edited calendar. + * + * @throws NotFoundException If the given calendarId is not found. + * @throws InvalidOrganizationException If the given calendarId is not part of the same organization. + * @throws DeletedException If the calendar has already been deleted. + * @throws AccessDeniedAdminOnlyException If the submitter is not an admin. + */ + static async editCalendar( + submitter: User, + calendarId: string, + name: string, + description: string, + colorHexCode: string, + organization: Organization + ): Promise { + const calendar = await prisma.calendar.findUnique({ + where: { calendarId } + }); + + if (!calendar) throw new NotFoundException('Calendar', calendarId); + if (calendar.dateDeleted) throw new DeletedException('Calendar', calendarId); + if (calendar.organizationId !== organization.organizationId) throw new InvalidOrganizationException('Calendar'); + + const hasPermission = await userHasPermission(submitter.userId, organization.organizationId, isAdmin); + + if (!hasPermission) { + throw new AccessDeniedException('Only admins can edit calendars'); + } + + const duplicate = await prisma.calendar.findFirst({ + where: { + organizationId: organization.organizationId, + dateDeleted: null, + name: { equals: name, mode: 'insensitive' }, + NOT: { calendarId } + } + }); + + if (duplicate) { + throw new HttpException(409, "Can't have two calendars with the same name"); + } + + const newCalendar = await prisma.calendar.update({ + where: { calendarId }, + data: { + name, + description, + colorHexCode + }, + ...getCalendarQueryArgs(organization.organizationId) + }); + + return calendarTransformer(newCalendar); + } + + /** + * Delete calendar in the database + * @param submitter The user submitting the request, who must be an admin. + * @param calendarId The id of the given calendar. + * @param organization The organization for which the calendar is being deleted. + * + * @returns The deleted calendar. + * + * @throws NotFoundException If the given calendarId is not found. + * @throws InvalidOrganizationException If the given calendarId is not part of the same organization. + * @throws DeletedException If the calendar has already been deleted. + * @throws AccessDeniedAdminOnlyException If the submitter is not an admin. + */ + static async deleteCalendar(submitter: User, calendarId: string, organization: Organization): Promise { + const calendar = await prisma.calendar.findUnique({ + where: { calendarId } + }); + + if (!calendar) throw new NotFoundException('Calendar', calendarId); + if (calendar.dateDeleted) throw new DeletedException('Calendar', calendarId); + if (calendar.organizationId !== organization.organizationId) throw new InvalidOrganizationException('Calendar'); + + const hasPermission = await userHasPermission(submitter.userId, organization.organizationId, isAdmin); + + if (!hasPermission) { + throw new AccessDeniedException('Only admins can delete calendars'); + } + + const deletedCalendar = await prisma.calendar.update({ + where: { calendarId }, + data: { dateDeleted: new Date(), userDeletedId: submitter.userId }, + ...getCalendarQueryArgs(organization.organizationId) + }); + + return calendarTransformer(deletedCalendar); + } + + /** + * Edits a given event type. + * + * @param eventTypeId The id of the event type of be edited + * @param submitter The user submitting the request, who must be an admin. + * @param name The name of the event type. + * @param calendarIds An array of the calendars this event type is associated with. + * @param organization The organization for which the event type is being created. + * @param schedule Determines if a date is associated with this event type. + * @param requiredMembers Determines if this event type has required members. + * @param optionalMembers Determines if this event type has optional members. + * @param teams Determines if this event type has teams. + * @param teamType Determines if this event type has team types. + * @param location Determines if this event type has a location. + * @param zoomLink Determines if this event type has a zoom link. + * @param shop Determines if a shop is associated with this event type. + * @param machinery Determines if machinery is associated with this event type. + * @param workPackage Determines if a work package is associated with this event type. + * @param questionDocument Determines if a question document is associated with this event type. + * @param documents Determines if documents are associates with this event type. + * @param description Determines if a description is associated with this event type. + * @param onlyHeadsOrAbove Determines if events associated with this event type can only be made by heads or above. + * @param requiredConfirmation Determines if events associated with this event type need to be confirmed. + * @param sendSlackNotifications Determines if events associated with this event type should receive slack notifications. + * + * @returns The created event type. + * + * @throws AccessDeniedAdminOnlyException If the submitter is not an admin. + * @throws NotFoundException If the given calendarIds are not found. + * @throws InvalidOrganizationException If the given calendarIds are not part of the same organization. + */ + static async editEventType( + eventTypeId: string, + submitter: User, + calendarIds: string[], + organization: Organization, + name: string, + requiredMembers: boolean, + optionalMembers: boolean, + teams: boolean, + teamType: boolean, + location: boolean, + zoomLink: boolean, + shop: boolean, + machinery: boolean, + workPackage: boolean, + questionDocument: boolean, + documents: boolean, + description: boolean, + onlyHeadsOrAbove: boolean, + requiresConfirmation: boolean, + sendSlackNotifications: boolean + ): Promise { + if (!(await userHasPermission(submitter.userId, organization.organizationId, isAdmin))) { + throw new AccessDeniedAdminOnlyException('edit event type'); + } + + // Check if calendars with ids exist and belong to the same organization + const existingCalendars = await prisma.calendar.findMany({ + where: { + calendarId: { in: calendarIds } + } + }); + + // Ensure all provided calendars exist + if (existingCalendars.length !== calendarIds.length) { + const foundIds = existingCalendars.map((c) => c.calendarId); + const missingIds = calendarIds.filter((id) => !foundIds.includes(id)); + throw new NotFoundException('Calendar', missingIds.join(', ')); + } + + // Ensure all calendars belong to the given organization + for (const calendar of existingCalendars) { + if (calendar.organizationId !== organization.organizationId) { + throw new InvalidOrganizationException('Calendar'); + } + } + + // Ensure event type to edit exists + const oldEventType = await prisma.event_Type.findUnique({ + where: { + eventTypeId, + organizationId: organization.organizationId + } + }); + + if (!oldEventType) throw new NotFoundException('Event Type', eventTypeId); + if (oldEventType.dateDeleted) throw new DeletedException('Event Type', eventTypeId); + + const updatedEventType = await prisma.event_Type.update({ + where: { eventTypeId: oldEventType.eventTypeId }, + data: { + name, + calendars: { + set: calendarIds.map((calendarId) => ({ calendarId })) + }, + requiredMembers, + optionalMembers, + teams, + teamType, + location, + zoomLink, + shop, + machinery, + workPackage, + questionDocument, + documents, + description, + onlyHeadsOrAboveForEventCreation: onlyHeadsOrAbove, + requiresConfirmation, + sendSlackNotifications + }, + ...getEventTypeQueryArgs(organization.organizationId) + }); + + return eventTypeTransformer(updatedEventType); + } + + /** + * Delete event type in the database + * @param submitter The user submitting the request, who must be an admin. + * @param eventTypeId The id of the given event type. + * @param organization The organization for which the event type is being deleted. + * + * @returns The deleted event type. + * + * @throws NotFoundException If the given event type is not found. + * @throws InvalidOrganizationException If the given eventTypeId is not part of the same organization. + * @throws DeletedException If the event type has already been deleted. + * @throws AccessDeniedAdminOnlyException If the submitter is not an admin. + */ + static async deleteEventType(submitter: User, eventTypeId: string, organization: Organization): Promise { + const eventType = await prisma.event_Type.findUnique({ + where: { eventTypeId } + }); + + if (!eventType) throw new NotFoundException('Event Type', eventTypeId); + if (eventType.dateDeleted) throw new DeletedException('Event Type', eventTypeId); + if (eventType.organizationId !== organization.organizationId) throw new InvalidOrganizationException('Event Type'); + + const hasPermission = await userHasPermission(submitter.userId, organization.organizationId, isAdmin); + + if (!hasPermission) { + throw new AccessDeniedException('Only admins can delete event types!'); + } + + const deletedEventType = await prisma.event_Type.update({ + where: { eventTypeId }, + data: { dateDeleted: new Date(), userDeletedId: submitter.userId }, + ...getEventTypeQueryArgs(organization.organizationId) + }); + + return eventTypeTransformer(deletedEventType); + } + + /** + * Deletes a shop by its ID. + * Requires the submitter to be head or above. + * @param submitter The user submitting the request. + * @param shopId The ID of the shop to be deleted. + * @param organization The organization to which the shop belongs. + * @returns The deleted shop object. + * @throws AccessDeniedAdminOnlyException If the submitter is not an admin. + * @throws NotFoundException If the shop with the given ID does not exist. + * @throws InvalidOrganizationException If the shop does not belong to the given organization. + * + */ + + static async deleteShop(submitter: User, shopId: string, organization: Organization): Promise { + if (!(await userHasPermission(submitter.userId, organization.organizationId, isAdmin))) { + throw new AccessDeniedAdminOnlyException('delete shop'); + } + + // Ensure the shop exists + const existing = await prisma.shop.findUnique({ where: { shopId } }); + if (!existing) throw new NotFoundException('Shop', shopId); + + // Ensure it belongs to this org + if (existing.organizationId !== organization.organizationId) { + throw new InvalidOrganizationException('Shop'); + } + + // not already soft-deleted + if (existing.dateDeleted) { + throw new NotFoundException('Shop', shopId); + } + + // Soft delete the shop and its associated shop machinery in a transaction + const deleted = await prisma.$transaction(async (tx) => { + await tx.shop_Machinery.deleteMany({ where: { shopId } }); + + return tx.shop.update({ + where: { shopId }, + data: { dateDeleted: new Date() }, + include: getShopQueryArgs(organization.organizationId).include + }); + }); + + return shopTransformer(deleted); + } + + /** + * Gets all events that match the given filter arguments + * + * @param filters Filters for the events you want to get, which include member IDs, team IDs, event IDs, event type IDs, approval status, and date ranges + * + * @returns The all events that match all of the given filter arguments. + * + * @throws NotFoundException If the given event type Ids, member IDs, team IDs, or event IDs are not found. + */ + static async getFilteredEvents(filters: FilterArgs, organization: Organization): Promise { + const { memberIds, teamIds, calendarIds, eventTypeIds, eventIds, approvalIds, startPeriod, endPeriod, statuses } = + filters; + + // validate memberIds + if (memberIds?.length) { + const foundMembers = await prisma.user.findMany({ + where: { + userId: { in: memberIds }, + organizations: { some: { organizationId: organization.organizationId } } + } + }); + if (foundMembers.length !== memberIds.length) { + const missingIds = memberIds.filter((id) => !foundMembers.some((mem) => mem.userId === id)); + throw new NotFoundException('User', missingIds.join(', ')); + } + } + + // validate teamIds + if (teamIds?.length) { + const foundteams = await prisma.team.findMany({ + where: { + teamId: { in: teamIds }, + organization: { organizationId: organization.organizationId } + } + }); + if (foundteams.length !== teamIds.length) { + const missingIds = teamIds.filter((id) => !foundteams.some((team) => team.teamId === id)); + throw new NotFoundException('Team', missingIds.join(', ')); + } + } + + // validate calendarIds + if (calendarIds?.length) { + const foundcalendars = await prisma.calendar.findMany({ + where: { + calendarId: { in: calendarIds }, + organization: { organizationId: organization.organizationId }, + dateDeleted: null + } + }); + if (foundcalendars.length !== calendarIds.length) { + const missingIds = calendarIds.filter((id) => !foundcalendars.some((mem) => mem.calendarId === id)); + throw new NotFoundException('Calendar', missingIds.join(', ')); + } + } + + // validate eventTypeIds + if (eventTypeIds?.length) { + const foundEventTypes = await prisma.event_Type.findMany({ + where: { + eventTypeId: { in: eventTypeIds }, + organization: { organizationId: organization.organizationId }, + dateDeleted: null + } + }); + if (foundEventTypes.length !== eventTypeIds.length) { + const missingIds = eventTypeIds.filter((id) => !foundEventTypes.some((et) => et.eventTypeId === id)); + throw new NotFoundException('Event Type', missingIds.join(', ')); + } + } + + // validate eventIds + if (eventIds?.length) { + const foundEvents = await prisma.event.findMany({ + where: { + eventId: { in: eventIds }, + dateDeleted: null + } + }); + if (foundEvents.length !== eventIds.length) { + const missingIds = eventIds.filter((id) => !foundEvents.some((et) => et.eventId === id)); + throw new NotFoundException('Event', missingIds.join(', ')); + } + } + + // filters for members/teams + const memberOrTeamFilter = []; + + if (memberIds?.length) { + memberOrTeamFilter.push( + { requiredMembers: { some: { userId: { in: memberIds } } } }, + { optionalMembers: { some: { userId: { in: memberIds } } } }, + { userCreatedId: { in: memberIds } } + ); + } + + if (teamIds?.length) { + memberOrTeamFilter.push({ teams: { some: { teamId: { in: teamIds } } } }); + memberOrTeamFilter.push({ workPackages: { some: { project: { teams: { some: { teamId: { in: teamIds } } } } } } }); + } + + // filters for selected calendars + const fromCalendar = + calendarIds !== undefined + ? { + eventType: { + is: { + organizationId: organization.organizationId, + calendars: { + some: { + calendarId: { in: calendarIds }, + organizationId: organization.organizationId + } + } + } + } + } + : undefined; + + // get event using filter args + const events = await prisma.event.findMany({ + where: { + dateDeleted: null, + eventId: eventIds?.length ? { in: eventIds } : undefined, + eventTypeId: eventTypeIds?.length ? { in: eventTypeIds } : undefined, + approvalRequiredFromUserId: approvalIds?.length ? { in: approvalIds } : undefined, + OR: memberIds || teamIds ? memberOrTeamFilter : undefined, + approved: statuses?.length ? { in: statuses } : undefined, + scheduledTimes: buildScheduledTimesOverlap(startPeriod, endPeriod), + ...fromCalendar + }, + ...getEventQueryArgs(organization.organizationId), + orderBy: { dateCreated: 'asc' } + }); + + return events.map(eventTransformer); + } + + static async getAllShops(organization: Organization): Promise { + const shops = await prisma.shop.findMany({ + where: { + organizationId: organization.organizationId, + dateDeleted: null + }, + ...getShopQueryArgs(organization.organizationId) + }); + + return shops.map(shopTransformer); + } + + static async getAllCalendars(organization: Organization): Promise { + const calendars = await prisma.calendar.findMany({ + where: { + organizationId: organization.organizationId, + dateDeleted: null + }, + ...getCalendarQueryArgs(organization.organizationId) + }); + + return calendars.map(calendarTransformer); + } + + static async getAllMachinery(organization: Organization): Promise { + const list = await prisma.machinery.findMany({ + where: { + organizationId: organization.organizationId, + dateDeleted: null + }, + ...getMachineryQueryArgs(organization.organizationId) + }); + return list.map(machineryTransformer); + } + /** + * Deletes a machinery by its ID. + * Requires the submitter to be an admin. + * @param submitter The user submitting the request. + * @param machineryid The ID of the machinery to be deleted. + * @param organization The organization to which the machinery belongs. + * @returns The deleted machinery object. + * @throws AccessDeniedAdminOnlyException If the submitter is not an admin. + * @throws NotFoundException If the machinery with the given ID does not exist. + * @throws InvalidOrganizationException If the machinery does not belong to the given organization. + * + */ + + static async deleteMachinery(submitter: User, machineryId: string, organization: Organization): Promise { + if (!(await userHasPermission(submitter.userId, organization.organizationId, isAdmin))) { + throw new AccessDeniedAdminOnlyException('delete machinery'); + } + + // Ensure the machinery exists + const existing = await prisma.machinery.findUnique({ where: { machineryId } }); + if (!existing) throw new NotFoundException('Machinery', machineryId); + + // Ensure it belongs to this org + if (existing.organizationId !== organization.organizationId) { + throw new InvalidOrganizationException('Machinery'); + } + + // not already soft-deleted + if (existing.dateDeleted) { + throw new NotFoundException('Machinery', machineryId); + } + + // Soft delete machinery and remove shop mappings in a transaction + const deleted = await prisma.$transaction(async (tx) => { + await tx.shop_Machinery.deleteMany({ + where: { machineryId } + }); + + return await tx.machinery.update({ + where: { machineryId }, + data: { + dateDeleted: new Date(), + userDeletedId: submitter.userId + }, + ...getMachineryQueryArgs(organization.organizationId) + }); + }); + + return machineryTransformer(deleted); + } + + /** + * Retrieves a single event + * + * @param submitter the user who is trying to retrieve the event + * @param eventId the id of the event to retrieve + * @param organizationId the organization that the user is currently in + * @returns the event + */ + static async getSingleEvent(_submitter: User, eventId: string, organization: Organization): Promise { + const event = await prisma.event.findUnique({ + where: { eventId }, + ...getEventQueryArgs(organization.organizationId) + }); + + if (!event) throw new NotFoundException('Event', eventId); + + if (event.dateDeleted) throw new DeletedException('Event', eventId); + + return eventTransformer(event); + } + + /** + * Retrieves a single event + * + * @param submitter the user who is trying to retrieve the event + * @param eventId the id of the event to retrieve + * @param organizationId the organization that the user is currently in + * @returns the event + */ + static async getSingleEventWithMembers(_submitter: User, eventId: string, organization: Organization): Promise { + const event = await prisma.event.findUnique({ + where: { eventId }, + ...getEventWithMembersQueryArgs(organization.organizationId) + }); + + if (!event) throw new NotFoundException('Event', eventId); + + if (event.dateDeleted) throw new DeletedException('Event', eventId); + + return eventTransformer(event); + } + + /** + * Retrieves a potential conflicting event + * If no conflict exists, returns the original event + * + * @param submitter the user who is trying to retrieve the event + * @param eventId the id of the event to retrieve + * @param organizationId the organization that the user is currently in + * @returns the event + */ + static async getConflictingEvent(_submitter: User, eventId: string, organization: Organization): Promise { + const event = await prisma.event.findUnique({ + where: { eventId }, + ...getEventQueryArgs(organization.organizationId) + }); + + if (!event) throw new NotFoundException('Event', eventId); + + if (event.dateDeleted) throw new DeletedException('Event', eventId); + + const eventType = await prisma.event_Type.findUnique({ + where: { eventTypeId: event.eventTypeId } + }); + + if (!eventType) throw new NotFoundException('Event Type', event.eventTypeId); + + if (eventType.dateDeleted) throw new DeletedException('Event Type', event.eventTypeId); + + if (eventType.organizationId !== organization.organizationId) { + throw new InvalidOrganizationException('Calendar'); + } + + const transformedEvent = eventTransformer(event); + + const { hasConflict, conflictingEvent } = await checkEventConflicts( + transformedEvent.scheduledTimes, + organization, + transformedEvent.location, + transformedEvent.eventId + ); + + if (hasConflict && conflictingEvent) { + return conflictingEvent; + } + + return transformedEvent; + } + + /** + * Gets all events in the database + * @param organizationId the organization id of the current user + * @returns All of the events + */ + static async getAllEvents(organization: Organization): Promise { + const events = await prisma.event.findMany({ + where: { dateDeleted: null }, + ...getEventQueryArgs(organization.organizationId) + }); + return events.map(eventTransformer); + } + + /** + * Gets all event types in the database + * @param organization the organization the user is currently in + * @returns All of the event types + */ + static async getAllEventTypes(organization: Organization): Promise { + const eventTypes = await prisma.event_Type.findMany({ + where: { + organizationId: organization.organizationId, + dateDeleted: null + }, + ...getEventTypeQueryArgs(organization.organizationId) + }); + return eventTypes.map(eventTypeTransformer); + } +} diff --git a/src/backend/src/services/design-reviews.services.ts b/src/backend/src/services/design-reviews.services.ts index e620e31021..d94feba9f5 100644 --- a/src/backend/src/services/design-reviews.services.ts +++ b/src/backend/src/services/design-reviews.services.ts @@ -1,4 +1,5 @@ -import { Design_Review_Status, Team_Type, Organization } from '@prisma/client'; +/* +import { Event_Status, Team_Type, Organization } from '@prisma/client'; import { DesignReview, WbsNumber, @@ -48,6 +49,7 @@ export default class DesignReviewsService { * @param organizationId the organization id of the current user * @returns All of the design reviews */ +/* static async getAllDesignReviews(organization: Organization): Promise { const designReviews = await prisma.design_Review.findMany({ where: { dateDeleted: null, wbsElement: { organizationId: organization.organizationId } }, @@ -62,6 +64,7 @@ export default class DesignReviewsService { * @param designReviewId the id of the design review to be deleted * @param organizationId the organization that the user is currently in */ +/* static async deleteDesignReview( submitter: User, designReviewId: string, @@ -114,6 +117,7 @@ export default class DesignReviewsService { * @param organizationId the organization that the user is currently in * @returns a new design review */ +/* static async createDesignReview( submitter: User, initialDate: string, @@ -160,7 +164,7 @@ export default class DesignReviewsService { initialDateScheduled: date, dateScheduled: date, dateCreated: new Date(), - status: Design_Review_Status.UNCONFIRMED, + status: Event_Status.UNCONFIRMED, isOnline: false, isInPerson: false, userCreated: { connect: { userId: submitter.userId } }, @@ -233,6 +237,7 @@ export default class DesignReviewsService { * @param organizationId the organization that the user is currently in * @returns the design review */ +/* static async getSingleDesignReview( _submitter: User, designReviewId: string, @@ -265,11 +270,12 @@ export default class DesignReviewsService { * @param zoomLink the zoom link for the design review meeting * @param location the location for the design review meeting * @param docTemplateLink the document template link for the design review - * @param status see Design_Review_Status enum + * @param status see Event_Status enum * @param attendees the attendees for the design review * @param meetingTimes meeting time must be between 0-83 (representing 1hr increments from 10am 10pm, Monday-Sunday) * @param organizationId the organization that the user is currently in */ +/* static async editDesignReview( user: User, @@ -283,7 +289,7 @@ export default class DesignReviewsService { zoomLink: string | null, location: string | null, docTemplateLink: string | null, - status: Design_Review_Status, + status: Event_Status, attendees: string[], meetingTimes: number[], organization: Organization @@ -310,7 +316,7 @@ export default class DesignReviewsService { meetingTimes = validateMeetingTimes(meetingTimes); // docTemplateLink is required if the status is scheduled or done - if (status === Design_Review_Status.SCHEDULED || status === Design_Review_Status.DONE) { + if (status === Event_Status.SCHEDULED || status === Event_Status.DONE) { if (docTemplateLink == null) { throw new HttpException(400, 'doc template link is required for scheduled and done design reviews'); } @@ -352,8 +358,8 @@ export default class DesignReviewsService { originaldesignReview.confirmedMembers.map((user) => user.userId).includes(member.userId) ); - if (status === Design_Review_Status.CONFIRMED && allRequiredMembersConfirmed) { - status = Design_Review_Status.SCHEDULED; + if (status === Event_Status.CONFIRMED && allRequiredMembersConfirmed) { + status = Event_Status.SCHEDULED; } // actually try to update the design review @@ -384,7 +390,7 @@ export default class DesignReviewsService { ...getDesignReviewQueryArgs(organization.organizationId) }); - if (status === Design_Review_Status.SCHEDULED) { + if (status === Event_Status.SCHEDULED) { await sendDRScheduledSlackNotif(updatedDesignReview.notificationSlackThreads, updatedDesignReview); if (updatedDesignReview.calendarEventId && updatedDesignReview.teamType.calendarId) { await updateCalendarEvent( @@ -412,6 +418,7 @@ export default class DesignReviewsService { * @param organizationId the organization that the user is currently in * @returns the modified design review with its updated confirmedMembers */ +/* static async markUserConfirmed( designReviewId: string, availabilities: AvailabilityCreateArgs[], @@ -482,7 +489,7 @@ export default class DesignReviewsService { where: { designReviewId }, ...getDesignReviewQueryArgs(organization.organizationId), data: { - status: Design_Review_Status.CONFIRMED + status: Event_Status.CONFIRMED } }); @@ -502,6 +509,7 @@ export default class DesignReviewsService { * @param organizationId the organization that the user is currently in * @returns the modified design review */ +/* static async setStatus( user: User, designReviewId: string, @@ -544,6 +552,7 @@ export default class DesignReviewsService { * @param organizationId The organization that the user is currently in * @returns The retrieved Team Type */ +/* static async getSingleTeamType(teamTypeId: string, organization: Organization): Promise { const teamType = await prisma.team_Type.findUnique({ where: { teamTypeId } @@ -555,3 +564,4 @@ export default class DesignReviewsService { return teamType; } } +*/ diff --git a/src/backend/src/services/notifications.services.ts b/src/backend/src/services/notifications.services.ts index 482b965f4a..fa6202c00c 100644 --- a/src/backend/src/services/notifications.services.ts +++ b/src/backend/src/services/notifications.services.ts @@ -1,23 +1,24 @@ import prisma from '../prisma/prisma.js'; import { - DesignReviewWithAttendees, TaskWithAssignees, endOfDayTomorrow, startOfDayTomorrow, - usersToSlackPings + usersToSlackPings, + EventWithAttendees } from '../utils/notifications.utils.js'; import { sendMessage } from '../integrations/slack.js'; -import { daysBetween, meetingStartTimePipe, startOfDay, wbsPipe } from 'shared'; +import { daysBetween, meetingStartTimePipeNumbers, startOfDay, wbsPipe } from 'shared'; import { buildDueString, sendThreadResponse } from '../utils/slack.utils.js'; import WorkPackagesService from './work-packages.services.js'; import { addWeeksToDate } from 'shared'; import { HttpException } from '../utils/errors.utils.js'; import { Reimbursement_Status_Type } from '@prisma/client'; +import { scheduleTimesTransformer } from '../transformers/calendar.transformer.js'; export default class NotificationsService { static async sendDailySlackNotifications() { await NotificationsService.sendTaskDeadlineSlackNotifications(); - await NotificationsService.sendDesignReviewSlackNotifications(); + await NotificationsService.sendEventSlackNotifications(); await NotificationsService.sendWorkPackageDeadlineSlackNotifications(); await NotificationsService.sendSponsorTaskNotifications(); await NotificationsService.sendPendingSaboSubmissionNotifications(); @@ -119,70 +120,91 @@ export default class NotificationsService { /** * Sends the design review slack notifications for all design reviews scheduled for today */ - static async sendDesignReviewSlackNotifications() { + static async sendEventSlackNotifications() { const endOfToday = startOfDayTomorrow(); const startOfToday = startOfDay(new Date()); - const designReviews = await prisma.design_Review.findMany({ + const events = await prisma.event.findMany({ where: { - dateScheduled: { - lt: endOfToday, - gte: startOfToday - }, status: 'SCHEDULED', - dateDeleted: null + dateDeleted: null, + workPackages: { + some: {} // Event must have at least one work package to be a design review + }, + scheduledTimes: { + some: { + AND: [{ endTime: { gte: startOfToday } }, { startTime: { lte: endOfToday } }] + } + } }, include: { requiredMembers: { include: { userSettings: true } }, optionalMembers: { include: { userSettings: true } }, userCreated: { include: { userSettings: true } }, - wbsElement: { + scheduledTimes: true, + workPackages: { include: { - project: { include: { teams: true } }, - workPackage: { include: { project: { include: { teams: true } } } } + wbsElement: true, + project: { + include: { + teams: true, + wbsElement: true + } + } } } } }); - const designReviewTeamMap = new Map(); + const desginReviewEventTeamMap = new Map(); - designReviews.forEach((designReview) => { - const teamSlackIds = designReview.wbsElement.project - ? designReview.wbsElement.project.teams.map((team) => team.slackId) - : (designReview.wbsElement.workPackage?.project.teams.map((team) => team.slackId) ?? []); + events.forEach((event) => { + // Get all unique teams from all work packages associated with this event + const teamSlackIds = new Set(); + + event.workPackages.forEach((workPackage) => { + workPackage.project.teams.forEach((team) => { + if (team.slackId) { + teamSlackIds.add(team.slackId); + } + }); + }); teamSlackIds.forEach((teamSlackId) => { - const currentTasks = designReviewTeamMap.get(teamSlackId); - if (currentTasks) { - currentTasks.push({ - ...designReview, - attendees: designReview.requiredMembers.concat(designReview.optionalMembers).concat(designReview.userCreated) - }); - designReviewTeamMap.set(teamSlackId, currentTasks); + const currentEvents = desginReviewEventTeamMap.get(teamSlackId); + const eventWithAttendees = { + ...event, + attendees: event.requiredMembers.concat(event.optionalMembers).concat(event.userCreated), + scheduledTimes: event.scheduledTimes.map(scheduleTimesTransformer) + }; + + if (currentEvents) { + currentEvents.push(eventWithAttendees); } else { - designReviewTeamMap.set(teamSlackId, [ - { - ...designReview, - attendees: designReview.requiredMembers.concat(designReview.optionalMembers).concat(designReview.userCreated) - } - ]); + desginReviewEventTeamMap.set(teamSlackId, [eventWithAttendees]); } }); }); - // send the notifications to each team for their respective design reviews - const promises = Array.from(designReviewTeamMap).map(async ([slackId, designReviews]) => { - const messageBlock = designReviews - .map((designReview) => { - const zoomLink = designReview.zoomLink ? `<${designReview.zoomLink}|Zoom Link>\n` : ''; - const questionDocLink = designReview.docTemplateLink - ? `<${designReview.docTemplateLink}|Question Doc Link>\n` - : ''; + // Send the notifications to each team for their respective design reviews + const promises = Array.from(desginReviewEventTeamMap).map(async ([slackId, events]) => { + const messageBlock = events + .map((event) => { + const zoomLink = event.zoomLink ? `<${event.zoomLink}|Zoom Link>\n` : ''; + const questionDocLink = event.questionDocumentLink ? `<${event.questionDocumentLink}|Question Doc Link>\n` : ''; + + // Get work package names for this event + const workPackageNames = event.workPackages.map((wp) => wp.wbsElement.name).join(', '); + + // Extract meeting times from scheduled slots + const meetingTimes = event.scheduledTimes + .map((slot) => (slot.startTime ? new Date(slot.startTime).getHours() : null)) + .filter((hour): hour is number => hour !== null) + .sort((a, b) => a - b); + return ( - `${usersToSlackPings(designReview.attendees ?? [])} ${ - designReview.wbsElement.name - } will be having a design review today at ${meetingStartTimePipe(designReview.meetingTimes)}! ` + + `${usersToSlackPings(event.attendees ?? [])} ${event.title} (${workPackageNames}) ` + + `will be having an event today at ${meetingStartTimePipeNumbers(meetingTimes)}! ` + zoomLink + questionDocLink ); diff --git a/src/backend/src/services/teams.services.ts b/src/backend/src/services/teams.services.ts index 94403ba8ee..0479d2e734 100644 --- a/src/backend/src/services/teams.services.ts +++ b/src/backend/src/services/teams.services.ts @@ -1,7 +1,7 @@ import { isAdmin, isHead, Team, TeamPreview, TeamType, User } from 'shared'; import { Organization, WBS_Element_Status } from '@prisma/client'; import prisma from '../prisma/prisma.js'; -import teamTransformer, { teamPreviewTransformer } from '../transformers/teams.transformer.js'; +import teamTransformer, { teamBaseTransformer, teamPreviewTransformer } from '../transformers/teams.transformer.js'; import { NotFoundException, AccessDeniedException, @@ -13,11 +13,20 @@ import { import { getPrismaQueryUserIds, getUsers, userHasPermission } from '../utils/users.utils.js'; import { isUnderWordCount } from 'shared'; import { removeUsersFromList } from '../utils/teams.utils.js'; -import { getTeamPreviewQueryArgs, getTeamQueryArgs } from '../prisma-query-args/teams.query-args.js'; +import { getTeamBaseQueryArgs, getTeamPreviewQueryArgs, getTeamQueryArgs } from '../prisma-query-args/teams.query-args.js'; import { uploadFile } from '../utils/google-integration.utils.js'; import { teamTypeTransformer } from '../transformers/team-types.transformer.js'; +import { TeamBase } from '../../../shared/src/types/team-types.js'; export default class TeamsService { + static async getAllTeamPreviews(organization: Organization): Promise { + const teams = await prisma.team.findMany({ + where: { dateArchived: null, organizationId: organization.organizationId }, + ...getTeamBaseQueryArgs() + }); + return teams.map(teamBaseTransformer); + } + /** * Gets all teams (archived teams are not included) * @param organizationId The organization the user is currently in diff --git a/src/backend/src/services/work-packages.services.ts b/src/backend/src/services/work-packages.services.ts index 9faf820fc5..dc0a3ab262 100644 --- a/src/backend/src/services/work-packages.services.ts +++ b/src/backend/src/services/work-packages.services.ts @@ -24,7 +24,7 @@ import { DeletedException, InvalidOrganizationException } from '../utils/errors.utils.js'; -import { getWorkPackageQueryArgs } from '../prisma-query-args/work-packages.query-args.js'; +import { getWorkPackageQueryArgs, getWorkPackagePreviewQueryArgs } from '../prisma-query-args/work-packages.query-args.js'; import workPackageTransformer, { workPackagePreviewTransformer } from '../transformers/work-packages.transformer.js'; import { updateBlocking, validateChangeRequestAccepted } from '../utils/change-requests.utils.js'; import { sendSlackUpcomingDeadlineNotification } from '../utils/slack.utils.js'; @@ -75,6 +75,31 @@ export default class WorkPackagesService { return outputWorkPackages; } + /** + * Retrieve all work packages in preview format (minimal data for dropdowns/lists). + * + * @param status Optional status filter + * @param organization the organization + * @returns a list of work package previews + */ + static async getAllWorkPackagesPreview( + status: WbsElementStatus | string | undefined, + organization: Organization + ): Promise { + const workPackages = await prisma.work_Package.findMany({ + where: { + wbsElement: { + dateDeleted: null, + organizationId: organization.organizationId, + ...(status ? { status: status as WbsElementStatus } : {}) + } + }, + ...getWorkPackagePreviewQueryArgs() + }); + + return workPackages.map(workPackagePreviewTransformer); + } + /** * Retrieve the work package with the specified WBS number. * @param parsedWbs the WBS number of the desired work package diff --git a/src/backend/src/transformers/calendar.transformer.ts b/src/backend/src/transformers/calendar.transformer.ts new file mode 100644 index 0000000000..d1317748a8 --- /dev/null +++ b/src/backend/src/transformers/calendar.transformer.ts @@ -0,0 +1,233 @@ +import { + Prisma, + DayOfWeek as PrismaDayOfWeek, + Event_Status as PrismaEventStatus, + Conflict_Status as PrismaConflictStatus +} from '@prisma/client'; +import { + Machinery, + Shop, + ShopMachinery, + EventType, + Calendar, + Event, + ScheduleSlot, + EventStatus, + EventPreview, + DayOfWeek, + ConflictStatus, + Document, + EventWithMembers +} from 'shared'; +import { CalendarQueryArgs } from '../prisma-query-args/calendar.query-args.js'; +import { EventTypeQueryArgs } from '../prisma-query-args/event-type.query-args.js'; +import { EventQueryArgs, EventWithMembersQueryArgs } from '../prisma-query-args/event.query-args.js'; +import { ShopMachineryQueryArgs, MachineryQueryArgs } from '../prisma-query-args/machinery.query-args.js'; +import { ShopQueryArgs } from '../prisma-query-args/shop.query-args.js'; +import { userTransformer, userWithScheduleSettingsTransformer } from './user.transformer.js'; + +export const documentTransformer = (document: Prisma.DocumentGetPayload): Document => { + return { documentId: document.documentId, googleFileId: document.googleFileId, name: document.name }; +}; + +export const shopTransformer = (shop: Prisma.ShopGetPayload): Shop => { + return { + shopId: shop.shopId, + name: shop.name, + description: shop.description, + dateCreated: shop.dateCreated, + userCreated: userTransformer(shop.userCreated) + }; +}; + +export const shopMachineryTransformer = ( + shopMachinery: Prisma.Shop_MachineryGetPayload +): ShopMachinery => { + return { + shopMachineryId: shopMachinery.shopMachineryId, + shop: shopTransformer(shopMachinery.shop), + quantity: shopMachinery.quantity + }; +}; + +export const machineryTransformer = (machinery: Prisma.MachineryGetPayload): Machinery => { + return { + machineryId: machinery.machineryId, + name: machinery.name, + shops: machinery.shops.map(shopMachineryTransformer), + dateCreated: machinery.dateCreated, + userCreated: userTransformer(machinery.userCreated) + }; +}; + +export const eventTypeTransformer = (eventType: Prisma.Event_TypeGetPayload): EventType => { + const eventTypeWithCalendars = eventType as typeof eventType & { + calendars?: { calendarId: string }[]; + }; + return { + eventTypeId: eventType.eventTypeId, + name: eventType.name, + userCreated: userTransformer(eventType.userCreated), + dateCreated: eventType.dateCreated, + calendarIds: eventTypeWithCalendars.calendars?.map((c: { calendarId: string }) => c.calendarId) || [], + requiredMembers: eventType.requiredMembers, + optionalMembers: eventType.optionalMembers, + teams: eventType.teams, + teamType: eventType.teamType, + location: eventType.location, + zoomLink: eventType.zoomLink, + shop: eventType.shop, + machinery: eventType.machinery, + workPackage: eventType.workPackage, + questionDocument: eventType.questionDocument, + documents: eventType.documents, + description: eventType.description, + onlyHeadsOrAboveForEventCreation: eventType.onlyHeadsOrAboveForEventCreation, + requiresConfirmation: eventType.requiresConfirmation, + sendSlackNotifications: eventType.sendSlackNotifications + } as EventType; +}; + +export const calendarTransformer = (calendar: Prisma.CalendarGetPayload): Calendar => { + return { + calendarId: calendar.calendarId, + name: calendar.name, + description: calendar.description, + color: calendar.colorHexCode, + userCreated: userTransformer(calendar.userCreated), + dateCreated: calendar.dateCreated, + eventTypes: calendar.eventTypes.map(eventTypeTransformer) + }; +}; + +export const scheduleTimesTransformer = (scheduleTimes: Prisma.Schedule_SlotGetPayload): ScheduleSlot => { + return { + scheduleSlotId: scheduleTimes.scheduleSlotId, + startTime: scheduleTimes.startTime ?? undefined, + endTime: scheduleTimes.endTime ?? undefined, + allDay: scheduleTimes.allDay + }; +}; + +export const eventTransformer = (event: Prisma.EventGetPayload): Event => { + return { + eventId: event.eventId, + title: event.title, + userCreated: userTransformer(event.userCreated), + dateCreated: event.dateCreated, + eventTypeId: event.eventTypeId, + requiredMembers: event.requiredMembers.map(userTransformer), + optionalMembers: event.optionalMembers.map(userTransformer), + confirmedMembers: event.confirmedMembers.map(userWithScheduleSettingsTransformer), + deniedMembers: event.deniedMembers.map(userTransformer), + teams: event.teams, + teamType: event.teamType ?? undefined, + shops: event.shops, + machinery: event.machinery, + workPackages: event.workPackages, + documents: event.documents.filter((document) => !document.dateDeleted).map(documentTransformer), + scheduledTimes: event.scheduledTimes.map(scheduleTimesTransformer), + approved: conflictStatusTransformer(event.approved), + approvalRequiredFrom: event.approvalRequiredBy ?? undefined, + location: event.location ?? undefined, + zoomLink: event.zoomLink ?? undefined, + questionDocumentLink: event.questionDocumentLink ?? undefined, + description: event.description ?? undefined, + status: eventStatusTransformer(event.status), + initialDateScheduled: event.initialDateScheduled ?? undefined + }; +}; + +export const eventWithMembersTransformer = (event: Prisma.EventGetPayload): EventWithMembers => { + return { + eventId: event.eventId, + title: event.title, + userCreated: userWithScheduleSettingsTransformer(event.userCreated), + dateCreated: event.dateCreated, + eventTypeId: event.eventTypeId, + requiredMembers: event.requiredMembers.map(userTransformer), + optionalMembers: event.optionalMembers.map(userTransformer), + confirmedMembers: event.confirmedMembers.map(userWithScheduleSettingsTransformer), + deniedMembers: event.deniedMembers.map(userTransformer), + teams: event.teams.map((team) => ({ + ...team, + members: team.members.map(userTransformer), + leads: team.leads.map(userTransformer), + head: userTransformer(team.head) + })), + teamType: event.teamType + ? { + teamTypeId: event.teamType.teamTypeId, + name: event.teamType.name, + teams: event.teamType.teams.map((team) => ({ + members: team.members.map(userTransformer), + leads: team.leads.map(userTransformer), + head: userTransformer(team.head) + })) + } + : undefined, + shops: event.shops, + machinery: event.machinery, + workPackages: event.workPackages, + documents: event.documents.filter((document) => !document.dateDeleted).map(documentTransformer), + scheduledTimes: event.scheduledTimes.map(scheduleTimesTransformer), + approved: conflictStatusTransformer(event.approved), + approvalRequiredFrom: event.approvalRequiredBy ?? undefined, + location: event.location ?? undefined, + zoomLink: event.zoomLink ?? undefined, + questionDocumentLink: event.questionDocumentLink ?? undefined, + description: event.description ?? undefined, + status: eventStatusTransformer(event.status), + initialDateScheduled: event.initialDateScheduled ?? undefined + }; +}; + +export const eventPreviewTransformer = (event: Prisma.EventGetPayload, wbsName: string): EventPreview => { + // Use first scheduled time's startTime, or fall back to initialDateScheduled (for confirmation events), or current date + const dateScheduled = + (event.scheduledTimes.length > 0 && event.scheduledTimes[0].startTime ? event.scheduledTimes[0].startTime : null) ?? + event.initialDateScheduled ?? + new Date(); + + return { + eventId: event.eventId, + title: event.title, + dateScheduled, + status: event.status as EventStatus, + userCreated: userTransformer(event.userCreated), + wbsName + }; +}; + +export const dayOfWeekTransformer = (day: PrismaDayOfWeek): DayOfWeek => { + const mapping: Record = { + MONDAY: DayOfWeek.MONDAY, + TUESDAY: DayOfWeek.TUESDAY, + WEDNESDAY: DayOfWeek.WEDNESDAY, + THURSDAY: DayOfWeek.THURSDAY, + FRIDAY: DayOfWeek.FRIDAY, + SATURDAY: DayOfWeek.SATURDAY, + SUNDAY: DayOfWeek.SUNDAY + }; + return mapping[day]; +}; + +export const conflictStatusTransformer = (day: PrismaConflictStatus): ConflictStatus => { + const mapping: Record = { + APPROVED: ConflictStatus.APPROVED, + PENDING: ConflictStatus.PENDING, + DENIED: ConflictStatus.DENIED, + NO_CONFLICT: ConflictStatus.NO_CONFLICT + }; + return mapping[day]; +}; + +export const eventStatusTransformer = (status: PrismaEventStatus): EventStatus => { + const mapping: Record = { + UNCONFIRMED: EventStatus.UNCONFIRMED, + CONFIRMED: EventStatus.CONFIRMED, + SCHEDULED: EventStatus.SCHEDULED, + DONE: EventStatus.DONE + }; + return mapping[status]; +}; diff --git a/src/backend/src/transformers/design-reviews.transformer.ts b/src/backend/src/transformers/design-reviews.transformer.ts index e246de31cc..aec911a2d6 100644 --- a/src/backend/src/transformers/design-reviews.transformer.ts +++ b/src/backend/src/transformers/design-reviews.transformer.ts @@ -1,3 +1,4 @@ +/* import { Prisma } from '@prisma/client'; import { DesignReview, DesignReviewPreview, DesignReviewStatus, isProjectWbs } from 'shared'; import { wbsNumOf } from '../utils/utils.js'; @@ -50,3 +51,4 @@ export const designReviewPreviewTransformer = ( wbsName }; }; +*/ diff --git a/src/backend/src/transformers/teams.transformer.ts b/src/backend/src/transformers/teams.transformer.ts index 5c75bc725f..4b9b9a7d33 100644 --- a/src/backend/src/transformers/teams.transformer.ts +++ b/src/backend/src/transformers/teams.transformer.ts @@ -1,6 +1,6 @@ import { Prisma } from '@prisma/client'; -import { Team, TeamPreview } from 'shared'; -import { TeamPreviewQueryArgs, TeamQueryArgs } from '../prisma-query-args/teams.query-args.js'; +import { Team, TeamPreview, TeamBase } from 'shared'; +import { getTeamBaseQueryArgs, TeamPreviewQueryArgs, TeamQueryArgs } from '../prisma-query-args/teams.query-args.js'; import { userTransformer } from './user.transformer.js'; import { teamTypeTransformer } from './team-types.transformer.js'; import { projectGanttTransformer } from './projects.transformer.js'; @@ -21,12 +21,24 @@ const teamTransformer = (team: Prisma.TeamGetPayload): Team => { }; }; +export const teamBaseTransformer = (team: Prisma.TeamGetPayload>): TeamBase => { + return { + teamId: team.teamId, + teamName: team.teamName, + slackId: team.slackId, + description: team.description, + dateArchived: team.dateArchived ?? undefined, + teamType: team.teamType ? teamTypeTransformer(team.teamType) : undefined + }; +}; + export const teamPreviewTransformer = (team: Prisma.TeamGetPayload): TeamPreview => { return { ...team, leads: team.leads.map(userTransformer), members: team.members.map(userTransformer), - head: userTransformer(team.head) + head: userTransformer(team.head), + dateArchived: team.dateArchived ?? undefined }; }; diff --git a/src/backend/src/transformers/work-packages.transformer.ts b/src/backend/src/transformers/work-packages.transformer.ts index c913411f32..2d6184c28b 100644 --- a/src/backend/src/transformers/work-packages.transformer.ts +++ b/src/backend/src/transformers/work-packages.transformer.ts @@ -4,8 +4,8 @@ import descriptionBulletTransformer from '../transformers/description-bullets.tr import { convertStatus, wbsNumOf } from '../utils/utils.js'; import { userTransformer } from './user.transformer.js'; import { WorkPackageQueryArgs, WorkPackagePreviewQueryArgs } from '../prisma-query-args/work-packages.query-args.js'; -import { designReviewPreviewTransformer } from './design-reviews.transformer.js'; import { teamTypeTransformer } from './team-types.transformer.js'; +import { eventPreviewTransformer } from './calendar.transformer.js'; const workPackageTransformer = (wpInput: Prisma.Work_PackageGetPayload): WorkPackage => { const wbsNum = wbsNumOf(wpInput.wbsElement); @@ -39,9 +39,9 @@ const workPackageTransformer = (wpInput: Prisma.Work_PackageGetPayload wbsNumOf(wp.wbsElement)), - designReviews: wpInput.wbsElement.designReviews.map((designReview) => - designReviewPreviewTransformer(designReview, `${wpInput.project.wbsElement.name} - ${wpInput.wbsElement.name}`) - ), + events: wpInput.events + .filter((event) => event.workPackages.length > 0) // Only events that are design reviews (have work packages) + .map((event) => eventPreviewTransformer(event, `${wpInput.project.wbsElement.name} - ${wpInput.wbsElement.name}`)), deleted: wpInput.wbsElement.dateDeleted !== null }; }; diff --git a/src/backend/src/utils/calendar.utils.ts b/src/backend/src/utils/calendar.utils.ts new file mode 100644 index 0000000000..34c4ed603f --- /dev/null +++ b/src/backend/src/utils/calendar.utils.ts @@ -0,0 +1,357 @@ +import { Prisma, Event_Type, Organization } from '@prisma/client'; +import { + User, + ScheduleSlotCreateArgs, + EventDocumentCreateArgs, + Document, + ConflictStatus, + Event, + EventWithMembers +} from 'shared'; +import { InvalidEventTypeConfigurationException } from './errors.utils.js'; +import prisma from '../prisma/prisma.js'; +import { getEventQueryArgs } from '../prisma-query-args/event.query-args.js'; +import { eventTransformer } from '../transformers/calendar.transformer.js'; + +export function buildScheduledTimesOverlap(start?: Date, end?: Date): Prisma.Schedule_SlotListRelationFilter | undefined { + if (!start && !end) return undefined; + + // With the new schema, we check if the slot's time range overlaps with the query range + const AND: Prisma.Schedule_SlotWhereInput[] = []; + + // For all-day events, we just check the date portion + // For timed events, we check the full datetime + if (start) { + AND.push({ + OR: [ + { allDay: true, startTime: { gte: start } }, + { allDay: false, endTime: { gte: start } } + ] + }); + } + + if (end) { + AND.push({ + OR: [ + { allDay: true, startTime: { lte: end } }, + { allDay: false, startTime: { lte: end } } + ] + }); + } + + return { some: { AND } }; +} + +export const isUserOnEvent = (user: User, event: EventWithMembers): boolean => { + // Check if user is directly a required or optional member + const isDirectMember = + event.requiredMembers.some((member) => member.userId === user.userId) || + event.optionalMembers.some((member) => member.userId === user.userId); + + if (isDirectMember) { + return true; + } + + // Check if user is on any of the event's teams (as member, lead, or head) + const isOnEventTeam = event.teams.some( + (team) => + team.members.some((member) => member.userId === user.userId) || + team.leads.some((lead) => lead.userId === user.userId) || + team.head.userId === user.userId + ); + + if (isOnEventTeam) { + return true; + } + + // Check if user is on any team that belongs to the event's team type + if (event.teamType) { + const isOnTeamType = event.teamType.teams.some( + (team) => + team.members.some((member) => member.userId === user.userId) || + team.leads.some((lead) => lead.userId === user.userId) || + team.head.userId === user.userId + ); + + if (isOnTeamType) { + return true; + } + } + + if (event.userCreated.userId === user.userId) { + return true; + } + + return false; +}; + +/** + * Validates that an event's data matches its event type configuration. + * Throws an exception if required fields are missing or if fields are provided that the event type doesn't allow. + * + * @param eventType The event type to validate against + * @param eventData The event data to validate + * @throws InvalidEventTypeConfigurationException if validation fails + */ +export function validateEventTypeConfiguration( + eventType: Event_Type, + eventData: { + requiredMemberIds: string[]; + optionalMemberIds: string[]; + teamIds: string[]; + shopIds: string[]; + machineryIds: string[]; + workPackageIds: string[]; + documents: EventDocumentCreateArgs[]; + scheduleSlots: ScheduleSlotCreateArgs[]; + initialDateScheduled?: Date; + teamTypeId?: string; + location?: string; + zoomLink?: string; + questionDocumentLink?: string; + description?: string; + } +): void { + const requiresLocationOrZoom = eventType.location || eventType.zoomLink; + + const missingBoth = !eventData.location && !eventData.zoomLink; + + // Check required fields + if (eventType.requiredMembers && eventData.requiredMemberIds.length === 0) { + throw new InvalidEventTypeConfigurationException('at least one required member'); + } + if (eventType.teamType && !eventData.teamTypeId) { + throw new InvalidEventTypeConfigurationException('a team type'); + } + if (requiresLocationOrZoom && missingBoth) { + throw new InvalidEventTypeConfigurationException('a location or zoom link'); + } + if (eventType.workPackage && eventData.workPackageIds.length === 0) { + throw new InvalidEventTypeConfigurationException('at least one work package'); + } + if (eventType.questionDocument && !eventData.questionDocumentLink) { + throw new InvalidEventTypeConfigurationException('a question document'); + } + + // For requiresConfirmation events, the event must have an initialDateScheduled + if (eventType.requiresConfirmation && !eventData.initialDateScheduled) { + throw new InvalidEventTypeConfigurationException('an initial date scheduled'); + } + + // For non-requiresConfirmation events, there must be at least one schedule slot + if (!eventType.requiresConfirmation && eventData.scheduleSlots.length === 0) { + throw new InvalidEventTypeConfigurationException('at least one schedule slot'); + } + + // Check disallowed fields (inverse validation) + if (!eventType.requiredMembers && eventData.requiredMemberIds.length > 0) { + throw new InvalidEventTypeConfigurationException('Event type does not allow required members'); + } + if (!eventType.optionalMembers && eventData.optionalMemberIds.length > 0) { + throw new InvalidEventTypeConfigurationException('Event type does not allow optional members'); + } + if (!eventType.location && eventData.location) { + throw new InvalidEventTypeConfigurationException('Event type does not allow a location'); + } + if (!eventType.zoomLink && eventData.zoomLink) { + throw new InvalidEventTypeConfigurationException('Event type does not allow a zoom link'); + } + if (!eventType.shop && eventData.shopIds.length > 0) { + throw new InvalidEventTypeConfigurationException('Event type does not allow shops'); + } + if (!eventType.machinery && eventData.machineryIds.length > 0) { + throw new InvalidEventTypeConfigurationException('Event type does not allow machinery'); + } + if (!eventType.workPackage && eventData.workPackageIds.length > 0) { + throw new InvalidEventTypeConfigurationException('Event type does not allow work packages'); + } + if (!eventType.questionDocument && eventData.questionDocumentLink) { + throw new InvalidEventTypeConfigurationException('Event type does not allow a question document'); + } + if (!eventType.documents && eventData.documents.length > 0) { + throw new InvalidEventTypeConfigurationException('Event type does not allow documents'); + } + if (!eventType.description && eventData.description) { + throw new InvalidEventTypeConfigurationException('Event type does not allow a description'); + } +} + +/** + * Checks if there are any scheduling conflicts with existing events. + * A conflict occurs when events overlap in both time and location. + * + * @param scheduleSlots The schedule slots to check (individual occurrences with full datetimes) + * @param location The location to check + * @param organization The organization + * @param eventId The event ID to exclude from conflict check (for edits) + * @returns An object with hasConflict boolean and the conflicting event (if any) + */ +export async function checkEventConflicts( + scheduleSlots: ScheduleSlotCreateArgs[], + organization: Organization, + location?: string, + eventId?: string +): Promise<{ hasConflict: boolean; conflictingEvent?: Event }> { + // No conflict if there's no location + if (!location) { + return { hasConflict: false }; + } + + // No conflict if there are no schedule slots + if (scheduleSlots.length === 0) { + return { hasConflict: false }; + } + + // To limit speed issues, find min start and max end across time slots being checked, and limit potential conflicts to that range + let minStart: Date | null = null; + let maxEnd: Date | null = null; + + for (const slot of scheduleSlots) { + if (slot.startTime) { + if (!minStart || slot.startTime < minStart) { + minStart = slot.startTime; + } + } + if (slot.endTime) { + if (!maxEnd || slot.endTime > maxEnd) { + maxEnd = slot.endTime; + } + } + } + + // Find all events in the same organization with the same location + const potentialConflicts = await prisma.event.findMany({ + where: { + eventId: eventId ? { not: eventId } : undefined, + location, + dateDeleted: null, + approved: { in: [ConflictStatus.APPROVED, ConflictStatus.NO_CONFLICT] }, + eventType: { + organizationId: organization.organizationId + }, + scheduledTimes: buildScheduledTimesOverlap(minStart ?? undefined, maxEnd ?? undefined) + }, + ...getEventQueryArgs(organization.organizationId) + }); + + // Check each new schedule slot against existing event slots + for (const newSlot of scheduleSlots) { + for (const event of potentialConflicts) { + for (const existingSlot of event.scheduledTimes) { + if (!existingSlot.startTime || !existingSlot.endTime) continue; + + // Check if events overlap + // If both are all-day events on the same day, they conflict + if (newSlot.allDay && existingSlot.allDay) { + const newDate = new Date(newSlot.startTime); + const existingDate = new Date(existingSlot.startTime); + + // Normalize to compare dates only + newDate.setHours(0, 0, 0, 0); + existingDate.setHours(0, 0, 0, 0); + + if (newDate.getTime() === existingDate.getTime()) { + return { hasConflict: true, conflictingEvent: eventTransformer(event) }; + } + } + // If one is all-day, check if the all-day event's date overlaps with the timed event + else if (newSlot.allDay || existingSlot.allDay) { + const allDaySlot = newSlot.allDay ? newSlot : existingSlot; + const timedSlot = newSlot.allDay ? existingSlot : newSlot; + + const allDayDate = new Date(allDaySlot.startTime!); + allDayDate.setHours(0, 0, 0, 0); + + const timedStart = new Date(timedSlot.startTime!); + const timedDate = new Date(timedStart); + timedDate.setHours(0, 0, 0, 0); + + if (allDayDate.getTime() === timedDate.getTime()) { + return { hasConflict: true, conflictingEvent: eventTransformer(event) }; + } + } + // Both have specific times - check for time overlap + else { + const newStart = new Date(newSlot.startTime).getTime(); + const newEnd = new Date(newSlot.endTime).getTime(); + const existingStart = new Date(existingSlot.startTime).getTime(); + const existingEnd = new Date(existingSlot.endTime).getTime(); + + const timeOverlap = newStart < existingEnd && newEnd > existingStart; + + if (timeOverlap) { + return { + hasConflict: true, + conflictingEvent: eventTransformer(event) + }; + } + } + } + } + } + + return { hasConflict: false }; +} + +/** + * This function removes any deleted documents and adds any new documents + * @param documents the new list of documents to compare against the old ones + * @param currentDocuments the current list of documents on the event that's being edited + */ +export const removeDeletedEventDocuments = async ( + newDocuments: EventDocumentCreateArgs[], + currentDocuments: Document[], + submitter: User +) => { + if (currentDocuments.length === 0) return; + const deletedDocuments = currentDocuments.filter( + (currentDocument) => !newDocuments.find((document) => document.googleFileId === currentDocument.googleFileId) + ); + + //mark any deleted documents as deleted in the database + await prisma.document.updateMany({ + where: { documentId: { in: deletedDocuments.map((document) => document.documentId) } }, + data: { + dateDeleted: new Date(), + deletedByUserId: submitter.userId + } + }); +}; + +/** + * Finds all schedule slots that have the same time-of-day pattern as the original slot. + * This is used for "edit all in series" functionality where we want to update all slots + * that were created with the same recurring time pattern. + * + * @param allSlots All schedule slots for an event + * @param originalSlotId The ID of the slot being edited + * @returns Array of schedule slots that match the original slot's time-of-day pattern + */ +export const findMatchingTimeOfDaySlots = ( + allSlots: T[], + originalSlotId: string +): T[] => { + const originalSlot = allSlots.find((s) => s.scheduleSlotId === originalSlotId); + + if (!originalSlot || !originalSlot.startTime || !originalSlot.endTime) { + // If we can't find the original slot or it has no times, return just that slot + const slot = allSlots.find((s) => s.scheduleSlotId === originalSlotId); + return slot ? [slot] : []; + } + + const originalStartHour = originalSlot.startTime.getHours(); + const originalStartMinute = originalSlot.startTime.getMinutes(); + const originalEndHour = originalSlot.endTime.getHours(); + const originalEndMinute = originalSlot.endTime.getMinutes(); + + // Find all slots that have the same time-of-day + return allSlots.filter((slot) => { + if (!slot.startTime || !slot.endTime) return false; + return ( + slot.startTime.getHours() === originalStartHour && + slot.startTime.getMinutes() === originalStartMinute && + slot.endTime.getHours() === originalEndHour && + slot.endTime.getMinutes() === originalEndMinute + ); + }); +}; diff --git a/src/backend/src/utils/design-reviews.utils.ts b/src/backend/src/utils/design-reviews.utils.ts index fb06727592..a73c5c6cd4 100644 --- a/src/backend/src/utils/design-reviews.utils.ts +++ b/src/backend/src/utils/design-reviews.utils.ts @@ -1,12 +1,6 @@ -import { DesignReview } from 'shared'; -import { HttpException } from './errors.utils.js'; -import { User } from 'shared'; +/* +import { HttpException } from './errors.utils'; -/** - * Validate meeting times - * @param nums the meeting times - * @returns the meeting times - */ export const validateMeetingTimes = (nums: number[]): number[] => { if (nums.length === 0) { throw new HttpException(400, 'There must be at least one meeting time'); @@ -23,12 +17,6 @@ export const validateMeetingTimes = (nums: number[]): number[] => { return nums; }; -export const isUserOnDesignReview = (user: User, designReview: DesignReview): boolean => { - const requiredMembers = designReview.requiredMembers.map((user) => user.userId); - const optionalMembers = designReview.optionalMembers.map((user) => user.userId); - return requiredMembers.includes(user.userId) || optionalMembers.includes(user.userId); -}; - export const transformStartTime = (times: number[]) => { return (times[0] % 12) + 10; }; @@ -38,3 +26,4 @@ export const addHours = (date: Date, hours: number) => { date.setTime(date.getTime() + hoursToAdd); return date; }; +*/ diff --git a/src/backend/src/utils/errors.utils.ts b/src/backend/src/utils/errors.utils.ts index 7701714be9..32e8716986 100644 --- a/src/backend/src/utils/errors.utils.ts +++ b/src/backend/src/utils/errors.utils.ts @@ -91,6 +91,16 @@ export class AccessDeniedGuestException extends AccessDeniedException { } } +export class InvalidEventTypeConfigurationException extends HttpException { + /** + * Constructs an invalid event type configuration error + * @param field the name of the required field that is missing + */ + constructor(field: string) { + super(400, `Event Type requires ${field}`); + } +} + /* * Error handling middleware. Takes the error and sends back the status of it and the message */ @@ -191,8 +201,14 @@ export type ExceptionObjectNames = | 'Graph Collection' | 'Sponsor' | 'SponsorTask' + | 'Shop' + | 'Machinery' | 'Sponsor Tier' | 'Index Code' | 'Reimbursement Product Other Reason' | 'Encryption Key' - | 'Reimbursement Request Comment'; + | 'Reimbursement Request Comment' + | 'Calendar' + | 'Event Type' + | 'Event' + | 'Schedule Slot'; diff --git a/src/backend/src/utils/google-integration.utils.ts b/src/backend/src/utils/google-integration.utils.ts index 8586fdac70..cc6071f375 100644 --- a/src/backend/src/utils/google-integration.utils.ts +++ b/src/backend/src/utils/google-integration.utils.ts @@ -4,9 +4,8 @@ import SMTPTransport from 'nodemailer/lib/smtp-transport/index.js'; import { HttpException } from './errors.utils.js'; import stream, { Readable } from 'stream'; import concat from 'concat-stream'; -import { User, WBS_Element } from '@prisma/client'; -import { transformDate } from './datetime.utils.js'; -import { transformStartTime } from './design-reviews.utils.js'; +import { User } from 'shared'; +import { Schedule_Slot } from '@prisma/client'; import { getUsers } from './users.utils.js'; const { OAuth2 } = google.auth; @@ -192,121 +191,162 @@ export const createCalendar = async (name: string) => { }; /** - * Creates A Google Calendar Event on the NER Google Calendar - * @param members required and optional members + * Creates Google Calendar Events on the NER Google Calendar for all schedule slot occurrences * @param calendarId the id of the calendar to add the event - * @param dateScheduled - * @param isInPerson - * @param zoomLink - * @param location - * @param meetingTimes - * @param wbsElement - * @returns the id of the calendar event + * @param memberIds required and optional members + * @param scheduledSlots the scheduled time slots for the event + * @param isInPerson whether the event is in person + * @param zoomLink zoom link if online + * @param location physical location if in person + * @param eventTitle the title of the event + * @returns an array of calendar event ids */ export const createCalendarEvent = async ( calendarId: string, memberIds: string[], - dateScheduled: Date, + scheduledSlots: Schedule_Slot[], isInPerson: boolean, zoomLink: string | null, location: string | null, - meetingTimes: number[], - wbsElement: WBS_Element + eventTitle: string ) => { - if (process.env.NODE_ENV !== 'production') return; + if (process.env.NODE_ENV !== 'production') return []; + + if (scheduledSlots.length === 0) { + throw new Error('Event must have at least one schedule slot'); + } + try { oauth2Client.setCredentials({ refresh_token: CALENDAR_REFRESH_TOKEN }); const calendar = google.calendar({ version: 'v3', auth: oauth2Client }); - const startTime = transformStartTime(meetingTimes); - const eventInput = { - location: isInPerson ? location : zoomLink, - summary: `Design Review - ${wbsElement.projectNumber} ${wbsElement.name}`, - start: { - dateTime: `${transformDate(new Date(dateScheduled))}T${startTime}:00:00-04:00`, - timeZone: 'America/New_York' - }, - end: { - dateTime: `${transformDate(new Date(dateScheduled))}T${startTime + 1}:00:00-04:00`, - timeZone: 'America/New_York' - }, - attendees: (await getUsers(memberIds)).map((user) => { - return { email: user.email }; - }), - reminders: { - useDefault: false, - overrides: [ - { method: 'email', minutes: 24 * 60 }, - { method: 'popup', minutes: 10 } - ] - } - }; - const calendarEvent = await calendar.events.insert({ - calendarId, - requestBody: eventInput - }); + const attendees = (await getUsers(memberIds)).map((user) => ({ + email: user.email + })); - return calendarEvent.data.id; + const calendarEventIds: string[] = []; + + for (const slot of scheduledSlots) { + const eventInput = { + location: isInPerson ? location : zoomLink, + summary: eventTitle, + start: slot.allDay + ? { date: slot.startTime ? slot.startTime.toISOString().split('T')[0] : undefined } + : { + dateTime: slot.startTime ? slot.startTime.toISOString() : undefined, + timeZone: 'America/New_York' + }, + end: slot.allDay + ? { date: slot.endTime ? slot.endTime.toISOString().split('T')[0] : undefined } + : { + dateTime: slot.endTime ? slot.endTime.toISOString() : undefined, + timeZone: 'America/New_York' + }, + attendees, + reminders: { + useDefault: false, + overrides: [ + { method: 'email', minutes: 24 * 60 }, + { method: 'popup', minutes: 10 } + ] + } + }; + + const calendarEvent = await calendar.events.insert({ + calendarId, + requestBody: eventInput + }); + + if (calendarEvent.data.id) { + calendarEventIds.push(calendarEvent.data.id); + } + } + + return calendarEventIds; } catch (error: unknown) { throw error; } }; /** - * Updates a Google Calendar Event + * Updates Google Calendar Events by deleting old ones and creating new ones * @param calendarId Id of the calendar the event is on - * @param eventId Id of the calendar event - * @param members required and optional members - * @param designReview - * @returns the id of the updated calendar event + * @param oldEventIds Array of old calendar event ids to delete + * @param memberIds required and optional members + * @param scheduledSlots the scheduled time slots for the event + * @param isInPerson whether the event is in person + * @param zoomLink zoom link if online + * @param location physical location if in person + * @param eventTitle the title of the event + * @returns an array of new calendar event ids */ export const updateCalendarEvent = async ( calendarId: string, - eventId: string, + oldEventIds: string[], memberIds: string[], - dateScheduled: Date, + scheduledSlots: Schedule_Slot[], isInPerson: boolean, zoomLink: string | null, location: string | null, - meetingTimes: number[], - wbsElement: WBS_Element + eventTitle: string ) => { + if (scheduledSlots.length === 0) { + throw new Error('Event must have at least one schedule slot'); + } + try { oauth2Client.setCredentials({ refresh_token: CALENDAR_REFRESH_TOKEN }); const calendar = google.calendar({ version: 'v3', auth: oauth2Client }); - const startTime = transformStartTime(meetingTimes); - const eventInput = { - location: isInPerson ? location : zoomLink, - summary: `Design Review - ${wbsElement.projectNumber} ${wbsElement.name}`, - start: { - dateTime: `${transformDate(dateScheduled)}T${startTime}:00:00-04:00`, - timeZone: 'America/New_York' - }, - end: { - dateTime: `${transformDate(dateScheduled)}T${startTime + 1}:00:00-04:00`, - timeZone: 'America/New_York' - }, - attendees: (await getUsers(memberIds)).map((user) => { - return { email: user.email }; - }), - reminders: { - useDefault: false, - overrides: [ - { method: 'email', minutes: 24 * 60 }, - { method: 'popup', minutes: 10 } - ] + + // Delete old calendar events + for (const eventId of oldEventIds) { + try { + await calendar.events.delete({ + calendarId, + eventId + }); + } catch (error) { + console.error(`Failed to delete calendar event ${eventId}:`, error); } - }; - const calendarEvent = await calendar.events.update({ - calendarId, - eventId, - requestBody: eventInput + } + + // Create new calendar events + return await createCalendarEvent(calendarId, memberIds, scheduledSlots, isInPerson, zoomLink, location, eventTitle); + } catch (error: unknown) { + throw error; + } +}; + +/** + * Deletes multiple Google Calendar Events + * @param calendarId Id of the calendar the events are on + * @param eventIds Array of calendar event IDs to delete + */ +export const deleteCalendarEvents = async (calendarId: string, eventIds: string[]) => { + if (process.env.NODE_ENV !== 'production') return; + + try { + oauth2Client.setCredentials({ + refresh_token: CALENDAR_REFRESH_TOKEN }); - return calendarEvent.data.id; + const calendar = google.calendar({ version: 'v3', auth: oauth2Client }); + + await Promise.all( + eventIds.map(async (eventId) => { + try { + await calendar.events.delete({ + calendarId, + eventId + }); + } catch (error) { + console.error(`Failed to delete calendar event ${eventId}:`, error); + } + }) + ); } catch (error: unknown) { throw error; } diff --git a/src/backend/src/utils/notifications.utils.ts b/src/backend/src/utils/notifications.utils.ts index 9e15f1501f..d5a111532c 100644 --- a/src/backend/src/utils/notifications.utils.ts +++ b/src/backend/src/utils/notifications.utils.ts @@ -1,12 +1,19 @@ -import { Task as Prisma_Task, WBS_Element, Design_Review } from '@prisma/client'; +import { Task as Prisma_Task, WBS_Element, Event, Work_Package } from '@prisma/client'; import { UserWithSettings } from './auth.utils.js'; +import { ScheduleSlot } from 'shared'; export type TaskWithAssignees = Prisma_Task & { assignees: UserWithSettings[] | null; wbsElement: WBS_Element; }; -export type DesignReviewWithAttendees = Design_Review & { attendees: UserWithSettings[]; wbsElement: WBS_Element }; +export type EventWithAttendees = Event & { + attendees: UserWithSettings[]; + scheduledTimes: ScheduleSlot[]; + workPackages: (Work_Package & { + wbsElement: WBS_Element; + })[]; +}; export const usersToSlackPings = (users: UserWithSettings[]) => { // https://api.slack.com/reference/surfaces/formatting#mentioning-users diff --git a/src/backend/src/utils/pop-up.utils.ts b/src/backend/src/utils/pop-up.utils.ts index 65b4f18bce..cc34a4422f 100644 --- a/src/backend/src/utils/pop-up.utils.ts +++ b/src/backend/src/utils/pop-up.utils.ts @@ -1,23 +1,23 @@ -import { Change_Request, Design_Review } from '@prisma/client'; +import { Change_Request, Event } from '@prisma/client'; import { User } from 'shared'; import { PopUpService } from '../services/pop-up.services.js'; /** - * Sends a pop up that a design review was scheduled - * @param designReview dr that was created - * @param members optional and required members of the dr - * @param submitter the user who created the dr - * @param workPackageName the name of the work package associated witht the dr - * @param organizationId id of the organization of the dr + * Sends a pop up that a design review event was scheduled + * @param event event that was created + * @param members optional and required members of the event + * @param submitter the user who created the event + * @param workPackageName the name of the work package associated witht the event + * @param organizationId id of the organization of the event */ -export const sendDrPopUp = async ( - designReview: Design_Review, +export const sendEventPopUp = async ( + event: Event, members: User[], submitter: User, workPackageName: string, organizationId: string ) => { - const designReviewLink = `/settings/preferences?drId=${designReview.designReviewId}`; + const designReviewEventLink = `/settings/preferences?eventId=${event.eventId}`; const msg = `Design Review for ${workPackageName} is being scheduled by ${submitter.firstName} ${submitter.lastName}`; await PopUpService.sendPopUpToUsers( @@ -25,7 +25,7 @@ export const sendDrPopUp = async ( 'calendar_month', members.map((member) => member.userId), organizationId, - designReviewLink + designReviewEventLink ); }; diff --git a/src/backend/src/utils/slack.utils.ts b/src/backend/src/utils/slack.utils.ts index ea79cc146f..a42cc57633 100644 --- a/src/backend/src/utils/slack.utils.ts +++ b/src/backend/src/utils/slack.utils.ts @@ -4,9 +4,10 @@ import { Task, wbsPipe, calculateEndDate, - meetingStartTimePipe, CreateSponsorTask, - User + User, + Event, + meetingStartTimePipeNumbers } from 'shared'; import { Account_Code, Reimbursement_Product_Other_Reason, Sponsor_Task } from '@prisma/client'; import { @@ -21,10 +22,9 @@ import { import { getUserSlackId, getUserSlackMentionOrName } from './users.utils.js'; import prisma from '../prisma/prisma.js'; import { HttpException } from './errors.utils.js'; -import { Change_Request, Design_Review, Team, WBS_Element } from '@prisma/client'; +import { Change_Request, Team, WBS_Element } from '@prisma/client'; import { UserWithSettings } from './auth.utils.js'; import { usersToSlackPings, userToSlackPing } from './notifications.utils.js'; -import { addHours } from './design-reviews.utils.js'; import { WorkPackageQueryArgs } from '../prisma-query-args/work-packages.query-args.js'; import { Prisma } from '@prisma/client'; import { userTransformer } from '../transformers/user.transformer.js'; @@ -243,18 +243,18 @@ export const sendPendingSaboSubmissionNotification = async ( ); }; -export const sendSlackDesignReviewConfirmNotification = async ( +export const sendSlackEventConfirmNotification = async ( slackId: string, - designReviewId: string, - designReviewName: string, + eventId: string, + eventName: string, projectName: string ) => { const isProduction = process.env.NODE_ENV === 'production'; if (!isProduction && !DEV_TESTING_OVERRIDE) return; // don't send msgs unless in prod - const msg = `You have been invited to the ${designReviewName} Design Review in project ${projectName}!`; + const msg = `You have been invited to the ${eventName} Design Review in project ${projectName}!`; const fullLink = isProduction - ? `https://finishlinebyner.com/settings/preferences?drId=${designReviewId}` - : `http://localhost:3000/settings/preferences?drId=${designReviewId}`; + ? `https://finishlinebyner.com/settings/preferences?eventId=${eventId}` + : `http://localhost:3000/settings/preferences?eventId=${eventId}`; const linkButtonText = 'Confirm Availability'; try { @@ -331,7 +331,7 @@ export const sendAndGetSlackCRNotifications = async ( return notifications; }; -export const sendSlackDesignReviewNotification = async ( +export const sendSlackEventNotification = async ( team: Team, message: string ): Promise<{ channelId: string; ts: string }[]> => { @@ -346,9 +346,9 @@ export const sendSlackDesignReviewNotification = async ( return msgs; }; -export const sendSlackDRNotifications = async ( +export const sendSlackEventNotifications = async ( teams: Team[], - designReview: Design_Review, + event: Event, submitter: User, workPackageName: string, projectName: string @@ -358,7 +358,7 @@ export const sendSlackDRNotifications = async ( const message = `:spiral_calendar_pad: Design Review for *${workPackageName}* is being scheduled by ${submitter.firstName} ${submitter.lastName} in project ${projectName}`; const completion: Promise[] = teams.map(async (team) => { - const sentNotifications: { channelId: string; ts: string }[] = await sendSlackDesignReviewNotification(team, message); + const sentNotifications: { channelId: string; ts: string }[] = await sendSlackEventNotification(team, message); if (sentNotifications) notifications.push(...sentNotifications); }); await Promise.all(completion); @@ -367,12 +367,12 @@ export const sendSlackDRNotifications = async ( async (notification) => await prisma.message_Info.create({ data: { - designReviewId: designReview.designReviewId, + eventId: event.eventId, channelId: notification.channelId, timestamp: notification.ts }, include: { - designReview: true + event: true } }) ); @@ -381,7 +381,7 @@ export const sendSlackDRNotifications = async ( return notifications; }; -export const sendDRUserConfirmationToThread = async (threads: SlackMessageThread[], submitter: UserWithSettings) => { +export const sendEventUserConfirmationToThread = async (threads: SlackMessageThread[], submitter: UserWithSettings) => { if (process.env.NODE_ENV !== 'production' && !DEV_TESTING_OVERRIDE) return; // don't send msgs unless in prod const slackPing = userToSlackPing(submitter); const fullMsg = `${slackPing} confirmed their availability!`; @@ -397,7 +397,7 @@ export const sendDRUserConfirmationToThread = async (threads: SlackMessageThread } }; -export const sendDRConfirmationToThread = async (threads: SlackMessageThread[], submitter: UserWithSettings) => { +export const sendEventConfirmationToThread = async (threads: SlackMessageThread[], submitter: UserWithSettings) => { if (process.env.NODE_ENV !== 'production' && !DEV_TESTING_OVERRIDE) return; // don't send msgs unless in prod const slackPing = userToSlackPing(submitter); const fullMsg = `${slackPing} All of the required attendees have confirmed their availability!`; @@ -413,27 +413,44 @@ export const sendDRConfirmationToThread = async (threads: SlackMessageThread[], } }; -export const sendDRScheduledSlackNotif = async ( - threads: SlackMessageThread[], - designReview: Design_Review & { wbsElement: WBS_Element; userCreated: User } -) => { +export const sendEventScheduledSlackNotif = async (threads: SlackMessageThread[], event: Event) => { if (process.env.NODE_ENV !== 'production' && !DEV_TESTING_OVERRIDE) return; // don't send msgs unless in prod - const drName = designReview.wbsElement.name; - const { dateScheduled } = designReview; - const drTime = `${addHours(dateScheduled, 12).toLocaleDateString()} at ${meetingStartTimePipe(designReview.meetingTimes)}`; - const drSubmitter = `${designReview.userCreated.firstName} ${designReview.userCreated.lastName}`; - const zoomLink = designReview.isOnline && designReview.zoomLink && `on <${designReview.zoomLink}|Zoom>`; - const location = - zoomLink && designReview.isInPerson - ? `in ${designReview.location} and ${zoomLink}` - : designReview.isInPerson - ? `in ${designReview.location}` - : zoomLink; + // Get work package names + const wpNames = event.workPackages.map((wp) => wp.wbsElement.name).join(', '); + const drName = event.title + (wpNames ? ` (${wpNames})` : ''); + + // Get the first scheduled time + const [firstScheduledTime] = event.scheduledTimes; + if (!firstScheduledTime) { + throw new HttpException(400, 'Event has no scheduled times'); + } + + const dateScheduled = firstScheduledTime.startTime; + + if (!dateScheduled) { + throw new HttpException(400, 'Event scheduled time has no start time'); + } + + // Extract meeting times from scheduled slots + const meetingTimes = event.scheduledTimes + .map((slot) => (slot.startTime ? new Date(slot.startTime).getHours() : null)) + .filter((hour): hour is number => hour !== null) + .sort((a, b) => a - b); + + const drTime = `${dateScheduled.toLocaleDateString()} at ${meetingStartTimePipeNumbers(meetingTimes)}`; + const drSubmitter = `${event.userCreated.firstName} ${event.userCreated.lastName}`; + + // Check for online/in-person location + const zoomLink = event.zoomLink && `on <${event.zoomLink}|Zoom>`; + const inPersonLocation = event.location && `in ${event.location}`; + + const location = zoomLink && inPersonLocation ? `${inPersonLocation} and ${zoomLink}` : inPersonLocation || zoomLink || ''; const msg = `:spiral_calendar_pad: Design Review for *${drName}* has been scheduled for *${drTime}* ${location} by ${drSubmitter}`; - const docLink = designReview.docTemplateLink ? `<${designReview.docTemplateLink}|Doc Link>` : ''; + const docLink = event.questionDocumentLink ? `<${event.questionDocumentLink}|Doc Link>` : ''; const threadMsg = `The Design Review has been Scheduled! \n` + docLink; + try { if (threads && threads.length !== 0) { const msgs = threads.map((thread) => editMessage(thread.channelId, thread.timestamp, msg)); diff --git a/src/backend/src/utils/validation.utils.ts b/src/backend/src/utils/validation.utils.ts index 6931a8e4e9..380c895bd1 100644 --- a/src/backend/src/utils/validation.utils.ts +++ b/src/backend/src/utils/validation.utils.ts @@ -1,7 +1,16 @@ -import { Design_Review_Status, Graph_Display_Type, Graph_Type, Measure, Special_Permission } from '@prisma/client'; +import { Event_Status, Graph_Display_Type, Graph_Type, Measure, Special_Permission } from '@prisma/client'; import { Request, Response } from 'express'; import { body, query, ValidationChain, validationResult } from 'express-validator'; -import { MaterialStatus, TaskPriority, TaskStatus, WorkPackageStage, RoleEnum, WbsElementStatus } from 'shared'; +import { + MaterialStatus, + TaskPriority, + TaskStatus, + WorkPackageStage, + RoleEnum, + WbsElementStatus, + DayOfWeek, + ConflictStatus +} from 'shared'; export const intMinZero = (validationObject: ValidationChain): ValidationChain => { return validationObject.isInt({ min: 0 }).not().isString(); @@ -153,17 +162,32 @@ export const isMaterialStatus = (validationObject: ValidationChain): ValidationC ]); }; -export const isDesignReviewStatus = (validationObject: ValidationChain): ValidationChain => { +export const isEventStatus = (validationObject: ValidationChain): ValidationChain => { + return validationObject + .isString() + .isIn([Event_Status.CONFIRMED, Event_Status.DONE, Event_Status.SCHEDULED, Event_Status.UNCONFIRMED]); +}; + +export const isDayOfWeek = (validationObject: ValidationChain): ValidationChain => { return validationObject .isString() .isIn([ - Design_Review_Status.CONFIRMED, - Design_Review_Status.DONE, - Design_Review_Status.SCHEDULED, - Design_Review_Status.UNCONFIRMED + DayOfWeek.MONDAY, + DayOfWeek.TUESDAY, + DayOfWeek.WEDNESDAY, + DayOfWeek.THURSDAY, + DayOfWeek.FRIDAY, + DayOfWeek.SATURDAY, + DayOfWeek.SUNDAY ]); }; +export const isConflictStatus = (validationObject: ValidationChain): ValidationChain => { + return validationObject + .isString() + .isIn([ConflictStatus.APPROVED, ConflictStatus.PENDING, ConflictStatus.DENIED, ConflictStatus.NO_CONFLICT]); +}; + export const descriptionBulletsValidators = [ body('descriptionBullets').isArray(), nonEmptyString(body('descriptionBullets.*.detail')), @@ -241,3 +265,12 @@ export const financeDashboardFilterValidators = [ nonEmptyString(query('endDate')).optional(), nonEmptyString(query('carNumber')).optional() ]; + +export const requireFile = (chain: ValidationChain): ValidationChain => { + return chain.custom((_value, { req }) => { + if (!req.file) { + throw new Error('Invalid or undefined document data'); + } + return true; + }); +}; diff --git a/src/backend/tests/test-utils.ts b/src/backend/tests/test-utils.ts index a262329c86..85925bd99a 100644 --- a/src/backend/tests/test-utils.ts +++ b/src/backend/tests/test-utils.ts @@ -18,7 +18,7 @@ import prisma from '../src/prisma/prisma.js'; import { dbSeedAllUsers } from '../src/prisma/seed-data/users.seed.js'; import TeamsService from '../src/services/teams.services.js'; import ReimbursementRequestService from '../src/services/reimbursement-requests.services.js'; -import { Permission, RoleEnum, TaskPriority, TaskStatus } from 'shared'; +import { DayOfWeek, Permission, RoleEnum, TaskPriority, TaskStatus } from 'shared'; import { batmanAppAdmin, batmanScheduleSettings, @@ -30,10 +30,10 @@ import { getProjectTemplateQueryArgs, getWorkPackageTemplateQueryArgs } from '../src/prisma-query-args/wbs-element-template.query-args.js'; -import DesignReviewsService from '../src/services/design-reviews.services.js'; import TasksService from '../src/services/tasks.services.js'; import ProjectsService from '../src/services/projects.services.js'; import { SlackMessage } from '../src/services/slack.services.js'; +import CalendarService from '../src/services/calendar.services.js'; export interface CreateTestUserParams { firstName: string; @@ -147,10 +147,10 @@ export const resetUsers = async () => { await prisma.wBS_Element_Template.deleteMany(); await prisma.user_Settings.deleteMany(); await prisma.session.deleteMany(); + await prisma.availability.deleteMany(); await prisma.user_Secure_Settings.deleteMany(); await prisma.schedule_Settings.deleteMany(); await prisma.role.deleteMany(); - await prisma.design_Review.deleteMany(); await prisma.team_Type.deleteMany(); await prisma.wBS_Element.deleteMany(); await prisma.milestone.deleteMany(); @@ -167,6 +167,12 @@ export const resetUsers = async () => { await prisma.account_Code.deleteMany(); await prisma.refund_Source.deleteMany(); await prisma.index_Code.deleteMany(); + await prisma.event.deleteMany(); + await prisma.event_Type.deleteMany(); + await prisma.calendar.deleteMany(); + await prisma.shop_Machinery.deleteMany(); + await prisma.machinery.deleteMany(); + await prisma.shop.deleteMany(); await prisma.organization.deleteMany(); await prisma.user.deleteMany(); }; @@ -538,7 +544,7 @@ export const createTestReimbursementRequest = async () => { }; // Always creates a new design review -export const createTestDesignReview = async () => { +export const createTestDesignReviewEvent = async () => { const organization = await createTestOrganization(); const head = await createTestUser( { ...batmanAppAdmin, googleAuthId: 'financeHead', role: RoleEnum.APP_ADMIN }, @@ -559,34 +565,87 @@ export const createTestDesignReview = async () => { const teamType = await TeamsService.createTeamType(head, 'Team1', 'Software', 'Software team', organization); - const { designReviewId } = await DesignReviewsService.createDesignReview( + const designReviewEventType = await CalendarService.createEventType( lead, - '03/25/2027', - teamType.teamTypeId, - [lead.userId], - [], - { - carNumber: 0, - projectNumber: 0, - workPackageNumber: 0 - }, - [0, 1], - organization + 'Design Review', + [], // No calendar IDs for now + organization, + true, // schedule + true, // requiredMembers + true, // optionalMembers + false, // teams + true, // team type + true, // location + true, // zoomLink + false, // shop + false, // machinery + true, // workPackage + true, // questionDocument + true, // documents + false, // description + true, // onlyHeadsOrAbove + true // requiresConfirmation + ); + + const testWorkPackage = await prisma.work_Package.findFirst({ + where: { + wbsElement: { + carNumber: 1, + projectNumber: 1, + workPackageNumber: 1, + organizationId: organization.organizationId + } + } + }); + + if (!testWorkPackage) { + throw new Error('Test work package not found'); + } + + const { eventId } = await CalendarService.createEvent( + lead, + 'Design Review - Impact Attenuator', + designReviewEventType.eventTypeId, + organization, + [lead.userId], // requiredMemberIds + [], // optionalMemberIds + [], // teamIds + [], // shopIds + [], // machineryIds + [testWorkPackage.workPackageId], // workPackageIds + [ + { + startTime: new Date('2027-03-25T10:00:00'), + endTime: new Date('2027-03-25T11:00:00'), + allDay: false + }, + { + startTime: new Date('2027-03-25T11:00:00'), + endTime: new Date('2027-03-25T12:00:00'), + allDay: false + } + ], // scheduleSlot - two 1-hour time slots + undefined, + teamType.teamTypeId, // team type id + 'https://docs.google.com/document/d/test-design-review-questions', // questionDocument + 'Campus Center Room 101', // location + 'https://zoom.us/j/123456789', // zoomLink + undefined // description ); - const dr = await prisma.design_Review.findUnique({ + const event = await prisma.event.findUnique({ where: { - designReviewId + eventId }, include: { userCreated: true } }); - if (!dr) throw new Error('Failed to create design review'); + if (!event) throw new Error('Failed to create design review'); const orgId = organization.organizationId; - return { dr, organization, orgId }; + return { event, organization, orgId }; }; export const createTestTeamType = async (name: string = 'aTeam', organizationId?: string) => { diff --git a/src/backend/tests/unit/calendar.test.ts b/src/backend/tests/unit/calendar.test.ts new file mode 100644 index 0000000000..92d124a05e --- /dev/null +++ b/src/backend/tests/unit/calendar.test.ts @@ -0,0 +1,2047 @@ +import { Calendar, Conflict_Status, Event_Status, Organization, User } from '@prisma/client'; +import CalendarService from '../../src/services/calendar.services'; +import { + AccessDeniedAdminOnlyException, + AccessDeniedException, + DeletedException, + NotFoundException, + InvalidOrganizationException +} from '../../src/utils/errors.utils'; +import { batmanAppAdmin, wonderwomanGuest, supermanAdmin, theVisitorGuest, alfred } from '../test-data/users.test-data'; +import { createTestOrganization, createTestUser, resetUsers } from '../test-utils'; +import prisma from '../../src/prisma/prisma'; +import { EventType, Machinery, ScheduleSlotCreateArgs, Shop, Event } from 'shared'; + +describe('Calendar Tests', () => { + let orgId: string; + let organization: Organization; + let adminUser: User; + let calendar: Calendar; + let shop: Shop; + let machinery: Machinery; + let shopId: string; + let eventType: EventType; + + beforeEach(async () => { + organization = await createTestOrganization(); + orgId = organization.organizationId; + adminUser = await createTestUser(batmanAppAdmin, orgId); + + calendar = await prisma.calendar.create({ + data: { + name: 'Engineering Team Calendar', + description: 'Tracks all engineering team events, meetings, and deadlines.', + colorHexCode: '#3498db', + userCreated: { connect: { userId: adminUser.userId } }, + dateCreated: new Date(), + organization: { connect: { organizationId: organization.organizationId } } + } + }); + + shop = await CalendarService.createShop( + adminUser, + 'Precision Manufacturing Lab', + 'Manufacturing facility equipped with advanced machinery and tools for engineering', + organization + ); + ({ shopId } = shop); + + const createdMachinery = await CalendarService.createMachinery(adminUser, 'Original Machinery Name', organization); + machinery = await CalendarService.addMachineryToShop( + adminUser, + createdMachinery.machineryId, + shop.shopId, + 1, + organization + ); + eventType = await CalendarService.createEventType( + adminUser, + 'Team Meeting', + [calendar.calendarId], + organization, + true, + true, + true, + false, + true, + true, + true, + true, + false, + true, + true, + true, + true, + true, + true + ); + }); + + afterEach(async () => { + await resetUsers(); + }); + + describe('edit calendar', () => { + it('fails if user is not an admin', async () => { + const member = await createTestUser(wonderwomanGuest, orgId); + + const calendar = await prisma.calendar.create({ + data: { + name: 'Test Calendar', + description: 'Test', + colorHexCode: '#000000', + userCreatedId: member.userId, + organizationId: orgId + } + }); + + await expect( + CalendarService.editCalendar( + member, + calendar.calendarId, + 'Updated Name', + 'Updated Description', + '#FF0000', + organization + ) + ).rejects.toThrow(new AccessDeniedException('Only admins can edit calendars')); + }); + + it('succeeds for admin', async () => { + const calendar = await prisma.calendar.create({ + data: { + name: 'Original Calendar', + description: 'Original Description', + colorHexCode: '#00FF00', + userCreatedId: adminUser.userId, + organizationId: orgId + } + }); + + const result = await CalendarService.editCalendar( + adminUser, + calendar.calendarId, + 'Updated Calendar', + 'Updated Description', + '#0000FF', + organization + ); + + expect(result.calendarId).toBe(calendar.calendarId); + expect(result.name).toBe('Updated Calendar'); + expect(result.description).toBe('Updated Description'); + expect(result.color).toBe('#0000FF'); + }); + + it('fails if calendar not found', async () => { + await expect( + CalendarService.editCalendar( + adminUser, + 'non-existent-id', + 'Updated Name', + 'Updated Description', + '#FF0000', + organization + ) + ).rejects.toThrow(new NotFoundException('Calendar', 'non-existent-id')); + }); + + it('fails if calendar already deleted', async () => { + const calendar = await prisma.calendar.create({ + data: { + name: 'Already Deleted', + description: 'Test', + colorHexCode: '#0000FF', + userCreatedId: adminUser.userId, + organizationId: orgId, + dateDeleted: new Date() + } + }); + + await expect( + CalendarService.editCalendar( + adminUser, + calendar.calendarId, + 'Updated Name', + 'Updated Description', + '#FF0000', + organization + ) + ).rejects.toThrow(new DeletedException('Calendar', calendar.calendarId)); + }); + }); + + describe('delete calendar', () => { + it('fails if user is not an admin', async () => { + const member = await createTestUser(wonderwomanGuest, orgId); + + const calendar = await prisma.calendar.create({ + data: { + name: 'Test Calendar', + description: 'Test', + colorHexCode: '#000000', + userCreatedId: member.userId, + organizationId: orgId + } + }); + + await expect(CalendarService.deleteCalendar(member, calendar.calendarId, organization)).rejects.toThrow( + new AccessDeniedException('Only admins can delete calendars') + ); + }); + + it('succeeds for admin', async () => { + const calendar = await prisma.calendar.create({ + data: { + name: 'Admin Delete Calendar', + description: 'Test', + colorHexCode: '#00FF00', + userCreatedId: adminUser.userId, + organizationId: orgId + } + }); + + const result = await CalendarService.deleteCalendar(adminUser, calendar.calendarId, organization); + + expect(result.calendarId).toBe(calendar.calendarId); + expect(result.name).toBe('Admin Delete Calendar'); + expect(result.description).toBe('Test'); + expect(result.color).toBe('#00FF00'); + expect(result.userCreated.userId).toBe(adminUser.userId); + }); + + it('fails if calendar not found', async () => { + await expect(CalendarService.deleteCalendar(adminUser, 'non-existent-id', organization)).rejects.toThrow( + new NotFoundException('Calendar', 'non-existent-id') + ); + }); + + it('fails if calendar already deleted', async () => { + const calendar = await prisma.calendar.create({ + data: { + name: 'Already Deleted', + description: 'Test', + colorHexCode: '#0000FF', + userCreatedId: adminUser.userId, + organizationId: orgId, + dateDeleted: new Date() // Already deleted + } + }); + + await expect(CalendarService.deleteCalendar(adminUser, calendar.calendarId, organization)).rejects.toThrow( + new DeletedException('Calendar', calendar.calendarId) + ); + }); + }); + + describe('Create EventType', () => { + it('Fails if user is not an admin', async () => { + await expect( + async () => + await CalendarService.createEventType( + await createTestUser(theVisitorGuest, orgId), + 'Team Meeting', + [calendar.calendarId], + organization, + true, + true, + true, + false, + false, + true, + false, + false, + false, + true, + true, + false, + true, + false, + false + ) + ).rejects.toThrow(new AccessDeniedAdminOnlyException('create event type')); + }); + + it('Succeeds and creates an event type', async () => { + const result = await CalendarService.createEventType( + await createTestUser(supermanAdmin, orgId), + 'Meeting', + [], + organization, + true, + true, + true, + false, + false, + true, + true, + false, + false, + false, + false, + false, + true, + false, + false + ); + + expect(result.name).toEqual('Meeting'); + expect(result.requiredMembers).toBe(true); + expect(result.optionalMembers).toBe(true); + expect(result.teams).toBe(true); + expect(result.teamType).toBe(false); + expect(result.location).toBe(false); + expect(result.zoomLink).toBe(true); + expect(result.shop).toBe(true); + expect(result.machinery).toBe(false); + expect(result.workPackage).toBe(false); + expect(result.questionDocument).toBe(false); + expect(result.documents).toBe(false); + expect(result.description).toBe(false); + expect(result.onlyHeadsOrAboveForEventCreation).toBe(true); + expect(result.requiresConfirmation).toBe(false); + expect(result.sendSlackNotifications).toBe(false); + }); + }); + + describe('Create Machinery', () => { + it('Fails if user is not an admin', async () => { + await expect( + async () => + await CalendarService.createMachinery( + await createTestUser(wonderwomanGuest, orgId), + 'Captain America Shield Press', + organization + ) + ).rejects.toThrow(new AccessDeniedAdminOnlyException('create machinery')); + }); + + it('Succeeds and creates machinery', async () => { + const createdMachinery = await CalendarService.createMachinery(adminUser, 'Iron Man Mark 42 CNC Mill', organization); + const result = await CalendarService.addMachineryToShop( + adminUser, + createdMachinery.machineryId, + shop.shopId, + 2, + organization + ); + + expect(result.name).toEqual('Iron Man Mark 42 CNC Mill'); + expect(result.shops).toHaveLength(1); + expect(result.shops[0].quantity).toBe(2); + expect(result.shops[0].shop.name).toBe('Precision Manufacturing Lab'); + expect(result.shops[0].description).toBe(undefined); + }); + }); + + describe('Edit Machinery', () => { + it('Fails if user is not a head or above', async () => { + await expect( + async () => + await CalendarService.editMachinery( + await createTestUser(wonderwomanGuest, orgId), + machinery.machineryId, + 'Updated Machinery Name', + organization + ) + ).rejects.toThrow(new AccessDeniedException('Only heads and above can edit machinery')); + }); + + it('Fails if machinery does not exist', async () => { + const nonExistentId = 'non-existent-id'; + await expect( + async () => await CalendarService.editMachinery(adminUser, nonExistentId, 'Updated Machinery Name', organization) + ).rejects.toThrow(new NotFoundException('Machinery', nonExistentId)); + }); + + it('Fails if shop does not exist', async () => { + const nonExistentShopId = 'non-existent-shop-id'; + await expect( + async () => + await CalendarService.addMachineryToShop( + adminUser, + machinery.machineryId, + nonExistentShopId, + 2, + organization, + shop.shopId + ) + ).rejects.toThrow(new NotFoundException('Shop', nonExistentShopId)); + }); + + it('Succeeds and updates machinery for head user', async () => { + await CalendarService.editMachinery(adminUser, machinery.machineryId, 'Updated Machinery Name', organization); + const result = await CalendarService.addMachineryToShop( + adminUser, + machinery.machineryId, + shop.shopId, + 3, + organization, + shop.shopId + ); + + expect(result.name).toEqual('Updated Machinery Name'); + expect(result.shops).toHaveLength(1); + expect(result.shops[0].quantity).toBe(3); + expect(result.shops[0].shop.name).toBe('Precision Manufacturing Lab'); + }); + + it('Succeeds and updates machinery for admin user', async () => { + await CalendarService.editMachinery(adminUser, machinery.machineryId, 'Admin Updated Machinery', organization); + const result = await CalendarService.addMachineryToShop( + adminUser, + machinery.machineryId, + shop.shopId, + 5, + organization, + shop.shopId + ); + + expect(result.name).toEqual('Admin Updated Machinery'); + expect(result.shops).toHaveLength(1); + expect(result.shops[0].quantity).toBe(5); + expect(result.shops[0].shop.name).toBe('Precision Manufacturing Lab'); + }); + + it('Succeeds and updates machinery without description', async () => { + await CalendarService.editMachinery(adminUser, machinery.machineryId, 'No Description Machinery', organization); + const result = await CalendarService.addMachineryToShop( + adminUser, + machinery.machineryId, + shop.shopId, + 2, + organization, + shop.shopId + ); + + expect(result.name).toEqual('No Description Machinery'); + expect(result.shops).toHaveLength(1); + expect(result.shops[0].quantity).toBe(2); + }); + + it('Succeeds and updates machinery to a different shop', async () => { + // Create a newshop + const newShop = await CalendarService.createShop( + adminUser, + 'Electronics Lab', + 'Electronics testing facility', + organization + ); + + //Check that the machinery original shop is not the new shop before editing + expect(machinery.shops[0].shop.shopId).not.toBe(newShop.shopId); + + // Get the original shop ID to pass to addMachineryToShop + const [originalShopMachinery] = machinery.shops; + const { + shop: { shopId: originalShopId } + } = originalShopMachinery; + + await CalendarService.editMachinery(adminUser, machinery.machineryId, 'Updated Shop to Electronics Lab', organization); + const result = await CalendarService.addMachineryToShop( + adminUser, + machinery.machineryId, + newShop.shopId, + 5, + organization, + originalShopId + ); + + expect(result.name).toEqual('Updated Shop to Electronics Lab'); + expect(result.shops).toHaveLength(1); + expect(result.shops[0].quantity).toBe(5); + expect(result.shops[0].shop.name).toBe('Electronics Lab'); + expect(result.shops[0].shop.shopId).toBe(newShop.shopId); + }); + }); + + describe('Shop Tests', () => { + describe('create shop', () => { + it('fails if user is not an admin', async () => { + await expect( + CalendarService.createShop(await createTestUser(wonderwomanGuest, orgId), 'Non-Admin Shop', 'desc', organization) + ).rejects.toThrow(new AccessDeniedAdminOnlyException('create shop')); + }); + + it('succeeds for admin', async () => { + const admin = await createTestUser(supermanAdmin, orgId); + + const result = await CalendarService.createShop(admin, 'Demo Shop', 'A seeded demo shop', organization); + + expect(result.name).toBe('Demo Shop'); + expect(result.description).toBe('A seeded demo shop'); + expect(result.userCreated.userId).toBe(admin.userId); + }); + + it('fails on duplicate name', async () => { + await CalendarService.createShop(await createTestUser(supermanAdmin, orgId), 'UniqueName', 'first', organization); + + await expect( + CalendarService.createShop(await createTestUser(alfred, orgId), 'UniqueName', 'second attempt', organization) + ).rejects.toBeTruthy(); + }); + }); + }); + + describe('Delete shop', () => { + it('fails if user is not head or above', async () => { + await expect( + CalendarService.deleteShop(await createTestUser(wonderwomanGuest, orgId), shop.shopId, organization) + ).rejects.toBeInstanceOf(AccessDeniedAdminOnlyException); + }); + it('succeeds for admin', async () => { + const result = await CalendarService.deleteShop(adminUser, shop.shopId, organization); + expect(result.shopId).toBe(shop.shopId); + // verify soft delete happened + const row = await prisma.shop.findUnique({ where: { shopId } }); + expect(row?.dateDeleted).not.toBeNull(); + }); + it('fails if shop does not exist', async () => { + await expect(CalendarService.deleteShop(adminUser, 'non-existent-id', organization)).rejects.toBeInstanceOf( + NotFoundException + ); + }); + it('fails if shop is already deleted', async () => { + await CalendarService.deleteShop(adminUser, shop.shopId, organization); + await expect(CalendarService.deleteShop(adminUser, shop.shopId, organization)).rejects.toBeInstanceOf( + NotFoundException + ); + }); + it('also deletes associated shopMachinery bridge rows', async () => { + // create a machinery that links to this shop + const createdMachinery = await CalendarService.createMachinery(adminUser, 'Bridge-Linked', organization); + await CalendarService.addMachineryToShop(adminUser, createdMachinery.machineryId, shop.shopId, 1, organization); + //confirm the bridge row exists before delete + const before = await prisma.shop_Machinery.count({ where: { shopId } }); + expect(before).toBeGreaterThan(0); + // delete shop + await CalendarService.deleteShop(adminUser, shop.shopId, organization); + // the bridge should be cleaned up + const after = await prisma.shop_Machinery.count({ where: { shopId } }); + expect(after).toBe(0); + // the shop should be soft-deleted + const deletedShop = await prisma.shop.findUnique({ where: { shopId } }); + expect(deletedShop?.dateDeleted).not.toBeNull(); + }); + it('fails if shop belongs to a different organization', async () => { + const otherOrg = await prisma.organization.create({ + data: { + name: 'Other Org (calendar test)', + description: 'for cross-org negative case', + applicationLink: '', + userCreated: { connect: { userId: adminUser.userId } } + } + }); + const AdminInOtherOrg = await createTestUser(alfred, otherOrg.organizationId); + await expect(CalendarService.deleteShop(AdminInOtherOrg, shop.shopId, otherOrg)).rejects.toThrow( + new InvalidOrganizationException('Shop') + ); + }); + }); + + describe('Shop: edit', () => { + it('fails if user is not admin', async () => { + const created = await CalendarService.createShop(adminUser, 'Shop A', 'Desc A', organization); + await expect( + CalendarService.editShop( + await createTestUser(wonderwomanGuest, orgId), + created.shopId, + 'New Name', + 'New Desc', + organization + ) + ).rejects.toBeInstanceOf(AccessDeniedAdminOnlyException); + }); + + it('succeeds for admin', async () => { + const created = await CalendarService.createShop(adminUser, 'Shop B', 'Desc B', organization); + const updated = await CalendarService.editShop( + adminUser, + created.shopId, + 'Updated Shop Name', + 'Updated Description', + organization + ); + expect(updated.shopId).toBe(created.shopId); + expect(updated.name).toBe('Updated Shop Name'); + expect(updated.description).toBe('Updated Description'); + expect(updated.userCreated.userId).toBe(created.userCreated.userId); + expect(updated.dateCreated).toBeTruthy(); + }); + + it('fails if shop does not exist', async () => { + await expect( + CalendarService.editShop(adminUser, 'non-existent-id', 'Name', 'Desc', organization) + ).rejects.toBeInstanceOf(NotFoundException); + }); + + it('fails if shop is soft-deleted', async () => { + const created = await CalendarService.createShop(adminUser, 'Shop D', 'Desc D', organization); + await CalendarService.deleteShop(adminUser, created.shopId, organization); + await expect(CalendarService.editShop(adminUser, created.shopId, 'X', 'Y', organization)).rejects.toBeInstanceOf( + DeletedException + ); + }); + }); + + describe('Main Calendar Tests', () => { + describe('create calendar', () => { + it('fails if user is not an admin', async () => { + await expect( + CalendarService.createCalendar( + await createTestUser(wonderwomanGuest, orgId), + 'Non-Admin Calendar', + 'desc', + '#3498DB', + organization + ) + ).rejects.toThrow(new AccessDeniedAdminOnlyException('create calendar')); + }); + it('succeeds for admin', async () => { + const result = await CalendarService.createCalendar( + adminUser, + 'Cool Calendar', + 'A very cool calendar', + '#3498DB', + organization + ); + expect(result.name).toBe('Cool Calendar'); + expect(result.description).toBe('A very cool calendar'); + expect(result.color).toBe('#3498DB'); + expect(result.userCreated.userId).toBe(adminUser.userId); + }); + it('fails on duplicate name', async () => { + await CalendarService.createCalendar(adminUser, 'Cool Calendar', 'A very cool calendar', '#3498DB', organization); + await expect( + CalendarService.createCalendar( + adminUser, + 'Cool Calendar', + 'A very cool calendar, but not quite as cool', + '#0062a3ff', + organization + ) + ).rejects.toBeTruthy(); + }); + }); + }); + describe('Edit EventType', () => { + let eventType: EventType; + + beforeEach(async () => { + eventType = await CalendarService.createEventType( + adminUser, + 'Initial Event Type', + [calendar.calendarId], + organization, + true, + true, + true, + false, + false, + false, + false, + false, + false, + false, + false, + false, + true, + true, + false + ); + }); + + it('fails if user is not an admin', async () => { + const guest = await createTestUser(wonderwomanGuest, orgId); + await expect( + CalendarService.editEventType( + eventType.eventTypeId, + guest, + [calendar.calendarId], + organization, + 'Initial Event Type', + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false + ) + ).rejects.toThrow(new AccessDeniedAdminOnlyException('edit event type')); + }); + + it('fails if any provided calendar does not exist', async () => { + const invalidCalendarId = 'non-existent-calendar-id'; + await expect( + CalendarService.editEventType( + eventType.eventTypeId, + adminUser, + [invalidCalendarId], + organization, + 'Initial Event Type 2', + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true + ) + ).rejects.toThrow(new NotFoundException('Calendar', invalidCalendarId)); + }); + + it('fails if a calendar belongs to a different organization', async () => { + const otherOrg = await prisma.organization.create({ + data: { + name: 'Different Org', + description: 'for invalid org calendar case', + applicationLink: '', + userCreated: { connect: { userId: adminUser.userId } } + } + }); + + const foreignCalendar = await prisma.calendar.create({ + data: { + name: 'Foreign Calendar', + description: 'Calendar from another org', + colorHexCode: '#ff0000', + userCreatedId: adminUser.userId, + organizationId: otherOrg.organizationId + } + }); + + await expect( + CalendarService.editEventType( + eventType.eventTypeId, + adminUser, + [foreignCalendar.calendarId], + organization, + 'Initial Event Type', + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false + ) + ).rejects.toThrow(new InvalidOrganizationException('Calendar')); + }); + + it('fails if event type does not exist', async () => { + const nonExistentId = 'non-existent-event-type-id'; + await expect( + CalendarService.editEventType( + nonExistentId, + adminUser, + [calendar.calendarId], + organization, + 'Non Existent Event Type', + false, + false, + true, + false, + false, + true, + false, + false, + false, + false, + false, + false, + false, + true, + false + ) + ).rejects.toThrow(new NotFoundException('Event Type', nonExistentId)); + }); + + it('succeeds and updates event type fields', async () => { + const result = await CalendarService.editEventType( + eventType.eventTypeId, + adminUser, + [calendar.calendarId], + organization, + 'Initial Event Type 2', + true, + true, + false, + false, + true, + false, + true, + true, + true, + false, + true, + false, + false, + false, + false + ); + + expect(result.name).toBe('Initial Event Type 2'); + expect(result.eventTypeId).toBe(eventType.eventTypeId); + expect(result.requiredMembers).toBe(true); + expect(result.optionalMembers).toBe(true); + expect(result.teams).toBe(false); + expect(result.teamType).toBe(false); + expect(result.location).toBe(true); + expect(result.zoomLink).toBe(false); + expect(result.shop).toBe(true); + expect(result.machinery).toBe(true); + expect(result.workPackage).toBe(true); + expect(result.questionDocument).toBe(false); + expect(result.documents).toBe(true); + expect(result.description).toBe(false); + expect(result.onlyHeadsOrAboveForEventCreation).toBe(false); + expect(result.requiresConfirmation).toBe(false); + expect(result.sendSlackNotifications).toBe(false); + }); + }); + + describe('Create Event', () => { + let member: User; + let otherOrg: Organization; + let otherOrgUser: User; + let otherOrgShop: Shop; + let otherOrgMachinery: Machinery; + + beforeEach(async () => { + member = await createTestUser(supermanAdmin, orgId); + + otherOrg = await prisma.organization.create({ + data: { + name: 'Other Org (calendar test)', + description: 'for cross-org negative case', + applicationLink: '', + userCreated: { connect: { userId: adminUser.userId } } + } + }); + otherOrgUser = await createTestUser(alfred, otherOrg.organizationId); + + // Create additional entities for testing + otherOrgShop = await CalendarService.createShop( + otherOrgUser, + 'Other Org Shop', + 'Shop in different organization', + otherOrg + ); + + const createdOtherOrgMachinery = await CalendarService.createMachinery(otherOrgUser, 'Other Org Machinery', otherOrg); + otherOrgMachinery = await CalendarService.addMachineryToShop( + otherOrgUser, + createdOtherOrgMachinery.machineryId, + otherOrgShop.shopId, + 1, + otherOrg + ); + }); + + it('succeeds for admin with valid inputs', async () => { + const scheduleSlots = [ + { + startTime: new Date('2025-10-13T09:00:00Z'), + endTime: new Date('2025-10-13T10:00:00Z'), + allDay: false + } + ]; + + const result = await CalendarService.createEvent( + adminUser, + 'Team Sync', + eventType.eventTypeId, + organization, + [member.userId], + [adminUser.userId], + [], + [shop.shopId], + [machinery.machineryId], + [], + scheduleSlots, + undefined, + 'https://example.com/questions.pdf', + 'Conference Room A', + 'https://zoom.us/j/123456789', + 'Weekly team synchronization meeting' + ); + + expect(result.title).toBe('Team Sync'); + expect(result.eventTypeId).toBe(eventType.eventTypeId); + expect(result.requiredMembers).toHaveLength(1); + expect(result.requiredMembers[0].userId).toBe(member.userId); + expect(result.optionalMembers).toHaveLength(1); + expect(result.optionalMembers[0].userId).toBe(adminUser.userId); + expect(result.shops).toHaveLength(1); + expect(result.shops[0].shopId).toBe(shop.shopId); + expect(result.machinery).toHaveLength(1); + expect(result.machinery[0].machineryId).toBe(machinery.machineryId); + expect(result.workPackages).toHaveLength(0); + expect(result.scheduledTimes).toHaveLength(1); + expect(result.teamType).toBe(undefined); + expect(result.approved).toBe(Conflict_Status.NO_CONFLICT); + expect(result.approvalRequiredFrom).toBe(undefined); + expect(result.questionDocumentLink).toBe('https://example.com/questions.pdf'); + expect(result.location).toBe('Conference Room A'); + expect(result.zoomLink).toBe('https://zoom.us/j/123456789'); + expect(result.description).toBe('Weekly team synchronization meeting'); + }); + + it('fails if eventTypeId does not exist', async () => { + const scheduleSlots = [ + { + startTime: new Date('2025-10-13T09:00:00Z'), + endTime: new Date('2025-10-13T10:00:00Z'), + allDay: false + } + ]; + + await expect( + CalendarService.createEvent( + adminUser, + 'Invalid Event Type', + 'non-existent-event-type-id', + organization, + [member.userId], + [], + [], + [], + [], + [], + scheduleSlots, + undefined + ) + ).rejects.toThrow(new NotFoundException('Event Type', 'non-existent-event-type-id')); + }); + + it('fails if organization is invalid', async () => { + const scheduleSlots = [ + { + startTime: new Date('2025-10-13T09:00:00Z'), + endTime: new Date('2025-10-13T10:00:00Z'), + allDay: false + } + ]; + + await expect( + CalendarService.createEvent( + adminUser, + 'Wrong Org Event', + eventType.eventTypeId, + otherOrg, + [member.userId], + [], + [], + [shop.shopId], + [], + [], + scheduleSlots, + undefined + ) + ).rejects.toThrow(new InvalidOrganizationException('Event Type')); + }); + + it('succeeds with minimal inputs', async () => { + const scheduleSlots = [ + { + startTime: new Date('2025-10-13T09:00:00Z'), + endTime: new Date('2025-10-13T10:00:00Z'), + allDay: false + } + ]; + + const result = await CalendarService.createEvent( + adminUser, + 'Minimal Event', + eventType.eventTypeId, + organization, + [member.userId], + [adminUser.userId], + [], + [shop.shopId], + [machinery.machineryId], + [], + scheduleSlots, + undefined, + 'https://example.com/questions.pdf', + 'Conference Room A', + 'https://zoom.us/j/123456789', + 'Weekly team synchronization meeting' + ); + + expect(result.title).toBe('Minimal Event'); + expect(result.eventTypeId).toBe(eventType.eventTypeId); + expect(result.requiredMembers).toHaveLength(1); + expect(result.requiredMembers[0].userId).toBe(member.userId); + expect(result.optionalMembers).toHaveLength(1); + expect(result.optionalMembers[0].userId).toBe(adminUser.userId); + expect(result.shops).toHaveLength(1); + expect(result.machinery).toHaveLength(1); + expect(result.workPackages).toHaveLength(0); + expect(result.scheduledTimes).toHaveLength(1); + expect(result.teamType).toBe(undefined); + expect(result.approved).toBe(Conflict_Status.NO_CONFLICT); + expect(result.approvalRequiredFrom).toBeUndefined(); + expect(result.questionDocumentLink).toBe('https://example.com/questions.pdf'); + expect(result.location).toBe('Conference Room A'); + expect(result.zoomLink).toBe('https://zoom.us/j/123456789'); + expect(result.description).toBe('Weekly team synchronization meeting'); + }); + + it('fails if memberIds are invalid', async () => { + const scheduleSlots = [ + { + startTime: new Date('2025-10-13T09:00:00Z'), + endTime: new Date('2025-10-13T10:00:00Z'), + allDay: false + } + ]; + + await expect( + CalendarService.createEvent( + adminUser, + 'Invalid Members', + eventType.eventTypeId, + organization, + ['non-existent-user-id'], + [adminUser.userId], + [], + [shop.shopId], + [machinery.machineryId], + [], + scheduleSlots, + undefined, + 'https://example.com/questions.pdf', + 'Conference Room A', + 'https://zoom.us/j/123456789', + 'Invalid' + ) + ).rejects.toThrow(new NotFoundException('User', 'non-existent-user-id')); + }); + + it('fails if memberIds belong to a different organization', async () => { + const scheduleSlots = [ + { + startTime: new Date('2025-10-13T09:00:00Z'), + endTime: new Date('2025-10-13T10:00:00Z'), + allDay: false + } + ]; + + await expect( + CalendarService.createEvent( + adminUser, + 'Wrong Org Members', + eventType.eventTypeId, + organization, + [otherOrgUser.userId], + [adminUser.userId], + [], + [shop.shopId], + [machinery.machineryId], + [], + scheduleSlots, + undefined, + 'https://example.com/questions.pdf', + 'Conference Room A', + 'https://zoom.us/j/123456789', + 'Invalid' + ) + ).rejects.toThrow(new NotFoundException('User', otherOrgUser.userId)); + }); + + it('fails if shopIds are inputted', async () => { + const scheduleSlots = [ + { + startTime: new Date('2025-10-13T09:00:00Z'), + endTime: new Date('2025-10-13T10:00:00Z'), + allDay: false + } + ]; + + await expect( + CalendarService.createEvent( + adminUser, + 'Invalid Shops', + eventType.eventTypeId, + organization, + [adminUser.userId], + [member.userId], + [], + ['non-existent-shop-id'], + [machinery.machineryId], + [], + scheduleSlots, + undefined, + 'https://example.com/questions.pdf', + 'Conference Room A', + 'https://zoom.us/j/123456789', + 'Invalid' + ) + ).rejects.toThrow(new NotFoundException('Shop', 'non-existent-shop-id')); + }); + + it('fails if shopIds belong to a different organization', async () => { + const scheduleSlots = [ + { + startTime: new Date('2025-10-13T09:00:00Z'), + endTime: new Date('2025-10-13T10:00:00Z'), + allDay: false + } + ]; + + await expect( + CalendarService.createEvent( + adminUser, + 'Wrong Org Shops', + eventType.eventTypeId, + organization, + [adminUser.userId], + [member.userId], + [], + [otherOrgShop.shopId], + [machinery.machineryId], + [], + scheduleSlots, + undefined, + 'https://example.com/questions.pdf', + 'Conference Room A', + 'https://zoom.us/j/123456789', + 'Invalid' + ) + ).rejects.toThrow(new NotFoundException('Shop', otherOrgShop.shopId)); + }); + + it('fails if machineryIds belong to a different organization', async () => { + const scheduleSlots = [ + { + startTime: new Date('2025-10-13T09:00:00Z'), + endTime: new Date('2025-10-13T10:00:00Z'), + allDay: false + } + ]; + + await expect( + CalendarService.createEvent( + adminUser, + 'Wrong Org Machinery', + eventType.eventTypeId, + organization, + [adminUser.userId], + [member.userId], + [], + [shop.shopId], + [otherOrgMachinery.machineryId], + [], + scheduleSlots, + undefined, + 'https://example.com/questions.pdf', + 'Conference Room A', + 'https://zoom.us/j/123456789', + 'Invalid' + ) + ).rejects.toThrow(new NotFoundException('Machinery', otherOrgMachinery.machineryId)); + }); + + it('fails if shopIds are deleted', async () => { + const deletedShop = await CalendarService.createShop(adminUser, 'Deleted Shop', 'Deleted shop', organization); + await prisma.shop.update({ + where: { shopId: deletedShop.shopId }, + data: { dateDeleted: new Date() } + }); + + const scheduleSlots = [ + { + startTime: new Date('2025-10-13T09:00:00Z'), + endTime: new Date('2025-10-13T10:00:00Z'), + allDay: false + } + ]; + + await expect( + CalendarService.createEvent( + adminUser, + 'Deleted Shops', + eventType.eventTypeId, + organization, + [adminUser.userId], + [member.userId], + [], + [deletedShop.shopId], + [machinery.machineryId], + [], + scheduleSlots, + undefined, + 'https://example.com/questions.pdf', + 'Conference Room A', + 'https://zoom.us/j/123456789', + 'Invalid' + ) + ).rejects.toThrow(new NotFoundException('Shop', deletedShop.shopId)); + }); + + it('fails if machineryIds are deleted', async () => { + const createdMachinery = await CalendarService.createMachinery(adminUser, 'Deleted Machinery', organization); + const deletedMachinery = await CalendarService.addMachineryToShop( + adminUser, + createdMachinery.machineryId, + shop.shopId, + 1, + organization + ); + await prisma.machinery.update({ + where: { machineryId: deletedMachinery.machineryId }, + data: { dateDeleted: new Date() } + }); + + const scheduleSlots = [ + { + startTime: new Date('2025-10-13T09:00:00Z'), + endTime: new Date('2025-10-13T10:00:00Z'), + allDay: false + } + ]; + + await expect( + CalendarService.createEvent( + adminUser, + 'Deleted Machinery', + eventType.eventTypeId, + organization, + [adminUser.userId], + [member.userId], + [], + [shop.shopId], + [deletedMachinery.machineryId], + [], + scheduleSlots, + undefined, + 'https://example.com/questions.pdf', + 'Conference Room A', + 'https://zoom.us/j/123456789', + 'Invalid' + ) + ).rejects.toThrow(new NotFoundException('Machinery', deletedMachinery.machineryId)); + }); + }); + + describe('Get Events', () => { + it('Succeeds and gets all events', async () => { + const member = await createTestUser(supermanAdmin, orgId); + + const scheduleSlots = [ + { + startTime: new Date('2025-10-13T09:00:00Z'), + endTime: new Date('2025-10-13T10:00:00Z'), + allDay: false + } + ]; + + const event1 = await CalendarService.createEvent( + adminUser, + 'Team Sync', + eventType.eventTypeId, + organization, + [member.userId], + [adminUser.userId], + [], + [shop.shopId], + [machinery.machineryId], + [], + scheduleSlots, + undefined, + 'https://example.com/questions.pdf', + 'Conference Room A', + 'https://zoom.us/j/123456789', + 'Weekly team synchronization meeting' + ); + + const event2 = await CalendarService.createEvent( + adminUser, + 'Awesome Meeting', + eventType.eventTypeId, + organization, + [member.userId], + [adminUser.userId], + [], + [shop.shopId], + [machinery.machineryId], + [], + scheduleSlots, + undefined, + 'https://example.com/questions.pdf', + 'Conference Room A', + 'https://zoom.us/j/123456789', + 'Weekly team synchronization meeting' + ); + + const result = await CalendarService.getFilteredEvents( + { + startPeriod: new Date('2020-10-01T09:00:00Z'), + endPeriod: new Date('2027-11-01T09:00:00Z'), + memberIds: [member.userId] + }, + organization + ); + expect(result).toStrictEqual([event1, event2]); + }); + + it('Succeeds and gets all events within a timeframe', async () => { + const member = await createTestUser(supermanAdmin, orgId); + + const scheduleSlots = [ + { + startTime: new Date('2025-10-13T09:00:00Z'), + endTime: new Date('2025-10-13T10:00:00Z'), + allDay: false + } + ]; + + const event1 = await CalendarService.createEvent( + adminUser, + 'Team Sync', + eventType.eventTypeId, + organization, + [member.userId], + [adminUser.userId], + [], + [shop.shopId], + [machinery.machineryId], + [], + scheduleSlots, + undefined, + 'https://example.com/questions.pdf', + 'Conference Room A', + 'https://zoom.us/j/123456789', + 'Weekly team synchronization meeting' + ); + + const event2 = await CalendarService.createEvent( + adminUser, + 'Awesome Meeting', + eventType.eventTypeId, + organization, + [member.userId], + [adminUser.userId], + [], + [shop.shopId], + [machinery.machineryId], + [], + scheduleSlots, + undefined, + 'https://example.com/questions.pdf', + 'Conference Room A', + 'https://zoom.us/j/123456789', + 'Weekly team synchronization meeting' + ); + + const scheduleSlots2 = [ + { + startTime: new Date('2029-10-01T09:00:00Z'), + endTime: new Date('2029-10-01T10:00:00Z'), + allDay: false + } + ]; + + // out of timeframe date + await CalendarService.createEvent( + adminUser, + 'Way too far in the future meeting', + eventType.eventTypeId, + organization, + [member.userId], + [adminUser.userId], + [], + [shop.shopId], + [machinery.machineryId], + [], + scheduleSlots2, + undefined, + 'https://example.com/questions.pdf', + 'Conference Room A', + 'https://zoom.us/j/123456789', + 'Weekly team synchronization meeting' + ); + + const result = await CalendarService.getFilteredEvents( + { + startPeriod: new Date('2025-10-01T09:00:00Z'), + endPeriod: new Date('2025-11-01T09:00:00Z'), + memberIds: [member.userId] + }, + organization + ); + expect(result).toStrictEqual([event1, event2]); + }); + + it('Succeeds and gets all events with matching members', async () => { + const member = await createTestUser(supermanAdmin, orgId); + const member2 = await createTestUser(wonderwomanGuest, orgId); + + const scheduleSlots = [ + { + startTime: new Date('2025-10-13T09:00:00Z'), + endTime: new Date('2025-10-13T10:00:00Z'), + allDay: false + } + ]; + + const event1 = await CalendarService.createEvent( + adminUser, + 'Team Sync', + eventType.eventTypeId, + organization, + [adminUser.userId], + [member.userId], + [], + [shop.shopId], + [machinery.machineryId], + [], + scheduleSlots, + undefined, + 'https://example.com/questions.pdf', + 'Conference Room A', + 'https://zoom.us/j/123456789', + 'Weekly team synchronization meeting' + ); + + await CalendarService.createEvent( + adminUser, + 'Awesome Meeting', + eventType.eventTypeId, + organization, + [adminUser.userId], + [member2.userId], + [], + [shop.shopId], + [machinery.machineryId], + [], + scheduleSlots, + undefined, + 'https://example.com/questions.pdf', + 'Conference Room A', + 'https://zoom.us/j/123456789', + 'Weekly team synchronization meeting' + ); + + const scheduleSlots2 = [ + { + startTime: new Date('2029-10-01T09:00:00Z'), + endTime: new Date('2029-10-01T10:00:00Z'), + allDay: false + } + ]; + + // out of timeframe date + await CalendarService.createEvent( + adminUser, + 'Way too far in the future meeting', + eventType.eventTypeId, + organization, + [adminUser.userId], + [member2.userId], + [], + [shop.shopId], + [machinery.machineryId], + [], + scheduleSlots2, + undefined, + 'https://example.com/questions.pdf', + 'Conference Room A', + 'https://zoom.us/j/123456789', + 'Weekly team synchronization meeting' + ); + + const result = await CalendarService.getFilteredEvents( + { + startPeriod: new Date('2020-10-01T09:00:00Z'), + endPeriod: new Date('2027-11-01T09:00:00Z'), + memberIds: [member.userId] + }, + organization + ); + expect(result).toStrictEqual([event1]); + }); + }); + + it('fails if memberIds do not exist', async () => { + await expect( + CalendarService.getFilteredEvents( + { + startPeriod: new Date('2020-10-01T09:00:00Z'), + endPeriod: new Date('2027-11-01T09:00:00Z'), + memberIds: ['fakeId'] + }, + organization + ) + ).rejects.toThrow(new NotFoundException('User', 'fakeId')); + }); + + it('fails if eventTypeIds do not exist', async () => { + await expect( + CalendarService.getFilteredEvents( + { + startPeriod: new Date('2020-10-01T09:00:00Z'), + endPeriod: new Date('2027-11-01T09:00:00Z'), + eventTypeIds: ['fakeId'] + }, + organization + ) + ).rejects.toThrow(new NotFoundException('Event Type', 'fakeId')); + }); + + it('fails if eventIds do not exist', async () => { + await expect( + CalendarService.getFilteredEvents( + { startPeriod: new Date('2020-10-01T09:00:00Z'), endPeriod: new Date('2027-11-01T09:00:00Z'), eventIds: ['fakeId'] }, + organization + ) + ).rejects.toThrow(new NotFoundException('Event', 'fakeId')); + }); + + it('fails if calendarIds do not exist', async () => { + await expect( + CalendarService.getFilteredEvents( + { + startPeriod: new Date('2020-10-01T09:00:00Z'), + endPeriod: new Date('2027-11-01T09:00:00Z'), + calendarIds: ['fakeId'] + }, + organization + ) + ).rejects.toThrow(new NotFoundException('Calendar', 'fakeId')); + }); + + it('fails if teamIds do not exist', async () => { + await expect( + CalendarService.getFilteredEvents( + { startPeriod: new Date('2020-10-01T09:00:00Z'), endPeriod: new Date('2027-11-01T09:00:00Z'), teamIds: ['fakeId'] }, + organization + ) + ).rejects.toThrow(new NotFoundException('Team', 'fakeId')); + }); + + describe('Delete Machinery', () => { + let machineryToDelete: Machinery; + let anotherShop: Shop; + + beforeEach(async () => { + const createdMachinery = await CalendarService.createMachinery(adminUser, 'Deletable Machinery', organization); + machineryToDelete = await CalendarService.addMachineryToShop( + adminUser, + createdMachinery.machineryId, + shop.shopId, + 2, + organization + ); + + anotherShop = await CalendarService.createShop( + adminUser, + 'Secondary Shop', + 'Another shop for deletion test', + organization + ); + + await prisma.shop_Machinery.create({ + data: { + shopId: anotherShop.shopId, + machineryId: machineryToDelete.machineryId, + quantity: 1 + } + }); + }); + + it('fails if user is not an admin', async () => { + const guest = await createTestUser(wonderwomanGuest, orgId); + await expect(CalendarService.deleteMachinery(guest, machineryToDelete.machineryId, organization)).rejects.toThrow( + new AccessDeniedAdminOnlyException('delete machinery') + ); + }); + + it('fails if machinery does not exist', async () => { + await expect(CalendarService.deleteMachinery(adminUser, 'non-existent-id', organization)).rejects.toThrow( + new NotFoundException('Machinery', 'non-existent-id') + ); + }); + + it('fails if machinery is already deleted', async () => { + await prisma.machinery.update({ + where: { machineryId: machineryToDelete.machineryId }, + data: { dateDeleted: new Date() } + }); + + await expect(CalendarService.deleteMachinery(adminUser, machineryToDelete.machineryId, organization)).rejects.toThrow( + new NotFoundException('Machinery', machineryToDelete.machineryId) + ); + }); + + it('succeeds for admin and soft deletes machinery and shopMachinery rows', async () => { + const bridgeBefore = await prisma.shop_Machinery.count({ + where: { machineryId: machineryToDelete.machineryId } + }); + expect(bridgeBefore).toBeGreaterThan(0); + + const deleted = await CalendarService.deleteMachinery(adminUser, machineryToDelete.machineryId, organization); + + const row = await prisma.machinery.findUnique({ where: { machineryId: machineryToDelete.machineryId } }); + expect(row?.dateDeleted).not.toBeNull(); + expect(row?.userDeletedId).toBe(adminUser.userId); + + expect(deleted.machineryId).toBe(machineryToDelete.machineryId); + expect(deleted.name).toBe(machineryToDelete.name); + + const bridgeAfter = await prisma.shop_Machinery.count({ + where: { machineryId: machineryToDelete.machineryId } + }); + expect(bridgeAfter).toBe(0); + }); + }); + + describe('Edit Event', () => { + let event: Event; + let member: User; + let scheduleSlots: ScheduleSlotCreateArgs[]; + + beforeEach(async () => { + member = await createTestUser(supermanAdmin, orgId); + scheduleSlots = [ + { + startTime: new Date('2025-10-13T09:00:00Z'), + endTime: new Date('2025-10-13T10:00:00Z'), + allDay: false + } + ]; + + event = await CalendarService.createEvent( + adminUser, + 'Original Event', + eventType.eventTypeId, + organization, + [member.userId], + [adminUser.userId], + [], + [shop.shopId], + [machinery.machineryId], + [], + scheduleSlots, + undefined, + 'https://example.com/questions.pdf', + 'Conference Room A', + 'https://zoom.us/j/123456789', + 'Weekly team synchronization meeting' + ); + }); + + it('fails if event does not exist', async () => { + await expect( + CalendarService.editEvent( + adminUser, + 'non-existent-id', + 'Updated Title', + organization, + [adminUser.userId], + [member.userId], + Event_Status.UNCONFIRMED, + [], + [], + [], + [], + [] + ) + ).rejects.toThrow(new NotFoundException('Event', 'non-existent-id')); + }); + + it('fails if event is already deleted', async () => { + await prisma.event.update({ + where: { eventId: event.eventId }, + data: { dateDeleted: new Date() } + }); + + await expect( + CalendarService.editEvent( + adminUser, + event.eventId, + 'Updated Title', + organization, + [adminUser.userId], + [member.userId], + Event_Status.UNCONFIRMED, + [], + [], + [], + [], + [] + ) + ).rejects.toThrow(new DeletedException('Event', event.eventId)); + }); + + it('fails if memberIds are invalid', async () => { + await expect( + CalendarService.editEvent( + adminUser, + event.eventId, + 'Updated Title', + organization, + ['non-existent-user-id'], + [adminUser.userId], + Event_Status.UNCONFIRMED, + [], + [shop.shopId], + [machinery.machineryId], + [], + [], + undefined, + 'https://example.com/questions.pdf', + 'Conference Room A', + 'https://zoom.us/j/123456789', + 'Weekly team synchronization meeting' + ) + ).rejects.toThrow(new NotFoundException('User', 'non-existent-user-id')); + }); + + it('fails if shopIds are invalid', async () => { + await expect( + CalendarService.editEvent( + adminUser, + event.eventId, + 'Updated Title', + organization, + [adminUser.userId], + [member.userId], + Event_Status.UNCONFIRMED, + [], + ['non-existent-shop-id'], + [machinery.machineryId], + [], + [], + undefined, + 'https://example.com/questions.pdf', + 'Conference Room A', + 'https://zoom.us/j/123456789', + 'Weekly team synchronization meeting' + ) + ).rejects.toThrow(new NotFoundException('Shop', 'non-existent-shop-id')); + }); + + it('fails if shopIds are deleted', async () => { + const deletedShop = await CalendarService.createShop(adminUser, 'Deleted Shop', 'Deleted', organization); + await prisma.shop.update({ + where: { shopId: deletedShop.shopId }, + data: { dateDeleted: new Date() } + }); + + await expect( + CalendarService.editEvent( + adminUser, + event.eventId, + 'Updated Title', + organization, + [adminUser.userId], + [member.userId], + Event_Status.UNCONFIRMED, + [], + [deletedShop.shopId], + [machinery.machineryId], + [], + [], + undefined, + 'https://example.com/questions.pdf', + 'Conference Room A', + 'https://zoom.us/j/123456789', + 'Weekly team synchronization meeting' + ) + ).rejects.toThrow(new NotFoundException('Shop', deletedShop.shopId)); + }); + + it('fails if machineryIds are invalid', async () => { + await expect( + CalendarService.editEvent( + adminUser, + event.eventId, + 'Updated Title', + organization, + [adminUser.userId], + [member.userId], + Event_Status.UNCONFIRMED, + [], + [shop.shopId], + ['non-existent-machinery-id'], + [], + [], + undefined, + 'https://example.com/questions.pdf', + 'Conference Room A', + 'https://zoom.us/j/123456789', + 'Weekly team synchronization meeting' + ) + ).rejects.toThrow(new NotFoundException('Machinery', 'non-existent-machinery-id')); + }); + + it('fails if machineryIds are deleted', async () => { + const deletedMachinery = await CalendarService.createMachinery(adminUser, 'Deleted Machinery', organization); + await prisma.machinery.update({ + where: { machineryId: deletedMachinery.machineryId }, + data: { dateDeleted: new Date() } + }); + + await expect( + CalendarService.editEvent( + adminUser, + event.eventId, + 'Updated Title', + organization, + [adminUser.userId], + [member.userId], + Event_Status.UNCONFIRMED, + [], + [shop.shopId], + [deletedMachinery.machineryId], + [], + [], + undefined, + 'https://example.com/questions.pdf', + 'Conference Room A', + 'https://zoom.us/j/123456789', + 'Weekly team synchronization meeting' + ) + ).rejects.toThrow(new NotFoundException('Machinery', deletedMachinery.machineryId)); + }); + + it('succeeds for admin and updates event', async () => { + const newMember = await createTestUser(alfred, orgId); + + const result = await CalendarService.editEvent( + adminUser, + event.eventId, + 'Updated Event Title', + organization, + [newMember.userId], + [adminUser.userId], + Event_Status.UNCONFIRMED, + [], + [shop.shopId], + [machinery.machineryId], + [], + [], + undefined, + 'https://updated.com/questions.pdf', + 'Updated Location', + 'https://zoom.us/updated', + 'Updated description' + ); + + expect(result.eventId).toBe(event.eventId); + expect(result.title).toBe('Updated Event Title'); + expect(result.requiredMembers).toHaveLength(1); + expect(result.requiredMembers[0].userId).toBe(newMember.userId); + expect(result.optionalMembers).toHaveLength(1); + expect(result.optionalMembers[0].userId).toBe(adminUser.userId); + expect(result.documents).toEqual([]); + expect(result.approved).toBe(Conflict_Status.NO_CONFLICT); + expect(result.approvalRequiredFrom).toBe(undefined); + expect(result.questionDocumentLink).toBe('https://updated.com/questions.pdf'); + expect(result.location).toBe('Updated Location'); + expect(result.zoomLink).toBe('https://zoom.us/updated'); + expect(result.description).toBe('Updated description'); + }); + }); + + describe('Delete Event', () => { + let event: Event; + let member: User; + + beforeEach(async () => { + member = await createTestUser(wonderwomanGuest, orgId); + const scheduleSlots: ScheduleSlotCreateArgs[] = [ + { + startTime: new Date('2025-10-13T09:00:00Z'), + endTime: new Date('2025-10-13T10:00:00Z'), + allDay: false + } + ]; + + event = await CalendarService.createEvent( + adminUser, + 'Event to Delete', + eventType.eventTypeId, + organization, + [adminUser.userId], + [member.userId], + [], + [shop.shopId], + [machinery.machineryId], + [], + scheduleSlots, + undefined, + 'https://updated.com/questions.pdf', + 'Updated Location', + undefined, + 'Updated description' + ); + }); + + it('fails if user is not an admin', async () => { + await expect(CalendarService.deleteEvent(member, event.eventId, organization)).rejects.toThrow( + new AccessDeniedException('Only admins can delete events!') + ); + }); + + it('fails if event does not exist', async () => { + await expect(CalendarService.deleteEvent(adminUser, 'non-existent-id', organization)).rejects.toThrow( + new NotFoundException('Event', 'non-existent-id') + ); + }); + + it('fails if event is already deleted', async () => { + await prisma.event.update({ + where: { eventId: event.eventId }, + data: { dateDeleted: new Date() } + }); + + await expect(CalendarService.deleteEvent(adminUser, event.eventId, organization)).rejects.toThrow( + new DeletedException('Event', event.eventId) + ); + }); + + it('succeeds for admin and soft deletes event', async () => { + const result = await CalendarService.deleteEvent(adminUser, event.eventId, organization); + + expect(result.eventId).toBe(event.eventId); + expect(result.title).toBe('Event to Delete'); + + const deletedEvent = await prisma.event.findUnique({ + where: { eventId: event.eventId } + }); + expect(deletedEvent?.dateDeleted).not.toBeNull(); + expect(deletedEvent?.userDeletedId).toBe(adminUser.userId); + }); + }); + + describe('Delete EventType', () => { + let eventTypeToDelete: EventType; + + beforeEach(async () => { + eventTypeToDelete = await CalendarService.createEventType( + adminUser, + 'EventType to Delete', + [calendar.calendarId], + organization, + true, + true, + true, + false, + false, + true, + false, + false, + false, + false, + false, + false, + false, + true, + false + ); + }); + + it('fails if user is not an admin', async () => { + const guest = await createTestUser(wonderwomanGuest, orgId); + await expect(CalendarService.deleteEventType(guest, eventTypeToDelete.eventTypeId, organization)).rejects.toThrow( + new AccessDeniedException('Only admins can delete event types!') + ); + }); + + it('fails if event type does not exist', async () => { + await expect(CalendarService.deleteEventType(adminUser, 'non-existent-id', organization)).rejects.toThrow( + new NotFoundException('Event Type', 'non-existent-id') + ); + }); + + it('fails if event type is already deleted', async () => { + await prisma.event_Type.update({ + where: { eventTypeId: eventTypeToDelete.eventTypeId }, + data: { dateDeleted: new Date() } + }); + + await expect(CalendarService.deleteEventType(adminUser, eventTypeToDelete.eventTypeId, organization)).rejects.toThrow( + new DeletedException('Event Type', eventTypeToDelete.eventTypeId) + ); + }); + + it('fails if event type belongs to different organization', async () => { + const otherOrg = await prisma.organization.create({ + data: { + name: 'Other Org (calendar test)', + description: 'for cross-org negative case', + applicationLink: '', + userCreated: { connect: { userId: adminUser.userId } } + } + }); + const AdminInOtherOrg = await createTestUser(alfred, otherOrg.organizationId); + + const otherOrgEventType = await CalendarService.createEventType( + AdminInOtherOrg, + 'Other Org Event Type', + [], + otherOrg, + true, + true, + true, + false, + false, + true, + false, + false, + false, + false, + false, + false, + false, + true, + false + ); + + await expect(CalendarService.deleteEventType(adminUser, otherOrgEventType.eventTypeId, organization)).rejects.toThrow( + new InvalidOrganizationException('Event Type') + ); + }); + + it('succeeds for admin and soft deletes event type', async () => { + const result = await CalendarService.deleteEventType(adminUser, eventTypeToDelete.eventTypeId, organization); + + expect(result.eventTypeId).toBe(eventTypeToDelete.eventTypeId); + expect(result.name).toBe('EventType to Delete'); + + const deletedEventType = await prisma.event_Type.findUnique({ + where: { eventTypeId: eventTypeToDelete.eventTypeId } + }); + expect(deletedEventType?.dateDeleted).not.toBeNull(); + expect(deletedEventType?.userDeletedId).toBe(adminUser.userId); + }); + }); +}); diff --git a/src/backend/tests/unit/design-review.test.ts b/src/backend/tests/unit/design-review.test.ts deleted file mode 100644 index 828cd58df7..0000000000 --- a/src/backend/tests/unit/design-review.test.ts +++ /dev/null @@ -1,117 +0,0 @@ -import { financeMember, supermanAdmin } from '../test-data/users.test-data.js'; -import DesignReviewsService from '../../src/services/design-reviews.services.js'; -import { AccessDeniedAdminOnlyException } from '../../src/utils/errors.utils.js'; -import { createTestDesignReview, createTestUser, resetUsers } from '../test-utils.js'; -import prisma from '../../src/prisma/prisma.js'; -import { getUserQueryArgs } from '../../src/prisma-query-args/user.query-args.js'; -import { DesignReviewStatus } from 'shared'; -import { Design_Review, Organization } from '@prisma/client'; - -describe('Design Reviews', () => { - let designReview: Design_Review; - let organizationId: string; - let organization: Organization; - beforeEach(async () => { - const { dr, organization: org, orgId } = await createTestDesignReview(); - designReview = dr; - organization = org; - organizationId = orgId; - }); - - afterEach(async () => { - await resetUsers(); - }); - - test('Marks design review as confirmed if updated list of required members have confirmed', async () => { - const user = await createTestUser(supermanAdmin, organizationId); - - const origonalDesignreview = await prisma.design_Review.update({ - where: { designReviewId: designReview.designReviewId }, - data: { - requiredMembers: { - connect: [{ userId: user.userId }] - }, - confirmedMembers: { - connect: [{ userId: designReview.userCreatedId }] - } - }, - include: { - requiredMembers: true, - confirmedMembers: true - } - }); - - const requiredMembers = origonalDesignreview.requiredMembers.map((member) => member.userId) || []; - const confirmedMembers = origonalDesignreview.confirmedMembers.map((member) => member.userId) || []; - - expect(requiredMembers.length).toBe(2); - expect(confirmedMembers.length).toBe(1); - - await DesignReviewsService.setStatus(user, designReview.designReviewId, DesignReviewStatus.CONFIRMED, organization); - - const updatedDesignReview = await DesignReviewsService.editDesignReview( - user, - designReview.designReviewId, - new Date(), - origonalDesignreview.teamTypeId, - [designReview.userCreatedId], - [], - false, - false, - null, - null, - '', - DesignReviewStatus.CONFIRMED, - [], - [0, 1], - organization - ); - - expect(updatedDesignReview.status).toBe(DesignReviewStatus.SCHEDULED); - }); - - // change with admin who is not creator - test('Set status works when an admin who is not the creator sets', async () => { - const user = await createTestUser(supermanAdmin, organizationId); - await DesignReviewsService.setStatus(user, designReview.designReviewId, DesignReviewStatus.CONFIRMED, organization); - const updatedDR = await prisma.design_Review.findUnique({ - where: { - designReviewId: designReview.designReviewId - } - }); - // check that status changed to correct status - expect(updatedDR?.status).toBe(DesignReviewStatus.CONFIRMED); - }); - - // Set status works when creator is not admin - test('Set status works when set with creator who is not admin', async () => { - const drCreator = await prisma.user.findUnique({ - where: { - userId: designReview.userCreatedId - }, - ...getUserQueryArgs(organizationId) - }); - if (!drCreator) { - throw new Error('User not found in database'); - } - await DesignReviewsService.setStatus(drCreator, designReview.designReviewId, DesignReviewStatus.CONFIRMED, organization); - const updatedDR = await prisma.design_Review.findUnique({ - where: { - designReviewId: designReview.designReviewId - } - }); - expect(updatedDR?.status).toBe(DesignReviewStatus.CONFIRMED); - }); - - // fails when user is not admin or creator - test('Set status fails when user is not admin or creator', async () => { - await expect(async () => - DesignReviewsService.setStatus( - await createTestUser(financeMember, organizationId), - designReview.designReviewId, - DesignReviewStatus.CONFIRMED, - organization - ) - ).rejects.toThrow(new AccessDeniedAdminOnlyException('set the status of a design review')); - }); -}); diff --git a/src/backend/tests/unmocked/design-review.test.ts b/src/backend/tests/unmocked/design-review.test.ts deleted file mode 100644 index 6da599778f..0000000000 --- a/src/backend/tests/unmocked/design-review.test.ts +++ /dev/null @@ -1,69 +0,0 @@ -import { financeMember, supermanAdmin } from '../test-data/users.test-data.js'; -import DesignReviewsService from '../../src/services/design-reviews.services.js'; -import { AccessDeniedAdminOnlyException } from '../../src/utils/errors.utils.js'; -import { createTestDesignReview, createTestUser, resetUsers } from '../test-utils.js'; -import prisma from '../../src/prisma/prisma.js'; -import { getUserQueryArgs } from '../../src/prisma-query-args/user.query-args.js'; -import { DesignReviewStatus } from 'shared'; -import { Design_Review, Organization } from '@prisma/client'; - -describe('Design Reviews', () => { - let designReview: Design_Review; - let organizationId: string; - let organization: Organization; - beforeEach(async () => { - const { dr, organization: org, orgId } = await createTestDesignReview(); - designReview = dr; - organization = org; - organizationId = orgId; - }); - - afterEach(async () => { - await resetUsers(); - }); - - // change with admin who is not creator - test('Set status works when an admin who is not the creator sets', async () => { - const user = await createTestUser(supermanAdmin, organizationId); - await DesignReviewsService.setStatus(user, designReview.designReviewId, DesignReviewStatus.CONFIRMED, organization); - const updatedDR = await prisma.design_Review.findUnique({ - where: { - designReviewId: designReview.designReviewId - } - }); - // check that status changed to correct status - expect(updatedDR?.status).toBe(DesignReviewStatus.CONFIRMED); - }); - - // Set status works when creator is not admin - test('Set status works when set with creator who is not admin', async () => { - const drCreator = await prisma.user.findUnique({ - where: { - userId: designReview.userCreatedId - }, - ...getUserQueryArgs(organizationId) - }); - if (!drCreator) { - throw new Error('User not found in database'); - } - await DesignReviewsService.setStatus(drCreator, designReview.designReviewId, DesignReviewStatus.CONFIRMED, organization); - const updatedDR = await prisma.design_Review.findUnique({ - where: { - designReviewId: designReview.designReviewId - } - }); - expect(updatedDR?.status).toBe(DesignReviewStatus.CONFIRMED); - }); - - // fails when user is not admin or creator - test('Set status fails when user is not admin or creator', async () => { - await expect(async () => - DesignReviewsService.setStatus( - await createTestUser(financeMember, organizationId), - designReview.designReviewId, - DesignReviewStatus.CONFIRMED, - organization - ) - ).rejects.toThrow(new AccessDeniedAdminOnlyException('set the status of a design review')); - }); -}); diff --git a/src/frontend/src/apis/calendar.api.ts b/src/frontend/src/apis/calendar.api.ts new file mode 100644 index 0000000000..bbb9f275f8 --- /dev/null +++ b/src/frontend/src/apis/calendar.api.ts @@ -0,0 +1,253 @@ +import axios from '../utils/axios'; +import { apiUrls } from '../utils/urls'; +import { + Shop, + Machinery, + EventType, + AvailabilityCreateArgs, + Event, + EventStatus, + EventTypeCreateArgs, + Calendar, + FilterArgs, + ScheduleSlot +} from 'shared'; +import { eventTransformer } from './transformers/calendar.transformer'; +import { EditEventArgs, EditScheduleSlotArgs, EventCreateArgs } from '../hooks/calendar.hooks'; + +export const getAllCalendars = () => { + return axios.get(apiUrls.calendarCalendars(), { + transformResponse: (data) => JSON.parse(data) as Calendar[] + }); +}; + +export const postCreateCalendar = (payload: { name: string; description: string; colorHexCode: string }) => { + return axios.post(apiUrls.calendarCreateCalendar(), payload, { + transformResponse: (data) => JSON.parse(data) as Calendar + }); +}; + +export const postEditCalendar = ( + calendarId: string, + payload: { name: string; description: string; colorHexCode: string } +) => { + return axios.post(apiUrls.calendarEditCalendar(calendarId), payload, { + transformResponse: (data) => JSON.parse(data) as Calendar + }); +}; + +export const getAllShops = () => { + return axios.get(apiUrls.calendarShops(), { + transformResponse: (data) => JSON.parse(data) as Shop[] + }); +}; + +export const postCreateShop = (payload: { name: string; description: string }) => { + return axios.post(apiUrls.calendarCreateShop(), payload, { + transformResponse: (data) => JSON.parse(data) as Shop + }); +}; + +export const postFilterEvents = (payload: FilterArgs) => { + return axios.post(apiUrls.calendarFilterEvents(), payload, { + transformResponse: (data) => JSON.parse(data).map(eventTransformer) + }); +}; + +export const postDeleteShop = async (id: string) => { + return axios.post(apiUrls.calendarDeleteShop(id)); +}; + +export const getAllMachinery = () => { + return axios.get(apiUrls.calendarMachinery(), { + transformResponse: (data) => JSON.parse(data) as Machinery[] + }); +}; + +export const postCreateMachinery = async (payload: { machineName: string }) => { + const { data } = await axios.post( + apiUrls.calendarCreateMachinery(), + { name: payload.machineName }, + { + transformResponse: (data) => JSON.parse(data) as Machinery + } + ); + return data; +}; + +export const postEditMachinery = async (payload: { machineryId: string; name: string }) => { + const { machineryId, name } = payload; + const { data } = await axios.post( + apiUrls.calendarEditMachinery(machineryId), + { name }, + { + transformResponse: (data) => JSON.parse(data) as Machinery + } + ); + return data; +}; + +export const postDeleteMachinery = async (machineryId: string) => { + const { data } = await axios.post( + apiUrls.calendarDeleteMachinery(machineryId), + {}, + { + transformResponse: (data) => JSON.parse(data) as Machinery + } + ); + return data; +}; + +export const postAddMachineryToShop = async (payload: { + machineryId: string; + shopId: string; + quantity: number; + originalShopId?: string; +}) => { + const { machineryId, ...body } = payload; + const { data } = await axios.post(apiUrls.calendarAddMachineryToShop(machineryId), body, { + transformResponse: (data) => JSON.parse(data) as Machinery + }); + return data; +}; + +export const editShop = (shopId: string, payload: { name: string; description: string }) => { + return axios.post(apiUrls.calendarEditShop(shopId), payload, { + transformResponse: (data) => JSON.parse(data) as Shop + }); +}; + +export const markUserConfirmed = async (id: string, payload: { availability: AvailabilityCreateArgs[] }) => { + return axios.post(apiUrls.calendarEventMarkUserConfirmed(id), payload); +}; + +export const getSingleEvent = async (id: string) => { + return axios.get(apiUrls.calendarGetSingleEvent(id), { + transformResponse: (data) => eventTransformer(JSON.parse(data)) + }); +}; + +export const getSingleEventWithMembers = async (id: string) => { + return axios.get(apiUrls.calendarGetSingleEventWithMembers(id), { + transformResponse: (data) => eventTransformer(JSON.parse(data)) + }); +}; + +export const getConflictingEvent = async (id: string) => { + return axios.get(apiUrls.calendarGetConflictingEvent(id), { + transformResponse: (data) => eventTransformer(JSON.parse(data)) + }); +}; + +export const postCreateEventType = (payload: EventTypeCreateArgs) => { + return axios.post(apiUrls.calendarCreateEventType(), payload, { + transformResponse: (data) => JSON.parse(data) as EventType + }); +}; + +export const postEditEventType = (eventTypeId: string, payload: EventTypeCreateArgs) => { + return axios.post(apiUrls.calendarEditEventType(eventTypeId), payload, { + transformResponse: (data) => JSON.parse(data) as EventType + }); +}; + +export const postDeleteEventType = async (eventTypeId: string) => { + return axios.post(apiUrls.calendarDeleteEventType(eventTypeId)); +}; + +export const getAllEvents = () => { + return axios.get(apiUrls.calendarEvents(), { + transformResponse: (data) => JSON.parse(data).map(eventTransformer) + }); +}; + +export const getAllEventTypes = () => { + return axios.get(apiUrls.calendarEventTypes(), { + transformResponse: (data) => JSON.parse(data).map(eventTransformer) + }); +}; + +export const deleteEvent = async (id: string) => { + return axios.post(apiUrls.calendarDeleteEvent(id)); +}; + +export const setEventStatus = async (id: string, payload: { status: EventStatus }) => { + return axios.post(apiUrls.calendarEventSetStatus(id), payload, { + transformResponse: (data) => eventTransformer(JSON.parse(data)) + }); +}; + +export const approveEvent = async (id: string) => { + return axios.post(apiUrls.calendarApproveEvent(id)); +}; + +export const denyEvent = async (id: string) => { + return axios.post(apiUrls.calendarDenyEvent(id)); +}; + +export const postDeleteCalendar = async (id: string) => { + return axios.post(apiUrls.calendarDeleteCalendar(id)); +}; + +export const postCreateEvent = async (payload: EventCreateArgs) => { + return axios.post(apiUrls.calendarCreateEvent(), payload, { + transformResponse: (data) => eventTransformer(JSON.parse(data)) + }); +}; + +export const postEditEvent = async (eventId: string, payload: EditEventArgs) => { + return axios.post(apiUrls.calendarEditEvent(eventId), payload, { + transformResponse: (data) => eventTransformer(JSON.parse(data)) + }); +}; + +export const postEditScheduleSlot = async (eventId: string, scheduleSlotId: string, payload: EditScheduleSlotArgs) => { + return axios.post(apiUrls.calendarEditScheduleSlot(eventId, scheduleSlotId), payload, { + transformResponse: (data) => eventTransformer(JSON.parse(data)) + }); +}; + +export const previewScheduleSlotRecurringEdits = async (eventId: string, scheduleSlotId: string) => { + return axios.get(apiUrls.calendarPreviewScheduleSlotRecurringEdits(eventId, scheduleSlotId), { + transformResponse: (data) => + JSON.parse(data).map((slot: { scheduleSlotId: string; startTime: string; endTime: string; allDay: boolean }) => ({ + ...slot, + startTime: new Date(slot.startTime), + endTime: new Date(slot.endTime) + })) + }); +}; + +export const postDeleteScheduleSlot = async (eventId: string, scheduleSlotId: string) => { + return axios.post( + apiUrls.calendarDeleteScheduleSlot(eventId, scheduleSlotId), + {}, + { + transformResponse: (data) => eventTransformer(JSON.parse(data)) + } + ); +}; + +/** + * Upload a document + * + * @param payload Payload containing the document data + */ +export const uploadSingleDocument = (file: File, id: string) => { + const formData = new FormData(); + formData.append('pdf', file); + return axios.post(apiUrls.calendarUploadDocument(id), formData); +}; + +/** + * Downloads a PDF file from google drive + * + * @param fileId the google id of the file to download + * @returns the downloaded file as a Blob + */ +export const downloadDocumentPdf = async (fileId: string): Promise => { + const response = await axios.get(apiUrls.calendarPDFById(fileId), { + responseType: 'blob' // Simply use 'blob' for PDF downloads + }); + return response.data; // response.data is already a Blob +}; diff --git a/src/frontend/src/apis/design-reviews.api.ts b/src/frontend/src/apis/design-reviews.api.ts index 269f1e9fba..d9ccfbe2f7 100644 --- a/src/frontend/src/apis/design-reviews.api.ts +++ b/src/frontend/src/apis/design-reviews.api.ts @@ -2,6 +2,7 @@ * 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 { EditDesignReviewPayload } from '../hooks/design-reviews.hooks'; import axios from '../utils/axios'; import { AvailabilityCreateArgs, DesignReview, DesignReviewStatus } from 'shared'; @@ -9,54 +10,60 @@ import { apiUrls } from '../utils/urls'; import { CreateDesignReviewsPayload } from '../hooks/design-reviews.hooks'; import { designReviewTransformer } from './transformers/design-reviews.tranformers'; import { datePipe } from '../utils/pipes'; - +*/ /** * Create a design review * @param payload all info needed to create a design review */ +/* export const createDesignReviews = async (payload: CreateDesignReviewsPayload) => { return axios.post(apiUrls.designReviewsCreate(), { ...payload, dateScheduled: datePipe(payload.dateScheduled) }); }; - +*/ /** * Gets all the design reviews */ +/* export const getAllDesignReviews = () => { return axios.get(apiUrls.designReviews(), { transformResponse: (data) => JSON.parse(data).map(designReviewTransformer) }); }; - +*/ /** * Edit a design review * * @param designReviewId The id of the design review being edited * @param payload The new information for the design review */ +/* export const editDesignReview = (designReviewId: string, payload: EditDesignReviewPayload) => { return axios.post<{ message: string }>(apiUrls.designReviewsEdit(designReviewId), { ...payload }); }; - +*/ /** * Gets a single design review * @param id the ID of the design review to return * @returns the request design review */ +/* export const getSingleDesignReview = async (id: string) => { return axios.get(apiUrls.designReviewById(id), { transformResponse: (data) => designReviewTransformer(JSON.parse(data)) }); }; +*/ /** * Deletes a design review * @param id the ID of the design review to delete */ +/* export const deleteDesignReview = async (id: string) => { return axios.delete(apiUrls.designReviewDelete(id)); }; @@ -70,3 +77,4 @@ export const setDesignReviewStatus = async (id: string, payload: { status: Desig transformResponse: (data) => designReviewTransformer(JSON.parse(data)) }); }; +*/ diff --git a/src/frontend/src/apis/teams.api.ts b/src/frontend/src/apis/teams.api.ts index 239df85b1b..a9065e0174 100644 --- a/src/frontend/src/apis/teams.api.ts +++ b/src/frontend/src/apis/teams.api.ts @@ -4,11 +4,17 @@ */ import axios from '../utils/axios'; -import { Team, TeamPreview } from 'shared'; +import { Team, TeamBase, TeamPreview } from 'shared'; import { apiUrls } from '../utils/urls'; import { CreateTeamPayload } from '../hooks/teams.hooks'; import { teamPreviewTransformer, teamTransformer } from './transformers/teams.transformers'; +export const getAllTeamPreviews = () => { + return axios.get(apiUrls.teamPreviews(), { + transformResponse: (data) => JSON.parse(data).map(teamPreviewTransformer) + }); +}; + export const getAllTeams = () => { return axios.get(apiUrls.teams(), { transformResponse: (data) => JSON.parse(data).map(teamPreviewTransformer) diff --git a/src/frontend/src/apis/transformers/calendar.transformer.ts b/src/frontend/src/apis/transformers/calendar.transformer.ts new file mode 100644 index 0000000000..811728263e --- /dev/null +++ b/src/frontend/src/apis/transformers/calendar.transformer.ts @@ -0,0 +1,52 @@ +import { Shop, Event, EventPreview, EventWithMembers } from 'shared'; +import { userTransformer } from './users.transformers'; + +export const shopTransformer = (shop: Shop): Shop => { + return { + ...shop, + dateCreated: new Date(shop.dateCreated), + userCreated: userTransformer(shop.userCreated) + }; +}; + +export const filterEventTransformer = (event: Event): Event => { + return { + ...event, + dateCreated: new Date(event.dateCreated), + scheduledTimes: event.scheduledTimes.map((schedule) => ({ + ...schedule, + startTime: new Date(schedule.startTime), + endTime: new Date(schedule.endTime) + })) + }; +}; + +export const eventTransformer = (event: Event): Event => { + return { + ...event + }; +}; + +export const eventWithMembersTransformer = (event: EventWithMembers): EventWithMembers => { + return { + ...event, + dateCreated: new Date(event.dateCreated), + initialDateScheduled: event.initialDateScheduled ? new Date(event.initialDateScheduled) : undefined, + scheduledTimes: event.scheduledTimes.map((slot: any) => ({ + ...slot, + startTime: new Date(slot.startTime), + endTime: new Date(slot.endTime) + })) + }; +}; + +export const eventPreviewTransformer = (event: EventPreview): EventPreview => { + return { + eventId: event.eventId, + title: event.title, + dateScheduled: event.dateScheduled, + status: event.status, + userCreated: userTransformer(event.userCreated), + wbsName: event.wbsName + }; +}; diff --git a/src/frontend/src/apis/transformers/design-reviews.tranformers.ts b/src/frontend/src/apis/transformers/design-reviews.tranformers.ts index 7401224f51..50d1996398 100644 --- a/src/frontend/src/apis/transformers/design-reviews.tranformers.ts +++ b/src/frontend/src/apis/transformers/design-reviews.tranformers.ts @@ -1,3 +1,4 @@ +/* import { DesignReview, DesignReviewPreview } from 'shared'; export const designReviewTransformer = (designReview: DesignReview): DesignReview => { @@ -20,3 +21,4 @@ export const designReviewPreviewTransformer = (designReview: DesignReviewPreview dateScheduled: new Date(anyDesignReview.dateScheduled.split('T')[0] + 'T04:00:00.000Z') }; }; +*/ diff --git a/src/frontend/src/apis/transformers/teams.transformers.ts b/src/frontend/src/apis/transformers/teams.transformers.ts index 9c033d252c..1cfb0be051 100644 --- a/src/frontend/src/apis/transformers/teams.transformers.ts +++ b/src/frontend/src/apis/transformers/teams.transformers.ts @@ -17,6 +17,7 @@ export const teamTransformer = (team: Team): Team => { export const teamPreviewTransformer = (team: TeamPreview): TeamPreview => { return { + dateArchived: team.dateArchived ? new Date(team.dateArchived) : undefined, ...team }; }; diff --git a/src/frontend/src/apis/transformers/work-packages.transformers.ts b/src/frontend/src/apis/transformers/work-packages.transformers.ts index 183e4b513c..d805517a41 100644 --- a/src/frontend/src/apis/transformers/work-packages.transformers.ts +++ b/src/frontend/src/apis/transformers/work-packages.transformers.ts @@ -5,8 +5,8 @@ import { RetrospectiveWorkPackage, WorkPackage, WorkPackagePreview } from 'shared'; import { implementedChangeTransformer } from './change-requests.transformers'; -import { designReviewPreviewTransformer } from './design-reviews.tranformers'; import { descriptionBulletTransformer } from './projects.transformers'; +import { eventPreviewTransformer } from './calendar.transformer'; /** * Transforms a work package to ensure deep field transformation of date objects. @@ -22,7 +22,7 @@ export const workPackageTransformer = (workPackage: WorkPackage): WorkPackage => endDate: new Date(workPackage.endDate), descriptionBullets: workPackage.descriptionBullets.map(descriptionBulletTransformer), changes: workPackage.changes.map(implementedChangeTransformer), - designReviews: workPackage.designReviews.map(designReviewPreviewTransformer) + events: workPackage.events.map(eventPreviewTransformer) }; }; diff --git a/src/frontend/src/apis/work-packages.api.ts b/src/frontend/src/apis/work-packages.api.ts index 55b38ae550..6933d7eb33 100644 --- a/src/frontend/src/apis/work-packages.api.ts +++ b/src/frontend/src/apis/work-packages.api.ts @@ -4,7 +4,7 @@ */ import axios from '../utils/axios'; -import { DescriptionBulletPreview, WbsNumber, WorkPackage, WorkPackageStage } from 'shared'; +import { DescriptionBulletPreview, WbsNumber, WorkPackage, WorkPackagePreview, WorkPackageStage } from 'shared'; import { wbsPipe } from '../utils/pipes'; import { apiUrls } from '../utils/urls'; import { workPackagePreviewTransformer, workPackageTransformer } from './transformers/work-packages.transformers'; @@ -118,3 +118,12 @@ export const getHomePageWorkPackages = (selection: WorkPackageSelection) => { transformResponse: (data) => JSON.parse(data).map(workPackagePreviewTransformer) }); }; + +/** + * Fetch all work packages in preview format (minimal data for dropdowns/lists). + */ +export const getAllWorkPackagesPreview = (status?: string) => { + return axios.get(apiUrls.workPackagesAllPreview(status), { + transformResponse: (data) => JSON.parse(data).map(workPackagePreviewTransformer) + }); +}; diff --git a/src/frontend/src/app/AppAuthenticated.tsx b/src/frontend/src/app/AppAuthenticated.tsx index 9b58381a39..d0489867a8 100644 --- a/src/frontend/src/app/AppAuthenticated.tsx +++ b/src/frontend/src/app/AppAuthenticated.tsx @@ -26,7 +26,6 @@ import { Box } from '@mui/system'; import { Container, IconButton, useTheme } from '@mui/material'; import ErrorPage from '../pages/ErrorPage'; import { Role, isGuest } from 'shared'; -import Calendar from '../pages/CalendarPage/Calendar'; import { useState } from 'react'; import ArrowCircleRightTwoToneIcon from '@mui/icons-material/ArrowCircleRightTwoTone'; import HiddenContentMargin from '../components/HiddenContentMargin'; @@ -34,6 +33,7 @@ import { useHomePageContext } from './HomePageContext'; import { useCurrentOrganization } from '../hooks/organizations.hooks'; import Statistics from '../pages/StatisticsPage/Statistics'; import RetrospectiveGanttChartPage from '../pages/RetrospectivePage/Retrospective'; +import NewCalendar from '../pages/CalendarPage/NewCalendar'; interface AppAuthenticatedProps { userId: string; @@ -126,7 +126,7 @@ const AppAuthenticated: React.FC = ({ userId, userRole }) - + diff --git a/src/frontend/src/components/TeamsDropdown.tsx b/src/frontend/src/components/TeamsDropdown.tsx index 02a87deaea..fafabfa092 100644 --- a/src/frontend/src/components/TeamsDropdown.tsx +++ b/src/frontend/src/components/TeamsDropdown.tsx @@ -1,7 +1,7 @@ import { Box, FormControl, FormLabel, MenuItem, Select, SelectChangeEvent } from '@mui/material'; import { Control, Controller } from 'react-hook-form'; import LoadingIndicator from './LoadingIndicator'; -import { useAllTeams } from '../hooks/teams.hooks'; +import { useAllTeamPreviews } from '../hooks/teams.hooks'; interface TeamDropdownProps { control: Control; @@ -10,7 +10,7 @@ interface TeamDropdownProps { } const TeamDropdown = ({ control, name, multiselect = false }: TeamDropdownProps) => { - const { isLoading, data: teams } = useAllTeams(); + const { isLoading, data: teams } = useAllTeamPreviews(); if (isLoading || !teams) return ; return ( diff --git a/src/frontend/src/hooks/calendar.hooks.ts b/src/frontend/src/hooks/calendar.hooks.ts new file mode 100644 index 0000000000..a028572990 --- /dev/null +++ b/src/frontend/src/hooks/calendar.hooks.ts @@ -0,0 +1,643 @@ +import { useMutation, useQuery, useQueryClient } from 'react-query'; +import { + Shop, + Machinery, + Calendar, + AvailabilityCreateArgs, + EventTypeCreateArgs, + Event, + EventStatus, + EventType, + FilterArgs, + ScheduleSlotCreateArgs, + EventWithMembers, + ScheduleSlot +} from 'shared'; +import { + getAllShops, + postCreateShop, + postDeleteShop, + getAllMachinery, + postCreateMachinery, + postEditMachinery, + postDeleteMachinery, + postAddMachineryToShop, + editShop, + postDeleteCalendar, + getAllCalendars, + postEditCalendar, + postCreateCalendar, + postCreateEventType, + postEditEventType, + postDeleteEventType, + markUserConfirmed, + getSingleEvent, + getAllEvents, + deleteEvent, + setEventStatus, + getAllEventTypes, + postFilterEvents, + approveEvent, + denyEvent, + getConflictingEvent, + postCreateEvent, + uploadSingleDocument, + downloadDocumentPdf, + postEditEvent, + postEditScheduleSlot, + getSingleEventWithMembers, + previewScheduleSlotRecurringEdits, + postDeleteScheduleSlot +} from '../apis/calendar.api'; +import { useCurrentUser } from './users.hooks'; +import { PDFDocument } from 'pdf-lib'; +import saveAs from 'file-saver'; + +export const FILTER_EVENTS_KEY = ['filter_events'] as const; + +export const MACHINERY_KEY = ['machinery'] as const; +const SHOP_KEY = ['shops'] as const; +const CALENDAR_KEY = ['calendars'] as const; +export const EVENT_TYPE_KEY = ['event-types'] as const; +export const EVENT_KEY = ['events'] as const; + +export interface EventCreateArgs { + title: string; + eventTypeId: string; + requiredMemberIds: string[]; + optionalMemberIds: string[]; + teamIds: string[]; + teamTypeId?: string; + location?: string; + zoomLink?: string; + shopIds: string[]; + machineryIds: string[]; + workPackageIds: string[]; + documentIds: string[]; + questionDocument?: string; + description?: string; + initialDateScheduled: Date; + scheduleSlots: ScheduleSlotCreateArgs[]; +} + +export interface EditEventArgs { + title: string; + requiredMemberIds: string[]; + optionalMemberIds: string[]; + teamIds: string[]; + teamTypeId?: string; + status: EventStatus; + location?: string; + zoomLink?: string; + shopIds: string[]; + machineryIds: string[]; + workPackageIds: string[]; + documents: Array<{ name: string; googleFileId: string }>; + questionDocumentLink?: string; + description?: string; +} + +export interface EditScheduleSlotArgs { + startTime: Date; + endTime: Date; + allDay: boolean; + editAllInSeries: boolean; +} + +export interface DownloadDocumentsFormInput { + fileIds: string[]; + startDate: Date; + endDate: Date; + event: Event; +} + +export const useAllCalendars = () => + useQuery(['calendars'], async () => { + const res = await getAllCalendars(); + return res.data; + }); + +export const useCreateCalendar = () => { + const qc = useQueryClient(); + return useMutation( + async (payload) => { + const { data } = await postCreateCalendar(payload); + return data; + }, + { + onSuccess: () => { + qc.invalidateQueries(['calendars']); + } + } + ); +}; + +export const useEditCalendar = (calendarId: string) => { + const qc = useQueryClient(); + return useMutation( + async (payload) => { + const { data } = await postEditCalendar(calendarId, payload); + return data; + }, + { + onSuccess: () => { + qc.invalidateQueries(['calendars']); + } + } + ); +}; + +export const useAllShops = () => + useQuery(SHOP_KEY, async () => { + const res = await getAllShops(); + return res.data; + }); + +export const useCreateShop = () => { + const qc = useQueryClient(); + return useMutation( + async (payload) => { + const { data } = await postCreateShop(payload); + return data; + }, + { + onSuccess: () => { + qc.invalidateQueries(SHOP_KEY); + } + } + ); +}; + +export const useEditShop = (shopId: string) => { + const qc = useQueryClient(); + return useMutation( + async (payload) => { + const { data } = await editShop(shopId, payload); + return data; + }, + { + onSuccess: () => { + qc.invalidateQueries(SHOP_KEY); + } + } + ); +}; + +export const useAllMachines = () => + useQuery(MACHINERY_KEY, async () => { + const res = await getAllMachinery(); + return res.data; + }); + +export const useCreateMachinery = () => { + const qc = useQueryClient(); + return useMutation( + async (payload) => { + return await postCreateMachinery(payload); + }, + { + onSuccess: () => { + qc.invalidateQueries(MACHINERY_KEY); + } + } + ); +}; + +export const useEditMachinery = (machineryId: string) => { + const qc = useQueryClient(); + return useMutation( + async (payload) => { + return await postEditMachinery({ + machineryId, + name: payload.machineName + }); + }, + { + onSuccess: () => { + qc.invalidateQueries(MACHINERY_KEY); + } + } + ); +}; + +export const useAddMachineryToShop = (machineryId: string) => { + const qc = useQueryClient(); + return useMutation( + async (payload) => { + return await postAddMachineryToShop({ + machineryId, + ...payload + }); + }, + { + onSuccess: () => { + qc.invalidateQueries(MACHINERY_KEY); + } + } + ); +}; + +export const useDeleteShop = () => { + const qc = useQueryClient(); + return useMutation<{ shopId: string }, Error, string>( + async (shopId: string) => { + const { data } = await postDeleteShop(shopId); + return data; + }, + { + onSuccess: () => { + qc.invalidateQueries(SHOP_KEY); + } + } + ); +}; + +export const useDeleteMachinery = () => { + const qc = useQueryClient(); + return useMutation( + async (machineryId: string) => { + return await postDeleteMachinery(machineryId); + }, + { + onSuccess: () => { + qc.invalidateQueries(MACHINERY_KEY); + } + } + ); +}; + +export const useCreateEventType = () => { + const qc = useQueryClient(); + return useMutation( + async (payload) => { + const { data } = await postCreateEventType(payload); + return data; + }, + { + onSuccess: () => { + qc.invalidateQueries(EVENT_TYPE_KEY); + } + } + ); +}; + +export const useEditEventType = (eventTypeId: string) => { + const qc = useQueryClient(); + return useMutation( + async (payload) => { + const { data } = await postEditEventType(eventTypeId, payload); + return data; + }, + { + onSuccess: () => { + qc.invalidateQueries(EVENT_TYPE_KEY); + } + } + ); +}; + +export const useDeleteEventType = () => { + const qc = useQueryClient(); + return useMutation<{ eventTypeId: string }, Error, string>( + async (eventTypeId: string) => { + const { data } = await postDeleteEventType(eventTypeId); + return data; + }, + { + onSuccess: () => { + qc.invalidateQueries(EVENT_TYPE_KEY); + } + } + ); +}; + +export const useMarkUserConfirmed = (id: string) => { + const user = useCurrentUser(); + const queryClient = useQueryClient(); + return useMutation( + ['events', 'mark-confirmed'], + async (eventPayload: { availability: AvailabilityCreateArgs[] }) => { + const { data } = await markUserConfirmed(id, eventPayload); + return data; + }, + { + onSuccess: () => { + queryClient.invalidateQueries(EVENT_KEY); + queryClient.invalidateQueries(['users', user.userId, 'schedule-settings']); + queryClient.invalidateQueries(['users', user.userId, 'schedule-settings']); + queryClient.invalidateQueries(['users', 'many-with-schedule-settings']); + queryClient.invalidateQueries(['users']); + } + } + ); +}; + +export const useDeleteCalendar = () => { + const qc = useQueryClient(); + return useMutation<{ calendarId: string }, Error, string>( + async (calendarId: string) => { + const { data } = await postDeleteCalendar(calendarId); + return data; + }, + { + onSuccess: () => { + qc.invalidateQueries(CALENDAR_KEY); + } + } + ); +}; + +export const useSingleEvent = (id?: string) => { + return useQuery( + ['events', id], + async () => { + const { data } = await getSingleEvent(id!); + return data; + }, + { enabled: !!id } + ); +}; + +export const useSingleEventWithMembers = (id?: string) => { + return useQuery( + ['events', id, 'with-members'], + async () => { + const { data } = await getSingleEventWithMembers(id!); + return data; + }, + { enabled: !!id } + ); +}; + +export const useConflictingEvents = (ids: string[]) => { + return useQuery(['events', 'conflicting', ids], async () => { + const results = await Promise.all( + ids.map(async (id) => { + const { data } = await getConflictingEvent(id); + return data; + }) + ); + return results; + }); +}; + +export const useAllEvents = () => { + return useQuery(EVENT_KEY, async () => { + const { data } = await getAllEvents(); + return data; + }); +}; + +export const useFilterEvents = (filterArgs: FilterArgs) => { + return useQuery( + ['filter-events', filterArgs], + async () => { + const { data } = await postFilterEvents(filterArgs); + return data; + }, + { + keepPreviousData: true + } + ); +}; + +export const useAllEventTypes = () => { + return useQuery(EVENT_TYPE_KEY, async () => { + const { data } = await getAllEventTypes(); + return data; + }); +}; + +export const useDeleteEvent = (id: string) => { + const queryClient = useQueryClient(); + return useMutation( + ['events', 'delete'], + async () => { + const { data } = await deleteEvent(id); + return data; + }, + { + onSuccess: () => { + queryClient.invalidateQueries(EVENT_KEY); + queryClient.invalidateQueries(['filter-events']); + } + } + ); +}; + +export const useSetEventStatus = (id: string) => { + const queryClient = useQueryClient(); + return useMutation( + ['events', id], + async (payload: { status: EventStatus }) => { + const { data } = await setEventStatus(id, payload); + return data; + }, + { + onSuccess: () => { + queryClient.invalidateQueries(['events', id]); + } + } + ); +}; + +export const useCreateEvent = () => { + const qc = useQueryClient(); + return useMutation( + async (payload) => { + const { data } = await postCreateEvent(payload); + return data; + }, + { + onSuccess: () => { + qc.invalidateQueries(['filter-events']); + qc.invalidateQueries(EVENT_KEY); + } + } + ); +}; + +export const useApproveEvent = (id: string) => { + const queryClient = useQueryClient(); + return useMutation( + ['events', id], + async () => { + const { data } = await approveEvent(id); + return data; + }, + { + onSuccess: () => { + queryClient.invalidateQueries(['events', id]); + queryClient.invalidateQueries(['filter-events']); + } + } + ); +}; + +export const useEditEvent = (eventId: string) => { + const queryClient = useQueryClient(); + return useMutation( + ['events', 'edit', eventId], + async (payload) => { + const { data } = await postEditEvent(eventId, payload); + return data; + }, + { + onSuccess: () => { + queryClient.invalidateQueries(['filter-events']); + queryClient.invalidateQueries(EVENT_KEY); + } + } + ); +}; + +export const useEditScheduleSlot = (eventId: string, scheduleSlotId: string) => { + const queryClient = useQueryClient(); + return useMutation( + ['events', 'edit-schedule-slot', eventId, scheduleSlotId], + async (payload) => { + const { data } = await postEditScheduleSlot(eventId, scheduleSlotId, payload); + return data; + }, + { + onSuccess: () => { + queryClient.invalidateQueries(['filter-events']); + queryClient.invalidateQueries(EVENT_KEY); + queryClient.invalidateQueries(['events', eventId]); + } + } + ); +}; + +/** + * Hook to get a preview of other schedule slots that would be affected + * when editing a schedule slot with "edit all in series" enabled. + */ +export const usePreviewScheduleSlotRecurringEdits = (eventId: string, scheduleSlotId: string, enabled: boolean = true) => { + return useQuery( + ['events', 'schedule-slot-recurring-edits-preview', eventId, scheduleSlotId], + async () => { + const { data } = await previewScheduleSlotRecurringEdits(eventId, scheduleSlotId); + return data; + }, + { + enabled: enabled && !!eventId && !!scheduleSlotId + } + ); +}; + +/** + * Hook to delete a single schedule slot from an event. + * If this is the last schedule slot, the entire event will be deleted instead. + */ +export const useDeleteScheduleSlot = (eventId: string, scheduleSlotId: string) => { + const queryClient = useQueryClient(); + return useMutation( + ['events', 'delete-schedule-slot', eventId, scheduleSlotId], + async () => { + const { data } = await postDeleteScheduleSlot(eventId, scheduleSlotId); + return data; + }, + { + onSuccess: () => { + queryClient.invalidateQueries(['filter-events']); + queryClient.invalidateQueries(EVENT_KEY); + queryClient.invalidateQueries(['events', eventId]); + } + } + ); +}; + +export const useDenyEvent = (id: string) => { + const queryClient = useQueryClient(); + return useMutation( + ['events', id], + async () => { + const { data } = await denyEvent(id); + return data; + }, + { + onSuccess: () => { + queryClient.invalidateQueries(['events', id]); + queryClient.invalidateQueries(['filter-events']); + } + } + ); +}; + +/** + * Custom React Hook to upload a new document. + */ +export const useUploadSingleDocument = () => { + return useMutation<{ googleFileId: string; name: string }, Error, { file: File; id: string }>( + ['events', 'upload', 'single'], + async (formData: { file: File; id: string }) => { + const { data } = await uploadSingleDocument(formData.file, formData.id); + return data; + } + ); +}; + +/** + * Custom hook that uploads many documents to a given event + * + * @returns The created document information + */ +export const useUploadManyDocuments = () => { + return useMutation<{ googleFileId: string; name: string }[], Error, { files: File[]; id: string }>( + ['events', 'upload', 'many'], + async (formData: { files: File[]; id: string }) => { + const results = []; + for (const file of formData.files) { + results.push(await uploadSingleDocument(file, formData.id)); + } + return results.map((result) => result.data); + } + ); +}; + +/** + * Custom react hook to download PDFs from google drive and combine them into a single PDF + * + * @param fileIds The google file ids to fetch the PDFs for + */ +export const useDownloadPDFOfDocuments = () => { + return useMutation(['events'], async (formData: DownloadDocumentsFormInput) => { + const promises = formData.fileIds.map((fileId) => { + return downloadDocumentPdf(fileId); + }); + + const blobs = await Promise.all(promises); + const pdfName = `${formData.startDate.toLocaleDateString()}-${formData.endDate.toLocaleDateString()}.pdf`; + + const pdfFileName = `documents-${formData.event.title}-${pdfName}`; + + await combinePdfsAndDownload(blobs, pdfFileName); + }); +}; + +/** + * Combines multiple PDF blobs into a single PDF and downloads it + * + * @param blobData an array of PDF blob data + * @param filename the name of the created PDF + */ +export const combinePdfsAndDownload = async (blobData: Blob[], filename: string) => { + const pdfDoc = await PDFDocument.create(); + + // Load and copy pages from each PDF + for (const blob of blobData) { + const arrayBuffer = await blob.arrayBuffer(); + const pdf = await PDFDocument.load(arrayBuffer); + const pages = await pdfDoc.copyPages(pdf, pdf.getPageIndices()); + + pages.forEach((page) => { + pdfDoc.addPage(page); + }); + } + + // Save and download + const pdfBytes = await pdfDoc.save(); + const pdfBlob = new Blob([new Uint8Array(pdfBytes)], { type: 'application/pdf' }); + saveAs(pdfBlob, filename); +}; diff --git a/src/frontend/src/hooks/design-reviews.hooks.ts b/src/frontend/src/hooks/design-reviews.hooks.ts index c801cc968c..7c6aa03c06 100644 --- a/src/frontend/src/hooks/design-reviews.hooks.ts +++ b/src/frontend/src/hooks/design-reviews.hooks.ts @@ -2,8 +2,9 @@ * 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 { useMutation, useQuery, useQueryClient } from 'react-query'; -import { DesignReview, WbsNumber, DesignReviewStatus, AvailabilityCreateArgs } from 'shared'; +import { WbsNumber, AvailabilityCreateArgs } from 'shared'; import { deleteDesignReview, editDesignReview, @@ -39,12 +40,13 @@ export const useCreateDesignReviews = () => { } ); }; - +/* /** * Custom react hook to get all design reviews * * @returns all the design reviews */ +/* export const useAllDesignReviews = () => { return useQuery(['design-reviews'], async () => { const { data } = await getAllDesignReviews(); @@ -66,11 +68,12 @@ export interface EditDesignReviewPayload { attendees: string[]; meetingTimes: number[]; } - +/* /** * Custom React Hook to edit a Design Review * @param designReviewId the design review being edited */ +/* export const useEditDesignReview = (designReviewId: string) => { const queryClient = useQueryClient(); return useMutation<{ message: string }, Error, EditDesignReviewPayload>( @@ -86,11 +89,11 @@ export const useEditDesignReview = (designReviewId: string) => { } ); }; - +/* /** * Custom react hook to delete a design review */ - +/* export const useDeleteDesignReview = (id: string) => { const queryClient = useQueryClient(); return useMutation( @@ -106,12 +109,13 @@ export const useDeleteDesignReview = (id: string) => { } ); }; - +/* /** * Custom react hook to get a single design review * * @returns a single design review */ +/* export const useSingleDesignReview = (id?: string) => { return useQuery( ['design-reviews', id], @@ -156,3 +160,4 @@ export const useSetDesignReviewStatus = (id: string) => { } ); }; +*/ diff --git a/src/frontend/src/hooks/onboarding.hook.ts b/src/frontend/src/hooks/onboarding.hook.ts index aeaee2c05c..8894c92411 100644 --- a/src/frontend/src/hooks/onboarding.hook.ts +++ b/src/frontend/src/hooks/onboarding.hook.ts @@ -1,5 +1,5 @@ import { useMutation, useQuery, useQueryClient } from 'react-query'; -import { Checklist, ChecklistItemType, ChecklistPreview, User } from '../../../shared'; +import { Checklist, ChecklistItemType, ChecklistPreview, User } from 'shared'; import { getAllChecklists, getGeneralChecklists, diff --git a/src/frontend/src/hooks/teams.hooks.ts b/src/frontend/src/hooks/teams.hooks.ts index a68bbf138c..431c4a3cff 100644 --- a/src/frontend/src/hooks/teams.hooks.ts +++ b/src/frontend/src/hooks/teams.hooks.ts @@ -4,7 +4,7 @@ */ import { useQuery, useQueryClient, useMutation } from 'react-query'; -import { Team, TeamPreview } from 'shared'; +import { Team, TeamBase, TeamPreview } from 'shared'; import { getAllTeams, getSingleTeam, @@ -18,7 +18,8 @@ import { getAllArchivedTeams, getUsersTeams, setTeamSlackId, - getMyTeamAsHead + getMyTeamAsHead, + getAllTeamPreviews } from '../apis/teams.api'; export interface CreateTeamPayload { @@ -29,6 +30,13 @@ export interface CreateTeamPayload { isFinanceTeam: boolean; } +export const useAllTeamPreviews = () => { + return useQuery(['teams'], async () => { + const { data } = await getAllTeamPreviews(); + return data; + }); +}; + export const useAllTeams = () => { return useQuery(['teams', false], async () => { const { data } = await getAllTeams(); diff --git a/src/frontend/src/hooks/work-packages.hooks.ts b/src/frontend/src/hooks/work-packages.hooks.ts index 47b72f0c39..acf0fa7d92 100644 --- a/src/frontend/src/hooks/work-packages.hooks.ts +++ b/src/frontend/src/hooks/work-packages.hooks.ts @@ -4,16 +4,17 @@ */ import { useMutation, useQuery, useQueryClient } from 'react-query'; -import { WorkPackage, WbsNumber, WorkPackageSelection } from 'shared'; +import { WorkPackage, WorkPackagePreview, WbsNumber, WorkPackageSelection } from 'shared'; import { createSingleWorkPackage, deleteWorkPackage, editWorkPackage, getAllBlockingWorkPackages, getAllWorkPackages, + getAllWorkPackagesPreview, + getManyWorkPackages, getSingleWorkPackage, slackUpcomingDeadlines, - getManyWorkPackages, WorkPackageCreateArgs, WorkPackageEditArgs, getHomePageWorkPackages @@ -29,6 +30,16 @@ export const useAllWorkPackages = (queryParams?: { [field: string]: string }) => }); }; +/** + * Custom React Hook to supply all work packages in preview format (minimal data). + */ +export const useAllWorkPackagesPreview = (status?: string) => { + return useQuery(['work packages', 'preview', status], async () => { + const { data } = await getAllWorkPackagesPreview(status); + return data; + }); +}; + /** * Custom React Hook to supply a single work package. * diff --git a/src/frontend/src/layouts/Sidebar/Sidebar.tsx b/src/frontend/src/layouts/Sidebar/Sidebar.tsx index 6fd7b11f4d..4e51ff444e 100644 --- a/src/frontend/src/layouts/Sidebar/Sidebar.tsx +++ b/src/frontend/src/layouts/Sidebar/Sidebar.tsx @@ -92,7 +92,7 @@ const Sidebar = ({ drawerOpen, setDrawerOpen, moveContent, setMoveContent }: Sid { name: 'Calendar', icon: , - route: routes.CALENDAR + route: routes.NEW_CALENDAR }, { name: 'Retrospective', diff --git a/src/frontend/src/pages/AdminToolsPage/AdminToolsAttendeeDesignReviewInfo.tsx b/src/frontend/src/pages/AdminToolsPage/AdminToolsAttendeeDesignReviewInfo.tsx index 28c12ef7d1..38cd5ab4d2 100644 --- a/src/frontend/src/pages/AdminToolsPage/AdminToolsAttendeeDesignReviewInfo.tsx +++ b/src/frontend/src/pages/AdminToolsPage/AdminToolsAttendeeDesignReviewInfo.tsx @@ -1,31 +1,22 @@ import React, { useState } from 'react'; import { TextField, FormControl, FormLabel, TableCell, TableRow, Grid, Typography } from '@mui/material'; import NERTable from '../../components/NERTable'; -import { useAllTeams } from '../../hooks/teams.hooks'; import LoadingIndicator from '../../components/LoadingIndicator'; import ErrorPage from '../ErrorPage'; import { fullNamePipe } from '../../utils/pipes'; import { useAllMembers } from '../../hooks/users.hooks'; -import { useAllDesignReviews } from '../../hooks/design-reviews.hooks'; -import { DesignReviewStatus } from 'shared'; +import { useAllEvents } from '../../hooks/calendar.hooks'; +import { EventStatus } from 'shared'; const AdminToolsAttendeeDesignReviewInfo: React.FC = () => { const [searchQuery, setSearchQuery] = useState(''); - const { data: allTeams, isLoading: teamsIsLoading, isError: teamsIsError, error: teamsError } = useAllTeams(); const { data: allMembers, isLoading: usersIsLoading, isError: usersIsError, error: usersError } = useAllMembers(); - const { - data: allDesignReviews, - isLoading: designReviewsIsLoading, - isError: designReviewsIsError, - error: designReviewsError - } = useAllDesignReviews(); + const { data: allEvents, isLoading: eventsIsLoading, isError: eventsIsError, error: eventsError } = useAllEvents(); - if (!allTeams || teamsIsLoading || !allMembers || usersIsLoading || !allDesignReviews || designReviewsIsLoading) - return ; - if (teamsIsError) return ; + if (!allMembers || usersIsLoading || !allEvents || eventsIsLoading) return ; if (usersIsError) return ; - if (designReviewsIsError) return ; + if (eventsIsError) return ; const filteredMembers = allMembers.filter((member) => fullNamePipe(member).toLowerCase().includes(searchQuery.toLowerCase()) @@ -38,9 +29,9 @@ const AdminToolsAttendeeDesignReviewInfo: React.FC = () => { const attendanceDict: Map = new Map(); const missedDict: Map = new Map(); - allDesignReviews.forEach((review) => { - if (review.status === DesignReviewStatus.DONE) { - review.attendees.forEach((member) => { + allEvents.forEach((review) => { + if (review.status === EventStatus.DONE) { + review.requiredMembers.forEach((member) => { if (attendanceDict.has(member.userId)) { attendanceDict.set(member.userId, attendanceDict.get(member.userId)! + 1); } else { @@ -48,7 +39,7 @@ const AdminToolsAttendeeDesignReviewInfo: React.FC = () => { } }); review.requiredMembers.forEach((member) => { - if (!review.attendees.map((user) => user.userId).includes(member.userId)) { + if (!review.requiredMembers.map((user) => user.userId).includes(member.userId)) { if (missedDict.has(member.userId)) { missedDict.set(member.userId, missedDict.get(member.userId)! + 1); } else { @@ -80,7 +71,7 @@ const AdminToolsAttendeeDesignReviewInfo: React.FC = () => { return ( - Design Review Attendee Info + Event Attendee Info Search by team member name @@ -89,7 +80,7 @@ const AdminToolsAttendeeDesignReviewInfo: React.FC = () => { { const currentUser = useCurrentUser(); @@ -37,6 +38,7 @@ const AdminToolsPage: React.FC = () => { if (isUserHead || isUserAdmin) { tabs.push({ tabUrlValue: 'user-management', tabName: 'User Management' }); tabs.push({ tabUrlValue: 'project-configuration', tabName: 'Project Configuration' }); + tabs.push({ tabUrlValue: 'schedule', tabName: 'Schedule' }); } if (isUserAdmin || isUserFinanceLead) { tabs.push({ tabUrlValue: 'finance-configuration', tabName: 'Finance Configuration' }); @@ -75,12 +77,14 @@ const AdminToolsPage: React.FC = () => { {isUserAdmin && } ) : tabIndex === 2 ? ( - + ) : tabIndex === 3 ? ( - + ) : tabIndex === 4 ? ( - + ) : tabIndex === 5 ? ( + + ) : tabIndex === 6 ? ( ) : ( diff --git a/src/frontend/src/pages/AdminToolsPage/AdminToolsSlackIds.tsx b/src/frontend/src/pages/AdminToolsPage/AdminToolsSlackIds.tsx index 4c287b0496..e8b5695c71 100644 --- a/src/frontend/src/pages/AdminToolsPage/AdminToolsSlackIds.tsx +++ b/src/frontend/src/pages/AdminToolsPage/AdminToolsSlackIds.tsx @@ -14,9 +14,9 @@ import { } from '../../hooks/organizations.hooks'; import LoadingIndicator from '../../components/LoadingIndicator'; import ErrorPage from '../ErrorPage'; -import { Organization, TeamPreview } from 'shared'; +import { Organization, TeamBase } from 'shared'; import HelpIcon from '@mui/icons-material/Help'; -import { useAllTeams } from '../../hooks/teams.hooks'; +import { useAllTeamPreviews } from '../../hooks/teams.hooks'; import NERTable from '../../components/NERTable'; import EditTeamSlackIdFormModal from './TeamConfig/EditTeamSlackIdFormModal'; @@ -40,8 +40,13 @@ const AdminToolsSlackIdsView: React.FC = ({ orga const [sponsorshipChannelId, setSponsorshipChannelId] = useState( organization.sponsorshipNotificationsSlackChannelId ?? '' ); - const { data: allTeams, isLoading: allTeamsIsLoading, isError: allTeamsIsError, error: allTeamsError } = useAllTeams(); - const [clickedTeam, setClickedTeam] = useState(); + const { + data: allTeams, + isLoading: allTeamsIsLoading, + isError: allTeamsIsError, + error: allTeamsError + } = useAllTeamPreviews(); + const [clickedTeam, setClickedTeam] = useState(); if (!allTeams || allTeamsIsLoading) return ; diff --git a/src/frontend/src/pages/AdminToolsPage/ScheduleConfig/AdminToolsScheduleConfig.tsx b/src/frontend/src/pages/AdminToolsPage/ScheduleConfig/AdminToolsScheduleConfig.tsx new file mode 100644 index 0000000000..3ce50f5d62 --- /dev/null +++ b/src/frontend/src/pages/AdminToolsPage/ScheduleConfig/AdminToolsScheduleConfig.tsx @@ -0,0 +1,621 @@ +import React, { useState } from 'react'; +import { Box, Grid, Typography, Paper, Table, TableBody, TableCell, TableHead, TableRow, Button } from '@mui/material'; +import LoadingIndicator from '../../../components/LoadingIndicator'; +import ErrorPage from '../../ErrorPage'; + +import { IconButton, Tooltip } from '@mui/material'; +import { + useAllShops, + useCreateShop, + useEditShop, + useAllMachines, + useDeleteMachinery, + useDeleteShop, + useDeleteCalendar, + useAllCalendars, + useCreateCalendar, + useEditCalendar, + useAllEventTypes, + useDeleteEventType +} from '../../../hooks/calendar.hooks'; +import ShopModal from './Shop/ShopModal'; +import CreateCalendarModal from './Calendar/CreateCalendarModal'; +import EditCalendarModal from './Calendar/EditCalendarModal'; +import EditIcon from '@mui/icons-material/Edit'; +import DeleteIcon from '@mui/icons-material/Delete'; +import CreateMachineryModal from './Machinery/CreateMachineryModal'; +import EditMachineryModal from './Machinery/EditMachineryModal'; +import CreateEventTypeModal from './EventType/CreateEventTypeModal'; +import EditEventTypeModal from './EventType/EditEventTypeModal'; +import { Shop, EventType, Calendar } from 'shared'; +import { useToast } from '../../../hooks/toasts.hooks'; +import NERDeleteModal from '../../../components/NERDeleteModal'; + +const AdminToolsScheduleConfig: React.FC = () => { + const { data: shops, isLoading: shopsLoading, isError: shopsError, error: shopsErrorMsg } = useAllShops(); + const { data: machines, isLoading: machinesLoading, isError: machinesError, error: machinesErrorMsg } = useAllMachines(); + const { + data: eventTypes, + isLoading: eventTypesLoading, + isError: eventTypesError, + error: eventTypesErrorMsg + } = useAllEventTypes(); + const { + data: calendars, + isLoading: calendarsLoading, + isError: calendarsError, + error: calendarsErrorMsg + } = useAllCalendars(); + const { mutateAsync: createShopMutate } = useCreateShop(); + const { mutateAsync: createCalendarMutate } = useCreateCalendar(); + + const [editingShopId, setEditingShopId] = useState(); + const editShopMutation = useEditShop(editingShopId ?? ''); + const [editingCalendarId, setEditingCalendarId] = useState(); + const editCalendarMutation = useEditCalendar(editingCalendarId ?? ''); + const [machineryToDelete, setMachineryToDelete] = useState<{ + machineryId: string; + machineName: string; + } | null>(null); + const { mutateAsync: deleteMachinery } = useDeleteMachinery(); + const { mutateAsync: deleteShop } = useDeleteShop(); + const { mutateAsync: deleteCalendar } = useDeleteCalendar(); + const { mutateAsync: deleteEventType } = useDeleteEventType(); + const toast = useToast(); + + const handleDeleteMachinery = async () => { + if (!machineryToDelete) return; + setMachineryToDelete(null); + try { + await deleteMachinery(machineryToDelete.machineryId); + toast.success('Machinery deleted successfully'); + } catch (e: unknown) { + if (e instanceof Error) { + toast.error(e.message, 3000); + } else { + toast.error('Failed to delete machinery', 3000); + } + } + }; + + const handleShopDelete = async () => { + if (!shopToDelete) return; + setShopToDelete(undefined); + try { + await deleteShop(shopToDelete.shopId); + toast.success('Shop deleted successfully'); + } catch (e: unknown) { + if (e instanceof Error) { + toast.error(e.message, 3000); + } else { + toast.error('Failed to delete shop', 3000); + } + } + }; + + const handleCalendarDelete = async () => { + if (!calendarToDelete) return; + setCalendarToDelete(undefined); + try { + await deleteCalendar(calendarToDelete.calendarId); + toast.success('Calendar deleted successfully'); + } catch (e: unknown) { + if (e instanceof Error) { + toast.error(e.message, 3000); + } else { + toast.error('Failed to delete calendar', 3000); + } + } + }; + + const handleEventTypeDelete = async () => { + if (!eventTypeToDelete) return; + setEventTypeToDelete(undefined); + try { + await deleteEventType(eventTypeToDelete.eventTypeId); + toast.success('Event type deleted successfully'); + } catch (e: unknown) { + if (e instanceof Error) { + toast.error(e.message, 3000); + } else { + toast.error('Failed to delete event type', 3000); + } + } + }; + + const [openCreate, setOpenCreate] = useState(false); + const [openCreateMachinery, setOpenCreateMachinery] = useState(false); + const [editMachinery, setEditMachinery] = useState<{ machineryId: string; shopId: string } | null>(null); + const [openEdit, setOpenEdit] = useState(false); + const [editingShop, setEditingShop] = useState(null); + const [shopToDelete, setShopToDelete] = useState(undefined); + const [openCreateEventType, setOpenCreateEventType] = useState(false); + const [editingEventType, setEditingEventType] = useState(null); + const [eventTypeToDelete, setEventTypeToDelete] = useState(undefined); + const [calendarToDelete, setCalendarToDelete] = useState(undefined); + const [openCreateCalendar, setOpenCreateCalendar] = useState(false); + const [openEditCalendar, setOpenEditCalendar] = useState(false); + const [editingCalendar, setEditingCalendar] = useState(undefined); + + if (shopsLoading || machinesLoading || eventTypesLoading || calendarsLoading) return ; + if (shopsError) return ; + if (machinesError) return ; + if (eventTypesError) return ; + if (calendarsError) return ; + + return ( + + + Schedule + + + + + + + Calendars + + + + + + Name + Description + + Color + + + + + + {!calendars || !Array.isArray(calendars) || calendars.length === 0 ? ( + + + No calendars yet. + + + ) : ( + calendars.map((calendar: Calendar) => ( + + {calendar.name} + {calendar.description ?? '—'} + + + + + + + + { + setEditingCalendar(calendar); + setEditingCalendarId(calendar.calendarId); + setOpenEditCalendar(true); + }} + > + + + + + + + + setCalendarToDelete(calendar)} + > + + + + + + + + )) + )} + +
+
+
+ + {/* Event Types Table */} + + + + Event Type + + + + + + + Type + + Actions + + + + + {!eventTypes || !Array.isArray(eventTypes) || eventTypes.length === 0 ? ( + + + No event types yet. + + + ) : ( + eventTypes.map((eventType) => ( + + {eventType.name} + + + + + { + setEditingEventType(eventType); + }} + > + + + + + + + + setEventTypeToDelete(eventType)} + > + + + + + + + + )) + )} + +
+
+
+ + {/* Shops Table */} + + + + Shops + + + + + + + Name + Description + + Actions + + + + + {!shops || shops.length === 0 ? ( + + + No shops yet. + + + ) : ( + shops.map((shop) => ( + + {shop.name} + {shop.description ?? '—'} + + + + + { + setEditingShop(shop); + setEditingShopId(shop.shopId); + setOpenEdit(true); + }} + > + + + + + + + + { + setShopToDelete(shop); + }} + > + + + + + + + + )) + )} + +
+
+
+ + {/*Machinery Table */} + + + + Machinery + + + + + + Name + Shop + + # of Machines + + + Actions + + + + + {!machines || !Array.isArray(machines) || machines.length === 0 ? ( + + + No machinery yet. + + + ) : ( + machines.flatMap( + (machine) => + machine.shops?.map( + (shopMachinery: { + shopMachineryId: string; + quantity: number; + shop: { shopId: string; name: string }; + }) => ( + + {machine.name} + {shopMachinery.shop.name} + + {shopMachinery.quantity.toString()} + + + + + + + setEditMachinery({ + machineryId: machine.machineryId, + shopId: shopMachinery.shop.shopId + }) + } + > + + + + + + + + + setMachineryToDelete({ + machineryId: machine.machineryId, + machineName: machine.name + }) + } + > + + + + + + + + ) + ) || [] + ) + )} + +
+
+
+
+ + {/* Delete Calendars Modal */} + setCalendarToDelete(undefined)} + formId="delete-calendar-form" + dataType={calendarToDelete?.name || ''} + onFormSubmit={handleCalendarDelete} + /> + + {/* Create Calendar Modal */} + setOpenCreateCalendar(false)} + onSubmit={async ({ name, description, colorHexCode }) => { + await createCalendarMutate({ + name, + description, + colorHexCode + }); + setOpenCreateCalendar(false); + }} + /> + + {/* Edit Calendar Modal */} + {editingCalendarId && ( + { + setOpenEditCalendar(false); + setEditingCalendar(undefined); + setEditingCalendarId(undefined); + }} + initialValues={{ + name: editingCalendar?.name ?? '', + description: editingCalendar?.description ?? '', + colorHexCode: editingCalendar?.color ?? '' + }} + onSubmit={async ({ name, description, colorHexCode }) => { + if (!editingCalendarId) return; + + await editCalendarMutation.mutateAsync({ + name, + description, + colorHexCode + }); + setOpenEditCalendar(false); + setEditingCalendar(undefined); + setEditingCalendarId(undefined); + }} + /> + )} + + {/* Add Shop Modal */} + setOpenCreate(false)} + onSubmit={async ({ name, description }) => { + const result = await createShopMutate({ name, description }); + setOpenCreate(false); + return result; + }} + /> + + {/* Delete Shop Modal */} + setShopToDelete(undefined)} + formId="delete-shop-form" + dataType={shopToDelete?.name || ''} + onFormSubmit={handleShopDelete} + /> + + {/* Create Machine Modal */} + setOpenCreateMachinery(false)} /> + + {/* Edit Machine Modal */} + {editMachinery && + machines && + (() => { + const selectedMachine = machines.find((m) => m.machineryId === editMachinery.machineryId); + if (!selectedMachine) return null; + + const selectedShopMachinery = selectedMachine.shops?.find( + (sm: { shop: { shopId: string } }) => sm.shop.shopId === editMachinery.shopId + ); + + if (!selectedShopMachinery) return null; + + const machineryForEdit: typeof selectedMachine = { + ...selectedMachine, + shops: [selectedShopMachinery] + }; + + return setEditMachinery(null)} machinery={machineryForEdit} />; + })()} + + {/* Edit Shop Modal */} + { + setOpenEdit(false); + setEditingShop(null); + setEditingShopId(undefined); + }} + initialValues={{ + name: editingShop?.name ?? '', + description: editingShop?.description ?? '' + }} + onSubmit={async ({ name, description }) => { + if (!editingShopId) return; + await editShopMutation.mutateAsync({ name, description }); + setOpenEdit(false); + setEditingShop(null); + setEditingShopId(undefined); + }} + /> + + {/* Delete Machinery */} + setMachineryToDelete(null)} + formId="delete-machinery-form" + dataType={`machine ${machineryToDelete?.machineName || ''}`} + onFormSubmit={handleDeleteMachinery} + /> + + {/* Create Event Type Modal */} + setOpenCreateEventType(false)} /> + + {/* Edit Event Type Modal */} + {editingEventType && ( + setEditingEventType(null)} eventType={editingEventType} /> + )} + + {/* Delete Event Type Modal */} + setEventTypeToDelete(undefined)} + formId="delete-event-type-form" + dataType={`event type "${eventTypeToDelete?.name || ''}"`} + onFormSubmit={handleEventTypeDelete} + /> +
+ ); +}; + +export default AdminToolsScheduleConfig; diff --git a/src/frontend/src/pages/AdminToolsPage/ScheduleConfig/Calendar/CalendarModal.tsx b/src/frontend/src/pages/AdminToolsPage/ScheduleConfig/Calendar/CalendarModal.tsx new file mode 100644 index 0000000000..a1a8079b45 --- /dev/null +++ b/src/frontend/src/pages/AdminToolsPage/ScheduleConfig/Calendar/CalendarModal.tsx @@ -0,0 +1,161 @@ +import React from 'react'; +import { Box, FormControl, FormHelperText, Typography, Stack } from '@mui/material'; +import NERFormModal from '../../../../components/NERFormModal'; +import ReactHookTextField from '../../../../components/ReactHookTextField'; +import { useToast } from '../../../../hooks/toasts.hooks'; +import { useForm } from 'react-hook-form'; +import * as yup from 'yup'; +import { yupResolver } from '@hookform/resolvers/yup'; +import type { Calendar } from 'shared'; + +export interface CalendarFormValues { + name: string; + description: string; + colorHexCode: string; +} + +const schema = yup.object({ + name: yup.string().required('Calendar Name is required'), + description: yup.string().required('Description is required'), + colorHexCode: yup.string().required('Color is required') +}); + +export interface BaseCalendarModalProps { + open: boolean; + onClose: () => void; + onSubmit: (data: CalendarFormValues) => Promise | Calendar | unknown; + initialValues?: Partial; +} + +const COLOR_OPTIONS: { label: string; value: string }[] = [ + { label: 'Red', value: '#EF4444' }, + { label: 'Orange', value: '#F97316' }, + { label: 'Green', value: '#22C55E' }, + { label: 'Blue', value: '#3B82F6' }, + { label: 'Purple', value: '#A855F7' }, + { label: 'Navy', value: '#1E3A8A' } +]; + +const CalendarModal: React.FC = ({ open, onClose, onSubmit, initialValues }) => { + const toast = useToast(); + + const { + handleSubmit, + control, + reset, + watch, + setValue, + formState: { errors } + } = useForm({ + resolver: yupResolver(schema), + defaultValues: { name: '', description: '', colorHexCode: '' } + }); + + const frozenValuesRef = React.useRef({ name: '', description: '', colorHexCode: '' }); + + React.useEffect(() => { + if (open) { + frozenValuesRef.current = { + name: initialValues?.name ?? '', + description: initialValues?.description ?? '', + colorHexCode: initialValues?.colorHexCode ?? '' + }; + reset(frozenValuesRef.current); + } else { + frozenValuesRef.current = { name: '', description: '', colorHexCode: '' }; + reset(frozenValuesRef.current); + } + }, [open, initialValues, reset]); + + const computedTitle = frozenValuesRef.current.name !== '' ? 'Edit Calendar' : 'Create Calendar'; + + const onFormSubmit = async (data: CalendarFormValues) => { + try { + await onSubmit(data); + onClose(); + reset({ name: '', description: '', colorHexCode: '' }); + } catch (e: unknown) { + if (e instanceof Error) toast.error(e.message); + } + }; + + const selectedColor = watch('colorHexCode'); + + const handleColorClick = (value: string) => { + setValue('colorHexCode', value, { shouldValidate: true }); + }; + + return ( + { + onClose(); + reset({ name: '', description: '', colorHexCode: '' }); + }} + title={computedTitle} + reset={() => reset({ name: '', description: '', colorHexCode: '' })} + handleUseFormSubmit={handleSubmit} + onFormSubmit={onFormSubmit} + formId="calendar-form" + showCloseButton + > + + + + Calendar:* + + + {errors.name?.message} + + + + + Description:* + + + {errors.description?.message} + + + + + Color:* + + + {COLOR_OPTIONS.map((c) => { + const isSelected = c.value === selectedColor; + return ( + handleColorClick(c.value)} + sx={{ + cursor: 'pointer', + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + px: 1.5, + height: 28, + borderRadius: '999px', + backgroundColor: c.value, + border: isSelected ? '2px solid #ef4345' : '2px solid transparent', + boxSizing: 'border-box', + minWidth: 32 + }} + /> + ); + })} + + {errors.colorHexCode?.message} + + + + ); +}; + +export default CalendarModal; diff --git a/src/frontend/src/pages/AdminToolsPage/ScheduleConfig/Calendar/CreateCalendarModal.tsx b/src/frontend/src/pages/AdminToolsPage/ScheduleConfig/Calendar/CreateCalendarModal.tsx new file mode 100644 index 0000000000..24851249c1 --- /dev/null +++ b/src/frontend/src/pages/AdminToolsPage/ScheduleConfig/Calendar/CreateCalendarModal.tsx @@ -0,0 +1,13 @@ +import CalendarModal, { CalendarFormValues } from './CalendarModal'; + +interface CreateCalendarModalProps { + open: boolean; + onClose: () => void; + onSubmit: (data: CalendarFormValues) => Promise | unknown; +} + +const CreateCalendarModal: React.FC = (props) => { + return ; +}; + +export default CreateCalendarModal; diff --git a/src/frontend/src/pages/AdminToolsPage/ScheduleConfig/Calendar/EditCalendarModal.tsx b/src/frontend/src/pages/AdminToolsPage/ScheduleConfig/Calendar/EditCalendarModal.tsx new file mode 100644 index 0000000000..902945f4d4 --- /dev/null +++ b/src/frontend/src/pages/AdminToolsPage/ScheduleConfig/Calendar/EditCalendarModal.tsx @@ -0,0 +1,14 @@ +import CalendarModal, { CalendarFormValues } from './CalendarModal'; + +export interface EditCalendarModalProps { + open: boolean; + onClose: () => void; + onSubmit: (data: CalendarFormValues) => Promise | unknown; + initialValues: Partial; +} + +const EditCalendarModal: React.FC = ({ open, onClose, onSubmit, initialValues }) => { + return ; +}; + +export default EditCalendarModal; diff --git a/src/frontend/src/pages/AdminToolsPage/ScheduleConfig/EventType/CreateEventTypeModal.tsx b/src/frontend/src/pages/AdminToolsPage/ScheduleConfig/EventType/CreateEventTypeModal.tsx new file mode 100644 index 0000000000..637fe338d0 --- /dev/null +++ b/src/frontend/src/pages/AdminToolsPage/ScheduleConfig/EventType/CreateEventTypeModal.tsx @@ -0,0 +1,37 @@ +import ErrorPage from '../../../ErrorPage'; +import LoadingIndicator from '../../../../components/LoadingIndicator'; +import { useCreateEventType } from '../../../../hooks/calendar.hooks'; +import { useToast } from '../../../../hooks/toasts.hooks'; +import EventTypeFormModal, { EventTypeFormValues } from './EventTypeFormModal'; + +interface CreateEventTypeModalProps { + open: boolean; + onClose: () => void; +} + +const CreateEventTypeModal = ({ open, onClose }: CreateEventTypeModalProps) => { + const { isLoading, isError, error, mutateAsync: createEventType } = useCreateEventType(); + const toast = useToast(); + + if (isError) return ; + if (isLoading) return ; + + const onSubmit = async (data: EventTypeFormValues) => { + try { + const result = await createEventType(data); + toast.success('Event type created successfully'); + return result; + } catch (e: unknown) { + if (e instanceof Error) { + toast.error(e.message); + } else { + toast.error('An error occurred while creating the event type'); + } + throw e; + } + }; + + return ; +}; + +export default CreateEventTypeModal; diff --git a/src/frontend/src/pages/AdminToolsPage/ScheduleConfig/EventType/EditEventTypeModal.tsx b/src/frontend/src/pages/AdminToolsPage/ScheduleConfig/EventType/EditEventTypeModal.tsx new file mode 100644 index 0000000000..e0e7555356 --- /dev/null +++ b/src/frontend/src/pages/AdminToolsPage/ScheduleConfig/EventType/EditEventTypeModal.tsx @@ -0,0 +1,68 @@ +import ErrorPage from '../../../ErrorPage'; +import LoadingIndicator from '../../../../components/LoadingIndicator'; +import { useEditEventType } from '../../../../hooks/calendar.hooks'; +import { useToast } from '../../../../hooks/toasts.hooks'; +import { EventType } from 'shared'; +import EventTypeFormModal, { EventTypeFormValues } from './EventTypeFormModal'; + +interface EditEventTypeModalProps { + open: boolean; + onClose: () => void; + eventType: EventType; +} + +const EditEventTypeModal = ({ open, onClose, eventType }: EditEventTypeModalProps) => { + const { + isLoading: isEditing, + isError: isEditError, + error: editError, + mutateAsync: editEventType + } = useEditEventType(eventType.eventTypeId); + const toast = useToast(); + + const isLoading = isEditing; + const isError = isEditError; + const error = editError; + + const eventTypeData: EventTypeFormValues = { + name: eventType.name, + calendarIds: eventType.calendarIds || [], + requiredMembers: eventType.requiredMembers, + optionalMembers: eventType.optionalMembers, + teams: eventType.teams, + teamType: eventType.teamType || false, + location: eventType.location, + zoomLink: eventType.zoomLink, + shop: eventType.shop, + machinery: eventType.machinery, + workPackage: eventType.workPackage, + questionDocument: eventType.questionDocument || false, + documents: eventType.documents, + description: eventType.description, + onlyHeadsOrAbove: eventType.onlyHeadsOrAboveForEventCreation, + requiresConfirmation: eventType.requiresConfirmation || false, + sendSlackNotifications: eventType.sendSlackNotifications || false + }; + + if (isError) return ; + if (isLoading) return ; + + const onSubmit = async (data: EventTypeFormValues) => { + try { + const result = await editEventType(data); + toast.success('Event type updated successfully'); + return result; + } catch (e: unknown) { + if (e instanceof Error) { + toast.error(e.message); + } else { + toast.error('An error occurred while updating the event type'); + } + throw e; + } + }; + + return ; +}; + +export default EditEventTypeModal; diff --git a/src/frontend/src/pages/AdminToolsPage/ScheduleConfig/EventType/EventTypeFormModal.tsx b/src/frontend/src/pages/AdminToolsPage/ScheduleConfig/EventType/EventTypeFormModal.tsx new file mode 100644 index 0000000000..6f952c6985 --- /dev/null +++ b/src/frontend/src/pages/AdminToolsPage/ScheduleConfig/EventType/EventTypeFormModal.tsx @@ -0,0 +1,847 @@ +import { Box, FormHelperText, Typography, Checkbox, FormControl, Select, MenuItem } from '@mui/material'; +import NERFormModal from '../../../../components/NERFormModal'; +import ReactHookTextField from '../../../../components/ReactHookTextField'; +import { useToast } from '../../../../hooks/toasts.hooks'; +import { useForm, Controller } from 'react-hook-form'; +import * as yup from 'yup'; +import { yupResolver } from '@hookform/resolvers/yup'; +import React from 'react'; +import { EventType } from 'shared'; +import useFormPersist from 'react-hook-form-persist'; +import { FormStorageKey } from '../../../../utils/form'; +import { useAllCalendars } from '../../../../hooks/calendar.hooks'; +import CalendarTodayIcon from '@mui/icons-material/CalendarToday'; +import AccessTimeIcon from '@mui/icons-material/AccessTime'; +import PeopleIcon from '@mui/icons-material/People'; +import LocationOnIcon from '@mui/icons-material/LocationOn'; +import VideocamIcon from '@mui/icons-material/Videocam'; +import BuildIcon from '@mui/icons-material/Build'; +import InventoryIcon from '@mui/icons-material/Inventory'; +import DescriptionIcon from '@mui/icons-material/Description'; +import WorkOutlineIcon from '@mui/icons-material/WorkOutline'; +import GroupsIcon from '@mui/icons-material/Groups'; +import PersonAddIcon from '@mui/icons-material/PersonAdd'; +import HelpOutlineIcon from '@mui/icons-material/HelpOutline'; +import SupervisorAccountIcon from '@mui/icons-material/SupervisorAccount'; +import CheckCircleIcon from '@mui/icons-material/CheckCircle'; +import NotificationsIcon from '@mui/icons-material/Notifications'; + +export interface EventTypeFormValues { + name: string; + calendarIds: string[]; + requiredMembers: boolean; + optionalMembers: boolean; + teams: boolean; + teamType: boolean; + location: boolean; + zoomLink: boolean; + shop: boolean; + machinery: boolean; + workPackage: boolean; + questionDocument: boolean; + documents: boolean; + description: boolean; + onlyHeadsOrAbove: boolean; + requiresConfirmation: boolean; + sendSlackNotifications: boolean; +} + +const eventTypeSchema = yup.object({ + name: yup.string().required('Event Type name is required'), + calendarIds: yup.array().of(yup.string()).required(), + requiredMembers: yup.boolean().required(), + optionalMembers: yup.boolean().required(), + teams: yup.boolean().required(), + teamType: yup.boolean().required(), + location: yup.boolean().required(), + zoomLink: yup.boolean().required(), + shop: yup.boolean().required(), + machinery: yup.boolean().required(), + workPackage: yup.boolean().required(), + questionDocument: yup.boolean().required(), + documents: yup.boolean().required(), + description: yup.boolean().required(), + onlyHeadsOrAbove: yup.boolean().required(), + requiresConfirmation: yup.boolean().required(), + sendSlackNotifications: yup.boolean().required() +}); + +interface EventTypeFormModalProps { + open: boolean; + onClose: () => void; + onSubmit: (data: EventTypeFormValues) => Promise; + initialValues?: EventTypeFormValues; +} + +export const EventTypeFormModal: React.FC = ({ open, onClose, onSubmit, initialValues }) => { + const toast = useToast(); + const { data: calendars } = useAllCalendars(); + + const defaultValues: EventTypeFormValues = { + name: '', + calendarIds: [], + requiredMembers: false, + optionalMembers: true, + teams: false, + teamType: false, + location: true, + zoomLink: true, + shop: false, + machinery: false, + workPackage: false, + questionDocument: false, + documents: false, + description: true, + onlyHeadsOrAbove: false, + requiresConfirmation: false, + sendSlackNotifications: false + }; + + const { + handleSubmit, + control, + reset, + formState: { errors }, + watch, + setValue + } = useForm({ + resolver: yupResolver(eventTypeSchema) as any, + defaultValues: initialValues || defaultValues + }); + + const formStorageKey = initialValues ? FormStorageKey.EDIT_EVENT_TYPE : FormStorageKey.CREATE_EVENT_TYPE; + + useFormPersist(formStorageKey, { + watch, + setValue + }); + + const onFormSubmit = async (data: EventTypeFormValues) => { + try { + await onSubmit(data); + onClose(); + if (!initialValues) { + reset(defaultValues); + } + } catch (e: unknown) { + if (e instanceof Error) { + toast.error(e.message); + } else { + toast.error('An error occurred while saving the event type'); + } + } + }; + + const handleCancel = () => { + reset(defaultValues); + sessionStorage.removeItem(formStorageKey); + onClose(); + }; + + return ( + reset(initialValues || defaultValues)} + handleUseFormSubmit={handleSubmit} + onFormSubmit={onFormSubmit} + formId={initialValues ? 'edit-event-type-form' : 'create-event-type-form'} + showCloseButton + > + + {/* Add New Event Type Input */} + + + + + {errors.name?.message} + + + {/* Calendar Selection */} + + + + ( + + )} + /> + {errors.calendarIds?.message} + + + + + Select the fields from this template to be included in the new event type. + + + {/* Initial Date Scheduled Field - Always checked */} + + + + + + Monday, March 17 2:00pm - 3:00pm + + + + + All Day + + + + + + + {/* Required Members Field */} + + ( + + )} + /> + + + + + + Required Member + + × + + + + Add Required Member + + + + + + + {/* Optional Members Field */} + + ( + + )} + /> + + + + + + Ethan Herrell + + × + + {/* Add Member button (visual only) */} + + + Add Member + + + + + + + {/* Teams Field */} + + ( + + )} + /> + + + + Select Team + ▼ + + + + + {/*Team Types Field */} + + ( + + )} + /> + + + + Select Team Type + ▼ + + + + + {/* Location Field */} + + ( + + )} + /> + + + + Location + + + + + {/* Zoom Link Field */} + + ( + + )} + /> + + + + Zoom Link + + + + + {/* Availability Field */} + + ( + + )} + /> + + + + + + Add Availability + + + {/* View Availability button (visual only) */} + + + View Availability + + + + + + + {/* Shop Field */} + + ( + + )} + /> + + + + Select Shop + ▼ + + + + + {/* Machinery Field */} + + ( + + )} + /> + + + + Select Machinery + ▼ + + + + + {/* Work Package Field */} + + ( + + )} + /> + + + + Select Work Package + ▼ + + + + + {/* Question Document Field */} + + ( + + )} + /> + + + + Select Question Document + ▼ + + + + + {/* Documents Field */} + + ( + + )} + /> + + + + Documents + + + + + Competitions.pdf + + × + + + + Upload + + + + + + + {/* Description/Attachment Field */} + + ( + + )} + /> + + + + Add a description + + + + + {/* Only Heads Or Above Field */} + + ( + + )} + /> + + + + Only Heads Or Above Can Create Events + + + + + {/* Requires Confirmation Field */} + + ( + + )} + /> + + + + Requires Confirmation + + + + + {/* Send Slack Notifications Field */} + + ( + + )} + /> + + + + Send Slack Notifications + + + + + + ); +}; + +export default EventTypeFormModal; diff --git a/src/frontend/src/pages/AdminToolsPage/ScheduleConfig/Machinery/CreateMachineryModal.tsx b/src/frontend/src/pages/AdminToolsPage/ScheduleConfig/Machinery/CreateMachineryModal.tsx new file mode 100644 index 0000000000..963444dfe8 --- /dev/null +++ b/src/frontend/src/pages/AdminToolsPage/ScheduleConfig/Machinery/CreateMachineryModal.tsx @@ -0,0 +1,39 @@ +import ErrorPage from '../../../ErrorPage'; +import LoadingIndicator from '../../../../components/LoadingIndicator'; +import { useCreateMachinery, MACHINERY_KEY } from '../../../../hooks/calendar.hooks'; +import { postAddMachineryToShop } from '../../../../apis/calendar.api'; +import { useQueryClient } from 'react-query'; +import MachineryFormModal from './MachineryFormModal'; + +interface CreateMachineryModalProps { + open: boolean; + onClose: () => void; +} + +const CreateMachineryModal = ({ open, onClose }: CreateMachineryModalProps) => { + const { isLoading, isError, error, mutateAsync: createMachinery } = useCreateMachinery(); + const queryClient = useQueryClient(); + + if (isError) return ; + if (isLoading) return ; + + const onSubmit = async (data: { shopId: string; machineName: string; quantity: number }) => { + const { machineName, shopId, quantity } = data; + // First create the machinery + const createdMachinery = await createMachinery({ machineName }); + // Then add it to the shop + const result = await postAddMachineryToShop({ + machineryId: createdMachinery.machineryId, + shopId, + quantity + }); + // Invalidate and refetch to ensure UI updates immediately + await queryClient.invalidateQueries(MACHINERY_KEY); + await queryClient.refetchQueries(MACHINERY_KEY); + return result; + }; + + return ; +}; + +export default CreateMachineryModal; diff --git a/src/frontend/src/pages/AdminToolsPage/ScheduleConfig/Machinery/EditMachineryModal.tsx b/src/frontend/src/pages/AdminToolsPage/ScheduleConfig/Machinery/EditMachineryModal.tsx new file mode 100644 index 0000000000..1b5c6dafa9 --- /dev/null +++ b/src/frontend/src/pages/AdminToolsPage/ScheduleConfig/Machinery/EditMachineryModal.tsx @@ -0,0 +1,81 @@ +import ErrorPage from '../../../ErrorPage'; +import LoadingIndicator from '../../../../components/LoadingIndicator'; +import { useEditMachinery, useAddMachineryToShop, MACHINERY_KEY } from '../../../../hooks/calendar.hooks'; +import { postAddMachineryToShop } from '../../../../apis/calendar.api'; +import { useQueryClient } from 'react-query'; +import { Machinery } from 'shared'; +import MachineryFormModal from './MachineryFormModal'; +import { MachineryFormValues } from './MachineryFormModal'; + +interface EditMachineryModalProps { + open: boolean; + onClose: () => void; + machinery: Machinery; +} + +const EditMachineryModal = ({ open, onClose, machinery }: EditMachineryModalProps) => { + const shopMachinery = machinery.shops?.[0]; + const originalShopId = shopMachinery?.shop?.shopId || ''; + const queryClient = useQueryClient(); + + const { + isLoading: isEditing, + isError: isEditError, + error: editError, + mutateAsync: editMachinery + } = useEditMachinery(machinery.machineryId); + const { + isLoading: isAdding, + isError: isAddError, + error: addError, + mutateAsync: addMachineryToShop + } = useAddMachineryToShop(machinery.machineryId); + + const isLoading = isEditing || isAdding; + const isError = isEditError || isAddError; + const error = editError || addError; + + const machineryData: MachineryFormValues = { + shopId: originalShopId, + machineName: machinery.name, + quantity: shopMachinery?.quantity || 1 + }; + + if (isError) return ; + if (isLoading) return ; + + const onSubmit = async (data: { shopId: string; machineName: string; quantity: number }) => { + const { machineName, shopId, quantity } = data; + let currentMachineryId = machinery.machineryId; + + // Check if name changed - this may merge with another machinery + if (machineName !== machinery.name) { + const updatedMachinery = await editMachinery({ machineName }); + currentMachineryId = updatedMachinery.machineryId; + } + + // Update shop/quantity relationship using the current machinery ID + if (currentMachineryId !== machinery.machineryId) { + // Use the API directly since the machinery ID changed after merge + const result = await postAddMachineryToShop({ + machineryId: currentMachineryId, + shopId, + quantity, + originalShopId: originalShopId || undefined + }); + queryClient.invalidateQueries(MACHINERY_KEY); + return result; + } + + // Same machinery ID, use the hook as normal + return await addMachineryToShop({ + shopId, + quantity, + originalShopId: originalShopId || undefined + }); + }; + + return ; +}; + +export default EditMachineryModal; diff --git a/src/frontend/src/pages/AdminToolsPage/ScheduleConfig/Machinery/MachineryFormModal.tsx b/src/frontend/src/pages/AdminToolsPage/ScheduleConfig/Machinery/MachineryFormModal.tsx new file mode 100644 index 0000000000..a6482a9a38 --- /dev/null +++ b/src/frontend/src/pages/AdminToolsPage/ScheduleConfig/Machinery/MachineryFormModal.tsx @@ -0,0 +1,159 @@ +import { Box, FormControl, FormHelperText, Typography, MenuItem, Select } from '@mui/material'; +import NERFormModal from '../../../../components/NERFormModal'; +import ReactHookTextField from '../../../../components/ReactHookTextField'; +import { useToast } from '../../../../hooks/toasts.hooks'; +import { useAllShops } from '../../../../hooks/calendar.hooks'; +import { useForm, Controller } from 'react-hook-form'; +import * as yup from 'yup'; +import { yupResolver } from '@hookform/resolvers/yup'; +import LoadingIndicator from '../../../../components/LoadingIndicator'; +import { useEffect } from 'react'; +import { Machinery } from 'shared'; +import useFormPersist from 'react-hook-form-persist'; +import { FormStorageKey } from '../../../../utils/form'; + +export interface MachineryFormValues { + shopId: string; + machineName: string; + quantity: number; +} + +const createSchema = yup.object({ + shopId: yup.string().required('Shop is required'), + machineName: yup.string().required('Machine Name is required'), + quantity: yup.number().required('Quantity is required').min(1, 'Quantity must be at least 1') +}); + +const editSchema = yup.object({ + shopId: yup.string().required('Shop is required'), + machineName: yup.string().required('Machine Name is required'), + quantity: yup.number().required('Quantity is required').min(0, 'Quantity cannot be negative') +}); + +interface MachineryFormModalProps { + open: boolean; + onClose: () => void; + onSubmit: (data: MachineryFormValues) => Promise; + initialValues?: MachineryFormValues; +} + +export const MachineryFormModal: React.FC = ({ open, onClose, onSubmit, initialValues }) => { + const toast = useToast(); + const { isLoading, data: shops } = useAllShops(); + + const defaultValues = { shopId: '', machineName: '', quantity: 1 }; + + const { + handleSubmit, + control, + reset, + formState: { errors }, + watch, + setValue + } = useForm({ + resolver: yupResolver(initialValues ? editSchema : createSchema), + defaultValues: initialValues || defaultValues + }); + + const formStorageKey = initialValues ? FormStorageKey.EDIT_MACHINERY : FormStorageKey.CREATE_MACHINERY; + + useFormPersist(formStorageKey, { + watch, + setValue + }); + + useEffect(() => { + if (initialValues) { + reset(initialValues); + } + }, [initialValues, reset]); + + const onFormSubmit = async (data: MachineryFormValues) => { + try { + await onSubmit(data); + // Only close and reset on success + onClose(); + if (!initialValues) { + reset(defaultValues); + } + } catch (e: unknown) { + // Show error but don't close modal + if (e instanceof Error) { + toast.error(e.message); + } else { + toast.error('An error occurred while saving the machinery'); + } + // Don't close modal on error so user can fix and retry + } + }; + + const handleCancel = () => { + reset(defaultValues); + sessionStorage.removeItem(formStorageKey); + onClose(); + }; + + if (isLoading || !shops) return ; + + return ( + reset(initialValues || defaultValues)} + handleUseFormSubmit={handleSubmit} + onFormSubmit={onFormSubmit} + formId={initialValues ? 'edit-machinery-form' : 'create-machinery-form'} + showCloseButton + > + + + + Shop:* + + ( + + )} + /> + {errors.shopId?.message} + + + + + Machine:* + + + {errors.machineName?.message} + + + + + # of Machines:* + + + {errors.quantity?.message} + + + + ); +}; + +export default MachineryFormModal; diff --git a/src/frontend/src/pages/AdminToolsPage/ScheduleConfig/Shop/AddShopModal.tsx b/src/frontend/src/pages/AdminToolsPage/ScheduleConfig/Shop/AddShopModal.tsx new file mode 100644 index 0000000000..6e78a9ec89 --- /dev/null +++ b/src/frontend/src/pages/AdminToolsPage/ScheduleConfig/Shop/AddShopModal.tsx @@ -0,0 +1,13 @@ +import ShopModal, { ShopFormValues } from './ShopModal'; + +interface CreateShopModalProps { + open: boolean; + onClose: () => void; + onSubmit: (data: ShopFormValues) => Promise | unknown; +} + +const CreateShopModal: React.FC = (props) => { + return ; +}; + +export default CreateShopModal; diff --git a/src/frontend/src/pages/AdminToolsPage/ScheduleConfig/Shop/EditShopModal.tsx b/src/frontend/src/pages/AdminToolsPage/ScheduleConfig/Shop/EditShopModal.tsx new file mode 100644 index 0000000000..5a3ed2192b --- /dev/null +++ b/src/frontend/src/pages/AdminToolsPage/ScheduleConfig/Shop/EditShopModal.tsx @@ -0,0 +1,14 @@ +import ShopModal, { ShopFormValues } from './ShopModal'; + +export interface EditShopModalProps { + open: boolean; + onClose: () => void; + onSubmit: (data: ShopFormValues) => Promise | unknown; + initialValues: Partial; +} + +const EditShopModal: React.FC = ({ open, onClose, onSubmit, initialValues }) => { + return ; +}; + +export default EditShopModal; diff --git a/src/frontend/src/pages/AdminToolsPage/ScheduleConfig/Shop/ShopModal.tsx b/src/frontend/src/pages/AdminToolsPage/ScheduleConfig/Shop/ShopModal.tsx new file mode 100644 index 0000000000..5b0e2311c2 --- /dev/null +++ b/src/frontend/src/pages/AdminToolsPage/ScheduleConfig/Shop/ShopModal.tsx @@ -0,0 +1,112 @@ +import React from 'react'; +import { Box, FormControl, FormHelperText, Typography } from '@mui/material'; +import NERFormModal from '../../../../components/NERFormModal'; +import ReactHookTextField from '../../../../components/ReactHookTextField'; +import { useToast } from '../../../../hooks/toasts.hooks'; +import { useForm } from 'react-hook-form'; +import * as yup from 'yup'; +import { yupResolver } from '@hookform/resolvers/yup'; +import type { Shop } from 'shared'; + +export interface ShopFormValues { + name: string; + description: string; +} + +const schema = yup.object({ + name: yup.string().required('Shop Name is required'), + description: yup.string().required('Description is required') +}); + +export interface BaseShopModalProps { + open: boolean; + onClose: () => void; + // Accept both branches: some returns were Promise, others unknown + onSubmit: (data: ShopFormValues) => Promise | Shop | unknown; + initialValues?: Partial; +} + +const ShopModal: React.FC = ({ open, onClose, onSubmit, initialValues }) => { + const toast = useToast(); + + const { + handleSubmit, + control, + reset, + formState: { errors } + } = useForm({ + resolver: yupResolver(schema), + defaultValues: { name: '', description: '' } + }); + + const frozenValuesRef = React.useRef({ name: '', description: '' }); + + React.useEffect(() => { + if (open) { + frozenValuesRef.current = { + name: initialValues?.name ?? '', + description: initialValues?.description ?? '' + }; + reset(frozenValuesRef.current); + } else { + frozenValuesRef.current = { name: '', description: '' }; + reset(frozenValuesRef.current); + } + }, [open, initialValues, reset]); + + const computedTitle = + frozenValuesRef.current.name !== '' || frozenValuesRef.current.description !== '' ? 'Edit Shop' : 'Create Shop'; + + const onFormSubmit = async (data: ShopFormValues) => { + try { + await onSubmit(data); + onClose(); + reset({ name: '', description: '' }); + } catch (e: unknown) { + if (e instanceof Error) toast.error(e.message); + } + }; + + return ( + { + onClose(); + reset({ name: '', description: '' }); + }} + title={computedTitle} + reset={() => reset({ name: '', description: '' })} + handleUseFormSubmit={handleSubmit} + onFormSubmit={onFormSubmit} + formId="shop-form" + showCloseButton + > + + + + Shop:* + + + {errors.name?.message} + + + + + Description:* + + + {errors.description?.message} + + + + ); +}; + +export default ShopModal; diff --git a/src/frontend/src/pages/AdminToolsPage/TeamConfig/EditTeamSlackIdFormModal.tsx b/src/frontend/src/pages/AdminToolsPage/TeamConfig/EditTeamSlackIdFormModal.tsx index ab3f2bc57b..ec454ba3c1 100644 --- a/src/frontend/src/pages/AdminToolsPage/TeamConfig/EditTeamSlackIdFormModal.tsx +++ b/src/frontend/src/pages/AdminToolsPage/TeamConfig/EditTeamSlackIdFormModal.tsx @@ -4,7 +4,7 @@ import { FormControl, FormLabel, FormHelperText } from '@mui/material'; import ReactHookTextField from '../../../components/ReactHookTextField'; import * as yup from 'yup'; import { yupResolver } from '@hookform/resolvers/yup'; -import { TeamPreview } from 'shared'; +import { TeamBase } from 'shared'; import { useEffect } from 'react'; import { useEditTeamSlackId } from '../../../hooks/teams.hooks'; import LoadingIndicator from '../../../components/LoadingIndicator'; @@ -13,7 +13,7 @@ import { useToast } from '../../../hooks/toasts.hooks'; interface EditTeamSlackIdFormModalProps { open: boolean; handleClose: () => void; - team: TeamPreview; + team: TeamBase; } const schema = yup.object().shape({ diff --git a/src/frontend/src/pages/CalendarPage/AvailabilityScheduleView.tsx b/src/frontend/src/pages/CalendarPage/AvailabilityScheduleView.tsx new file mode 100644 index 0000000000..c8b3690e53 --- /dev/null +++ b/src/frontend/src/pages/CalendarPage/AvailabilityScheduleView.tsx @@ -0,0 +1,134 @@ +import { Table, TableBody, TableCell, TableContainer, TableHead, TableRow, Typography } from '@mui/material'; +import { Availability, Event, getDayOfWeek, getNextSevenDays, User } from 'shared'; +import React, { useState } from 'react'; +import { enumToArray, getBackgroundColor, NUMBER_OF_TIME_SLOTS, REVIEW_TIMES } from '../../utils/design-review.utils'; +import { datePipe } from '../../utils/pipes'; +import EventTimeSlot from './Components/EventTimeSlot'; + +interface AvailabilityScheduleViewProps { + availableUsers: Map; + unavailableUsers: Map; + usersToAvailabilities: Map; + setCurrentAvailableUsers: (val: User[]) => void; + setCurrentUnavailableUsers: (val: User[]) => void; + event: Event; + displayDate?: Date; +} + +const AvailabilityScheduleView: React.FC = ({ + availableUsers, + unavailableUsers, + usersToAvailabilities, + setCurrentAvailableUsers, + setCurrentUnavailableUsers, + event, + displayDate +}) => { + const totalUsers = usersToAvailabilities.size; + const [selectedTimeslot, setSelectedTimeslot] = useState(null); + // Use displayDate if provided, otherwise fall back to event's initial date + const initialDate = displayDate || event.initialDateScheduled || new Date(); + const potentialDays = getNextSevenDays(initialDate); + + const handleTimeslotClick = (index: number, _day: Date) => { + if (selectedTimeslot === index) { + setSelectedTimeslot(null); + setCurrentAvailableUsers([]); + setCurrentUnavailableUsers([]); + } else { + setSelectedTimeslot(index); + setCurrentAvailableUsers(availableUsers.get(index) || []); + setCurrentUnavailableUsers(unavailableUsers.get(index) || []); + } + }; + + // Populates the availableUsers map + for (let time = 0; time < NUMBER_OF_TIME_SLOTS; time++) { + availableUsers.set(time, []); + } + usersToAvailabilities.forEach((availabilities, user) => { + let i = 0; + availabilities.forEach((availability) => { + availability.availability.forEach((time) => { + const usersAtTime = availableUsers.get(enumToArray(REVIEW_TIMES).length * i + time) || []; + usersAtTime.push(user); + availableUsers.set(enumToArray(REVIEW_TIMES).length * i + time, usersAtTime); + }); + i++; + }); + }); + + // Populates the unavailableUsers map + const allUsers = [...usersToAvailabilities.keys()]; + for (let time = 0; time < NUMBER_OF_TIME_SLOTS; time++) { + const currentUsers = availableUsers.get(time) || []; + const currentUnavailableUsers = allUsers.filter((user) => !currentUsers.includes(user)); + unavailableUsers.set(time, currentUnavailableUsers); + } + + const stickyLeft = { + position: 'sticky', + left: 0, + zIndex: 2, + bgcolor: 'background.paper' + }; + + return ( + + + + + + {potentialDays.map((day) => ( + + + {getDayOfWeek(day) + ' ' + datePipe(day)} + + + ))} + + + + {enumToArray(REVIEW_TIMES).map((time, timeIndex) => ( + + + + {time} + + + {potentialDays.map((day, dayIndex) => { + const index = dayIndex * enumToArray(REVIEW_TIMES).length + timeIndex; + return ( + + handleTimeslotClick(index, day)} + /> + + ); + })} + + ))} + +
+
+ ); +}; + +export default AvailabilityScheduleView; diff --git a/src/frontend/src/pages/CalendarPage/Calendar.tsx b/src/frontend/src/pages/CalendarPage/Calendar.tsx deleted file mode 100644 index 82f6fc912d..0000000000 --- a/src/frontend/src/pages/CalendarPage/Calendar.tsx +++ /dev/null @@ -1,19 +0,0 @@ -/* - * 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 { Route, Switch } from 'react-router-dom'; -import { routes } from '../../utils/routes'; -import CalendarPage from './CalendarPage'; -import DesignReviewDetails from './DesignReviewDetailPage/DesignReviewDetails'; - -const Calendar: React.FC = () => { - return ( - - - - - ); -}; - -export default Calendar; diff --git a/src/frontend/src/pages/CalendarPage/CalendarComponents/CalendarDayCard.tsx b/src/frontend/src/pages/CalendarPage/CalendarComponents/CalendarDayCard.tsx deleted file mode 100644 index 97889ed529..0000000000 --- a/src/frontend/src/pages/CalendarPage/CalendarComponents/CalendarDayCard.tsx +++ /dev/null @@ -1,258 +0,0 @@ -import { Box, Card, CardContent, Grid, Link, Stack, Tooltip, Typography, useTheme } from '@mui/material'; -import { DesignReview, DesignReviewStatus, TeamType } from 'shared'; -import { meetingStartTimePipe } from '../../../utils/pipes'; -import ConstructionIcon from '@mui/icons-material/Construction'; -import WorkOutlineIcon from '@mui/icons-material/WorkOutline'; -import ElectricalServicesIcon from '@mui/icons-material/ElectricalServices'; -import TerminalIcon from '@mui/icons-material/Terminal'; -import { useState } from 'react'; -import DRCSummaryModal from '../DesignReviewSummaryModal'; -import { DesignReviewCreateModal } from '../DesignReviewCreateModal'; -import DynamicTooltip from '../../../components/DynamicTooltip'; -import { designReviewStatusColor } from '../../../utils/design-review.utils'; - -export const getTeamTypeIcon = (teamTypeName: string, isLarge?: boolean) => { - const teamIcons: Map = new Map([ - ['Software', ], - ['Business', ], - ['Electrical', ], - ['Mechanical', ] - ]); - return teamIcons.get(teamTypeName); -}; - -interface CalendarDayCardProps { - cardDate: Date; - events: DesignReview[]; - teamTypes: TeamType[]; -} - -const CalendarDayCard: React.FC = ({ cardDate, events, teamTypes }) => { - const [isCreateModalOpen, setIsCreateModalOpen] = useState(false); - const theme = useTheme(); - const DayCardTitle = () => ( - - - - {cardDate.getDate()} - - - - ); - - const EventCard = ({ event }: { event: DesignReview }) => { - const [isSummaryModalOpen, setIsSummaryModalOpen] = useState(false); - const [markedStatus, setMarkedStatus] = useState(event.status); - const name = event.wbsName; - - return ( - <> - setIsSummaryModalOpen(false)} - designReview={event} - teamTypes={teamTypes} - markedStatus={markedStatus} - setMarkedStatus={setMarkedStatus} - /> - { - e.stopPropagation(); - setIsSummaryModalOpen(true); - }} - sx={{ - position: 'relative', - zIndex: 2, - cursor: 'pointer' - }} - > - - 0 - ? meetingStartTimePipe(event.meetingTimes) - : '' - : 'UNCONFIRMED! THIS TIME IS SUBJECT TO CHANGE') - } - > - - {name} - - - - - - ); - }; - - const ExtraEventNote = ({ event }: { event: DesignReview }) => { - const [isSummaryModalOpen, setIsSummaryModalOpen] = useState(false); - const [markedStatus, setMarkedStatus] = useState(event.status); - - return ( - <> - setIsSummaryModalOpen(false)} - designReview={event} - teamTypes={teamTypes} - markedStatus={markedStatus} - setMarkedStatus={setMarkedStatus} - /> - { - setIsSummaryModalOpen(true); - }} - > - {event.wbsName + (event.meetingTimes.length > 0 ? ' - ' + meetingStartTimePipe(event.meetingTimes) : '')} - - - ); - }; - - const ExtraEventsCard = ({ extraEvents }: { extraEvents: DesignReview[] }) => { - const [showTooltip, setShowTooltip] = useState(false); - return ( - - - setShowTooltip(!showTooltip)} - placement="right" - sx={{ cursor: 'pointer' }} - PopperProps={{ - popperOptions: { - modifiers: [ - { - name: 'flip', - options: { - fallbackPlacements: ['top', 'bottom'], - padding: -1, - rootBoundary: 'document' - } - }, - { - name: 'offset', - options: { - offset: [0, -1] - } - } - ] - } - }} - arrow - title={ - - {extraEvents.map((event) => ( - - ))} - - } - > - - {'+' + extraEvents.length} - - - - - ); - }; - - const today = new Date().toDateString(); - const isCurrentDay = cardDate.toDateString() === today; - const isFutureDay = cardDate >= new Date(); - - return ( - <> - { - setIsCreateModalOpen(false); - }} - teamTypes={teamTypes} - defaultDate={cardDate} - /> - - { - if (isFutureDay || isCurrentDay) { - setIsCreateModalOpen(true); - } - }} - sx={{ - position: 'absolute', - width: '100%', - height: '100%', - zIndex: 1, - pointerEvents: 'auto' - }} - /> - - - {events.length < 3 ? ( - events.map((event) => ) - ) : ( - <> - - - - )} - - - - ); -}; -export default CalendarDayCard; diff --git a/src/frontend/src/pages/CalendarPage/CalendarComponents/MonthSelector.tsx b/src/frontend/src/pages/CalendarPage/CalendarComponents/MonthSelector.tsx deleted file mode 100644 index 730b511573..0000000000 --- a/src/frontend/src/pages/CalendarPage/CalendarComponents/MonthSelector.tsx +++ /dev/null @@ -1,53 +0,0 @@ -import { Box, MenuItem, TextField } from '@mui/material'; -import { Dispatch, SetStateAction } from 'react'; -import { enumToArray, MONTH_NAMES } from '../../../utils/design-review.utils'; - -interface MonthSelectorProps { - displayMonth: Date; - setDisplayMonth: Dispatch>; -} - -const MonthSelector: React.FC = ({ displayMonth, setDisplayMonth }) => { - // TODO change this to use Pagination instead of hardocding 50 years - const years = [...Array(50).keys()].map((num) => (num + 2024).toString()); - - return ( - - { - displayMonth.setMonth(Number(event.target.value)); - setDisplayMonth(new Date(displayMonth)); - }} - > - {enumToArray(MONTH_NAMES).map((month, index) => { - return ( - - {month} - - ); - })} - - - { - displayMonth.setFullYear(Number.parseInt(event.target.value)); - setDisplayMonth(new Date(displayMonth)); - }} - > - {years.map((year) => ( - - {year} - - ))} - - - ); -}; - -export default MonthSelector; diff --git a/src/frontend/src/pages/CalendarPage/CalendarDayCard.tsx b/src/frontend/src/pages/CalendarPage/CalendarDayCard.tsx new file mode 100644 index 0000000000..b30e1f10fa --- /dev/null +++ b/src/frontend/src/pages/CalendarPage/CalendarDayCard.tsx @@ -0,0 +1,519 @@ +import { useState } from 'react'; +import { Box, Card, CardContent, Grid, Stack, Tooltip, Typography, useTheme } from '@mui/material'; +import { Calendar, DayOfWeek, EventInstance, EventType } from 'shared'; +import ConstructionIcon from '@mui/icons-material/Construction'; +import WorkOutlineIcon from '@mui/icons-material/WorkOutline'; +import ElectricalServicesIcon from '@mui/icons-material/ElectricalServices'; +import TerminalIcon from '@mui/icons-material/Terminal'; +import CheckCircleIcon from '@mui/icons-material/CheckCircle'; +import HelpIcon from '@mui/icons-material/Help'; +import { EventClickContent } from './EventClickPopup'; +import EventPartialInfoView from './EventPartialInfoView'; +import EditEventModal from './Components/EditEventModal'; +import DeleteSeriesConfirmationModal from './Components/DeleteSeriesConfirmationModal'; +import NERDeleteModal from '../../components/NERDeleteModal'; +import { useDeleteEvent, useDeleteScheduleSlot } from '../../hooks/calendar.hooks'; +import { useToast } from '../../hooks/toasts.hooks'; + +export const getTeamTypeIcon = (teamTypeName: string, isLarge?: boolean) => { + const teamIcons: Map = new Map([ + ['Software', ], + ['Business', ], + ['Electrical', ], + ['Mechanical', ] + ]); + + return teamIcons.get(teamTypeName); +}; + +export const getStatusIcon = (status: string, isLarge?: boolean) => { + const statusIcons: Map = new Map([ + ['UNCONFIRMED', ], + ['CONFIRMED', ], + ['SCHEDULED', ], + ['DONE', ] + ]); + + return statusIcons.get(status); +}; + +interface CalendarDayCardProps { + cardDate: Date; + displayMonth: Date; + events: EventInstance[]; + eventTypes?: EventType[]; + calendars?: Calendar[]; + dayOfWeek?: DayOfWeek; + onCreateEventClick: (date: Date) => void; +} + +const CalendarDayCard: React.FC = ({ + cardDate, + displayMonth, + events, + eventTypes = [], + calendars = [], + dayOfWeek = DayOfWeek.MONDAY, + onCreateEventClick +}) => { + const theme = useTheme(); + + const today = new Date(); + today.setHours(0, 0, 0, 0); + const isCurrentDay = cardDate.toDateString() === today.toDateString(); + const isCurrentMonth = + cardDate.getMonth() === displayMonth.getMonth() && cardDate.getFullYear() === displayMonth.getFullYear(); + const isFutureDay = cardDate >= today; + const isClickable = isFutureDay || isCurrentDay; + + // Track hover state for stable hover effect + const [isHovered, setIsHovered] = useState(false); + + // Track which event's tooltip is locked open after clicking + const [lockedTooltipEventId, setLockedTooltipEventId] = useState(null); + const [showEditModal, setShowEditModal] = useState(false); + const [showDeleteModal, setShowDeleteModal] = useState(false); + const [showSeriesDeleteModal, setShowSeriesDeleteModal] = useState(false); + const [selectedEvent, setSelectedEvent] = useState(null); + const toast = useToast(); + + const { mutateAsync: deleteEvent } = useDeleteEvent(selectedEvent?.eventId ?? ''); + const { mutateAsync: deleteScheduleSlot } = useDeleteScheduleSlot( + selectedEvent?.eventId ?? '', + selectedEvent?.scheduleSlotId ?? '' + ); + + const handleEdit = (event: EventInstance) => { + setSelectedEvent(event); + setShowEditModal(true); + }; + + const handleDelete = (event: EventInstance) => { + setSelectedEvent(event); + if (event.recurring) { + setShowSeriesDeleteModal(true); + } else { + setShowDeleteModal(true); + } + }; + + const handleDeleteConfirm = async () => { + try { + setShowDeleteModal(false); + setLockedTooltipEventId(null); + await deleteEvent(); + toast.success('Event deleted successfully!'); + } catch (err) { + if (err instanceof Error) { + toast.error(err.message); + } + } + }; + + const handleSeriesDeleteConfirm = async (deleteEntireEvent: boolean) => { + try { + setShowSeriesDeleteModal(false); + setLockedTooltipEventId(null); + if (deleteEntireEvent) { + await deleteEvent(); + toast.success('Event deleted successfully!'); + } else { + await deleteScheduleSlot(); + toast.success('Event occurrence deleted successfully!'); + } + } catch (err) { + if (err instanceof Error) { + toast.error(err.message); + } + } + }; + + const DayCardTitle = () => ( + + + + {cardDate.getDate()} + + + + ); + + const EventCard = ({ event }: { event: EventInstance }) => { + const [isHovered, setIsHovered] = useState(false); + const [tooltipHovered, setTooltipHovered] = useState(false); + const specificEventType = eventTypes.find((eventType) => eventType.eventTypeId === event.eventTypeId); + const specificCalendar = calendars.find((calendar) => + calendar.eventTypes.some((eventType) => eventType.eventTypeId === specificEventType?.eventTypeId) + ); + + const bgColor = specificCalendar?.color ?? 'gray'; + const isLocked = lockedTooltipEventId === event.eventId; + const shouldBeOpen = isLocked || isHovered || tooltipHovered; + + return ( + setIsHovered(true)} + onMouseLeave={() => { + setTimeout(() => { + if (!isLocked && !tooltipHovered) { + setIsHovered(false); + } + }, 100); + }} + onClick={(e) => { + e.stopPropagation(); + setLockedTooltipEventId(event.eventId); + }} + sx={{ + position: 'relative', + zIndex: 2, + cursor: 'pointer' + }} + > + + setTooltipHovered(true)} onMouseLeave={() => setTooltipHovered(false)}> + setLockedTooltipEventId(null)} + onEdit={handleEdit} + onDelete={handleDelete} + clickedDate={cardDate} + /> + + } + slotProps={{ + popper: { sx: { zIndex: 1200 } }, + tooltip: { + sx: { + maxWidth: 'none', + borderRadius: 4, + p: 0, + cursor: 'pointer', + bgcolor: 'transparent', + boxShadow: '0 0 15px rgba(255, 255, 255, 1.0)' + } + }, + arrow: { + sx: { + color: theme.palette.grey[900], + fontSize: 16 + } + } + }} + PopperProps={{ + modifiers: [ + { + name: 'offset', + options: { + offset: [0, 4] + } + } + ] + }} + > + + {getTeamTypeIcon(event.teamType?.name ?? '')} {event.title} + + + +
+ ); + }; + + const ExtraEventItem = ({ event }: { event: EventInstance }) => { + const [isHovered, setIsHovered] = useState(false); + const [tooltipHovered, setTooltipHovered] = useState(false); + const isLocked = lockedTooltipEventId === event.eventId; + const shouldBeOpen = isLocked || isHovered || tooltipHovered; + + return ( + setTooltipHovered(true)} onMouseLeave={() => setTooltipHovered(false)}> + setLockedTooltipEventId(null)} + onEdit={handleEdit} + onDelete={handleDelete} + clickedDate={cardDate} + /> + + } + slotProps={{ + popper: { sx: { zIndex: 1300 } }, + tooltip: { + sx: { + maxWidth: 'none', + borderRadius: 4, + p: 0, + cursor: 'pointer', + bgcolor: 'transparent', + boxShadow: '0 0 15px rgba(255, 255, 255, 1.0)' + } + }, + arrow: { + sx: { + color: theme.palette.grey[900], + fontSize: 16 + } + } + }} + PopperProps={{ + modifiers: [ + { + name: 'offset', + options: { + offset: [0, 4] + } + } + ] + }} + > + setIsHovered(true)} + onMouseLeave={() => { + setTimeout(() => { + if (!isLocked && !tooltipHovered) { + setIsHovered(false); + } + }, 100); + }} + onClick={(e) => { + e.stopPropagation(); + setLockedTooltipEventId(event.eventId); + }} + > + {}} calendars={calendars} eventTypes={eventTypes} /> + + + ); + }; + + const ExtraEventsCard = ({ extraEvents }: { extraEvents: EventInstance[] }) => { + return ( + + + {extraEvents.map((event) => ( + + ))} + + } + slotProps={{ + popper: { sx: { zIndex: 1200 } }, + tooltip: { + sx: { + maxWidth: 'none', + borderRadius: 4, + p: 2, + bgcolor: theme.palette.grey[900], + boxShadow: '0 0 15px rgba(255, 255, 255, 1.0)' + } + }, + arrow: { + sx: { + color: theme.palette.grey[900], + fontSize: 16 + } + } + }} + PopperProps={{ + modifiers: [ + { + name: 'offset', + options: { + offset: [0, 4] + } + } + ] + }} + > + + + {'+' + extraEvents.length} + + + + + ); + }; + + return ( + <> + {/* Backdrop for locked tooltips to handle click-away */} + {lockedTooltipEventId && ( + setLockedTooltipEventId(null)} + /> + )} + + isClickable && setIsHovered(true)} + onMouseLeave={() => setIsHovered(false)} + sx={{ + position: 'relative', + backgroundColor: !isCurrentMonth ? '#1f1f1f' : isHovered ? '#383838' : '#2a2a2a', + borderRadius: 2, + width: '100%', + height: '100%', + border: isCurrentDay ? '2px solid gray' : 'none', + cursor: isClickable ? 'pointer' : 'default', + transition: 'background-color 0.15s ease-in-out', + opacity: isCurrentMonth ? 1 : 0.5 + }} + > + { + if (isClickable) onCreateEventClick(cardDate); + }} + sx={{ + position: 'absolute', + width: '100%', + height: '100%', + zIndex: 1, + pointerEvents: 'auto' + }} + /> + + + + {events.length === 1 ? ( + + ) : events.length === 2 ? ( + <> + + + + ) : events.length >= 3 ? ( + <> + + + + ) : null} + + + + {selectedEvent && showEditModal && ( + { + setShowEditModal(false); + setLockedTooltipEventId(null); + }} + event={selectedEvent} + eventTypes={eventTypes} + /> + )} + + {selectedEvent && showDeleteModal && ( + { + setShowDeleteModal(false); + setLockedTooltipEventId(null); + }} + formId="delete-event-form" + dataType={selectedEvent.title} + onFormSubmit={handleDeleteConfirm} + /> + )} + + {selectedEvent && showSeriesDeleteModal && ( + { + setShowSeriesDeleteModal(false); + setLockedTooltipEventId(null); + }} + onConfirm={handleSeriesDeleteConfirm} + eventTitle={selectedEvent.title} + totalSlots={selectedEvent.totalScheduledSlots} + /> + )} + + ); +}; + +export default CalendarDayCard; diff --git a/src/frontend/src/pages/CalendarPage/CalendarPage.tsx b/src/frontend/src/pages/CalendarPage/CalendarPage.tsx deleted file mode 100644 index d6e1b3d2af..0000000000 --- a/src/frontend/src/pages/CalendarPage/CalendarPage.tsx +++ /dev/null @@ -1,191 +0,0 @@ -/* - * 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 { useState } from 'react'; -import { Box, Grid, Stack, Tooltip, Typography, useMediaQuery, useTheme } from '@mui/material'; -import PageLayout from '../../components/PageLayout'; -import { DesignReview, DesignReviewStatus } from 'shared'; -import MonthSelector from './CalendarComponents/MonthSelector'; -import CalendarDayCard, { getTeamTypeIcon } from './CalendarComponents/CalendarDayCard'; -import { DAY_NAMES, enumToArray, calendarPaddingDays, daysInMonth } from '../../utils/design-review.utils'; -import ActionsMenu from '../../components/ActionsMenu'; -import { useAllDesignReviews } from '../../hooks/design-reviews.hooks'; -import ErrorPage from '../ErrorPage'; -import { useCurrentUser } from '../../hooks/users.hooks'; -import { datePipe } from '../../utils/pipes'; -import LoadingIndicator from '../../components/LoadingIndicator'; -import DRCSummaryModal from './DesignReviewSummaryModal'; -import { useAllTeamTypes } from '../../hooks/team-types.hooks'; -import HelpOutlineIcon from '@mui/icons-material/HelpOutline'; - -const CalendarPage = () => { - const theme = useTheme(); - const { - data: allTeamTypes, - isLoading: allTeamTypesLoading, - isError: allTeamTypesIsError, - error: allTeamTypesError - } = useAllTeamTypes(); - - const [displayMonthYear, setDisplayMonthYear] = useState(new Date()); - const { isLoading, isError, error, data: allDesignReviews } = useAllDesignReviews(); - const user = useCurrentUser(); - const [unconfirmedDesignReview, setUnconfirmedDesignReview] = useState(); - const isLargerView = useMediaQuery(theme.breakpoints.up('md')); - const isExtraSmallView = useMediaQuery(theme.breakpoints.down('sm')); - - if (isLoading || !allDesignReviews) return ; - if (isError) return ; - - const confirmedDesignReviews = allDesignReviews; - - const eventDict = new Map(); - confirmedDesignReviews.sort((designReview1, designReview2) => { - if (designReview1.dateScheduled.getTime() === designReview2.dateScheduled.getTime()) { - return designReview1.meetingTimes[0] - designReview2.meetingTimes[0]; - } - return designReview1.dateScheduled.getTime() - designReview2.dateScheduled.getTime(); - }); - - confirmedDesignReviews.forEach((designReview) => { - // Accessing the date actually converts it to local time, which causes the date to be off. This is a workaround. - const date = datePipe( - new Date(designReview.dateScheduled.getTime() - designReview.dateScheduled.getTimezoneOffset() * -60000) - ); - if (eventDict.has(date)) { - eventDict.get(date)?.push(designReview); - } else { - eventDict.set(date, [designReview]); - } - }); - - const currentUserDesignReviews = allDesignReviews.filter( - (designReview) => designReview.userCreated.userId === user.userId && designReview.status !== DesignReviewStatus.DONE - ); - - const startOfEachWeek = [0, 7, 14, 21, 28, 35]; - - const isDayInDifferentMonth = (day: number, week: number) => { - return day < week - 7 || day < 1 || day > week + 7; - }; - - const designReviewButtons = (designReviews: DesignReview[]) => { - return designReviews.map((designReview) => { - return { - icon: getTeamTypeIcon(designReview.teamType.name), - title: designReview.wbsName, - onClick: () => { - setUnconfirmedDesignReview(designReview); - }, - disabled: false - }; - }); - }; - - const NoDRSButton = () => { - return [ - { - title: 'No Design Reviews', - disabled: true, - onClick: () => {} - } - ]; - }; - - const paddingArrayStart = [...Array(calendarPaddingDays(displayMonthYear)).keys()] - .map( - (day) => - daysInMonth(new Date(displayMonthYear.getFullYear(), displayMonthYear.getMonth() - 1, displayMonthYear.getDate())) - - day - ) - .reverse(); - const paddingArrayEnd = [ - ...Array(7 - ((daysInMonth(displayMonthYear) + calendarPaddingDays(displayMonthYear)) % 7)).keys() - ].map((day) => day + 1); - const daysThisMonth = paddingArrayStart - .concat([...Array(daysInMonth(displayMonthYear)).keys()].map((day) => day + 1)) - .concat(paddingArrayEnd.length < 7 ? paddingArrayEnd : []); - - const unconfirmedDRSDropdown = ( - - My Unconfirmed DRs - - ); - - if (!allTeamTypes || allTeamTypesLoading) return ; - if (allTeamTypesIsError) return ; - - return ( - <> - {unconfirmedDesignReview && ( - { - setUnconfirmedDesignReview(undefined); - }} - designReview={unconfirmedDesignReview as DesignReview} - teamTypes={allTeamTypes} - /> - )} - - - Design Review Calendar - - - - - - {unconfirmedDRSDropdown} - - - - {enumToArray(DAY_NAMES).map((day) => ( - - - { - // Day of the week display based on current breakpoint - isLargerView ? day : isExtraSmallView ? day.charAt(0) : day.substring(0, 3) - } - - - ))} - - - - {startOfEachWeek.map((week) => ( - - {daysThisMonth.slice(week, week + 7).map((day) => { - const cardDate = new Date( - displayMonthYear.getFullYear(), - displayMonthYear.getMonth() + (isDayInDifferentMonth(day, week) ? (day > 15 ? -1 : 1) : 0), - day - ); - return ( - - - - - - ); - })} - - ))} - - - - - ); -}; - -export default CalendarPage; diff --git a/src/frontend/src/pages/CalendarPage/CalendarTab.tsx b/src/frontend/src/pages/CalendarPage/CalendarTab.tsx new file mode 100644 index 0000000000..47f76509fe --- /dev/null +++ b/src/frontend/src/pages/CalendarPage/CalendarTab.tsx @@ -0,0 +1,181 @@ +import { routes } from '../../utils/routes'; +import NewCalendarPage from './NewCalendarPage'; +import PageLayout from '../../components/PageLayout'; +import { Box, Button } from '@mui/material'; +import FullPageTabs from '../../components/FullPageTabs'; +import { useState } from 'react'; +import { useCurrentUser } from '../../hooks/users.hooks'; +import { ConflictStatus, isHead, isLead } from 'shared'; +import { useAllCalendars, useAllEventTypes, useFilterEvents } from '../../hooks/calendar.hooks'; +import LoadingIndicator from '../../components/LoadingIndicator'; +import ErrorPage from '../ErrorPage'; +import { filterEventTransformer } from '../../apis/transformers/calendar.transformer'; +import EventsTable from './EventsTable'; +import AddCircleOutlineIcon from '@mui/icons-material/AddCircleOutline'; +import CreateEventModal from './Components/CreateEventModal'; +import { useHistory } from 'react-router-dom'; + +const CalendarTab: React.FC = () => { + const [tabIndex, setTabIndex] = useState(0); + const [isCreateModalOpen, setIsCreateModalOpen] = useState(false); + const [createModalDate, setCreateModalDate] = useState(new Date()); + const user = useCurrentUser(); + const history = useHistory(); + const canViewReviews = isHead(user.role) || isLead(user.role); + + const tabs = [ + { tabUrlValue: 'mainCalendar', tabName: 'Calendar' }, + { tabUrlValue: 'yourEvents', tabName: 'Your Events' } + ]; + + const { + data: untransformedYourEvents, + isLoading: yourEventsLoading, + isError: yourEventsIsError, + error: yourEventsError + } = useFilterEvents({ + memberIds: [user.userId], + startPeriod: new Date(0), + endPeriod: new Date(2099, 11, 31) // Adjust as needed + }); + + const { + data: untransformedReviewEvents, + isLoading: reviewEventsLoading, + isError: reviewEventsIsError, + error: reviewEventsError + } = useFilterEvents({ + approvalIds: canViewReviews ? [] : [user.userId], + statuses: canViewReviews ? [ConflictStatus.PENDING] : [], + startPeriod: new Date(0), + endPeriod: new Date(2099, 11, 31) // Adjust as needed + }); + + const { + data: allEventTypes, + isLoading: allEventTypesLoading, + isError: allEventTypesIsError, + error: allEventTypesError + } = useAllEventTypes(); + + const { + data: allCalendars, + isLoading: allCalendarsLoading, + isError: allCalendarsIsError, + error: allCalendarsError + } = useAllCalendars(); + + const yourEvents = untransformedYourEvents?.map(filterEventTransformer); + const reviewEvents = untransformedReviewEvents?.map(filterEventTransformer); + + if ( + !yourEvents || + yourEventsLoading || + !reviewEvents || + reviewEventsLoading || + !allEventTypes || + allEventTypesLoading || + !allCalendars || + allCalendarsLoading + ) + return ; + if (yourEventsIsError) return ; + + if (reviewEventsIsError) return ; + + if (allEventTypesIsError) return ; + + if (allCalendarsIsError) return ; + + if (canViewReviews) tabs.push({ tabUrlValue: 'reviews', tabName: 'Review Bookings' }); + + const handleNewEventClick = (date?: Date) => { + if (tabIndex !== 0) { + history.push(`${routes.NEW_CALENDAR}/mainCalendar`); + } + setCreateModalDate(date || new Date()); + setIsCreateModalOpen(true); + }; + + return ( + <> + + + + } + headerRight={ + + } + > + {tabIndex === 0 ? ( + + event.scheduledTimes.map((scheduledTime) => ({ + ...event, + ...scheduledTime, + recurring: event.scheduledTimes.length > 1, + totalScheduledSlots: event.scheduledTimes.length + })) + ) ?? [] + } + allCalendars={allCalendars} + onCreateEventClick={handleNewEventClick} + /> + ) : ( + + )} + + + {isCreateModalOpen && ( + setIsCreateModalOpen(false)} + eventTypes={allEventTypes} + defaultDate={createModalDate} + /> + )} + + ); +}; + +export default CalendarTab; diff --git a/src/frontend/src/pages/CalendarPage/Components/CreateEventModal.tsx b/src/frontend/src/pages/CalendarPage/Components/CreateEventModal.tsx new file mode 100644 index 0000000000..022e570544 --- /dev/null +++ b/src/frontend/src/pages/CalendarPage/Components/CreateEventModal.tsx @@ -0,0 +1,110 @@ +import React from 'react'; +import EventModal, { EventPayload } from './EventModal'; +import type { EventType, EventDocumentUploadArgs } from 'shared'; +import { useCreateEvent, useUploadManyDocuments } from '../../../hooks/calendar.hooks'; +import { useToast } from '../../../hooks/toasts.hooks'; +import { convertDayToInt } from '../../../utils/calendar.utils'; + +interface CreateEventModalProps { + open: boolean; + onClose: () => void; + eventTypes: EventType[]; + defaultDate?: Date; +} + +const CreateEventModal: React.FC = ({ open, onClose, eventTypes, defaultDate }) => { + const toast = useToast(); + const { mutateAsync: createEvent } = useCreateEvent(); + const { mutateAsync: uploadDocuments } = useUploadManyDocuments(); + + const handleSubmit = async (payload: EventPayload) => { + try { + const { documentFiles, createScheduleSlotArgs, initialDateScheduled, ...eventData } = payload; + + const scheduleSlots: Array<{ + startTime: Date; + endTime: Date; + allDay: boolean; + }> = []; + + // Generate schedule slots from createScheduleSlotArgs if provided + if (createScheduleSlotArgs) { + const { startTime, endTime, days, recurrenceNumber, allDay } = createScheduleSlotArgs; + + // Helper function to create a date/time for a specific occurrence + const createSlotDateTime = (baseDate: Date, time: Date): Date => { + const result = new Date(baseDate); + result.setHours(time.getHours(), time.getMinutes(), time.getSeconds(), time.getMilliseconds()); + return result; + }; + + // Convert selected days to day indices for comparison + const dayIndices = days.map(convertDayToInt); + + // Generate recurring occurrences + let occurrencesGenerated = 0; + const searchDate = new Date(startTime); + + const maxDaysToSearch = 365; // Search up to a year + let daysSearched = 0; + + while (occurrencesGenerated < recurrenceNumber && daysSearched < maxDaysToSearch) { + const currentDayIndex = searchDate.getDay(); + + if ((dayIndices as number[]).includes(currentDayIndex)) { + scheduleSlots.push({ + startTime: createSlotDateTime(searchDate, startTime), + endTime: createSlotDateTime(searchDate, endTime), + allDay + }); + occurrencesGenerated++; + } + + searchDate.setDate(searchDate.getDate() + 1); + daysSearched++; + } + } + + const createArgs = { + ...eventData, + initialDateScheduled: initialDateScheduled ?? new Date(), + scheduleSlots, + documentIds: [] + }; + + const createdEvent = await createEvent(createArgs); + + const filesToUpload = documentFiles + .map((doc: EventDocumentUploadArgs) => doc.file) + .filter((file: File | undefined): file is File => file !== undefined); + if (filesToUpload.length > 0) { + await uploadDocuments({ + id: createdEvent.eventId, + files: filesToUpload + }); + } + + toast.success('Event created successfully!'); + } catch (e: unknown) { + if (e instanceof Error) { + toast.error(e.message, 5000); + } else { + toast.error('Failed to create event', 5000); + } + throw e; + } + }; + + return ( + + ); +}; + +export default CreateEventModal; diff --git a/src/frontend/src/pages/CalendarPage/Components/DeleteSeriesConfirmationModal.tsx b/src/frontend/src/pages/CalendarPage/Components/DeleteSeriesConfirmationModal.tsx new file mode 100644 index 0000000000..90cec9579b --- /dev/null +++ b/src/frontend/src/pages/CalendarPage/Components/DeleteSeriesConfirmationModal.tsx @@ -0,0 +1,89 @@ +import React, { useState } from 'react'; +import { + Dialog, + DialogTitle, + DialogContent, + DialogActions, + FormControl, + FormControlLabel, + Radio, + RadioGroup, + Typography, + Box +} from '@mui/material'; +import NERFailButton from '../../../components/NERFailButton'; +import NERSuccessButton from '../../../components/NERSuccessButton'; + +const headerBackground = '#ef4345'; + +export interface DeleteSeriesConfirmationModalProps { + open: boolean; + onCancel: () => void; + onConfirm: (deleteEntireEvent: boolean) => void; + eventTitle: string; + totalSlots: number; +} + +const DeleteSeriesConfirmationModal: React.FC = ({ + open, + onCancel, + onConfirm, + eventTitle, + totalSlots +}) => { + const [deleteEntireEvent, setDeleteEntireEvent] = useState(false); + + const handleConfirm = () => { + onConfirm(deleteEntireEvent); + setDeleteEntireEvent(false); + }; + + const handleCancel = () => { + onCancel(); + setDeleteEntireEvent(false); + }; + + return ( + + Delete Event + + + This event "{eventTitle}" has {totalSlots} scheduled occurrences. Would you like to delete just this occurrence or + all occurrences? + + + setDeleteEntireEvent(e.target.value === 'true')}> + } + label="This occurrence only" + /> + } + label={`All occurrences (${totalSlots} total)`} + /> + + + + + + + Cancel + + + Confirm + + + + + ); +}; + +export default DeleteSeriesConfirmationModal; diff --git a/src/frontend/src/pages/CalendarPage/Components/EditEventModal.tsx b/src/frontend/src/pages/CalendarPage/Components/EditEventModal.tsx new file mode 100644 index 0000000000..62c1b78806 --- /dev/null +++ b/src/frontend/src/pages/CalendarPage/Components/EditEventModal.tsx @@ -0,0 +1,84 @@ +import React from 'react'; +import EventModal, { EventPayload } from './EventModal'; +import type { EventInstance, EventType, EventDocumentUploadArgs } from 'shared'; +import { convertEventToFormValues } from '../../../utils/calendar.utils'; +import { useEditEvent, useEditScheduleSlot, useUploadManyDocuments } from '../../../hooks/calendar.hooks'; +import { useToast } from '../../../hooks/toasts.hooks'; + +export interface EditEventModalProps { + open: boolean; + onClose: () => void; + event: EventInstance; + eventTypes: EventType[]; +} + +const EditEventModal: React.FC = ({ open, onClose, event, eventTypes }) => { + const toast = useToast(); + const { mutateAsync: editEvent } = useEditEvent(event.eventId); + const { mutateAsync: editScheduleSlot } = useEditScheduleSlot(event.eventId, event.scheduleSlotId); + const { mutateAsync: uploadDocuments } = useUploadManyDocuments(); + + const initialValues = convertEventToFormValues(event); + + const handleSubmit = async (payload: EventPayload) => { + try { + const { documentFiles, editScheduleSlotArgs, ...eventData } = payload; + + // First, update the event base information + const editArgs = { + ...eventData, + status: event.status, + documents: event.documents.map((doc) => ({ + name: doc.name, + googleFileId: doc.googleFileId + })) + }; + + const editedEvent = await editEvent(editArgs); + + // If there are schedule slot changes, update the schedule slot separately + if (editScheduleSlotArgs) { + await editScheduleSlot({ + startTime: editScheduleSlotArgs.newStartTime, + endTime: editScheduleSlotArgs.newEndTime, + allDay: editScheduleSlotArgs.newAllDay, + editAllInSeries: editScheduleSlotArgs.editAllInSeries + }); + } + + // Handle document uploads + const filesToUpload = documentFiles + .map((doc: EventDocumentUploadArgs) => doc.file) + .filter((file: File | undefined): file is File => file !== undefined); + if (filesToUpload.length > 0) { + await uploadDocuments({ + id: editedEvent.eventId, + files: filesToUpload + }); + } + + toast.success('Event updated successfully!'); + } catch (e: unknown) { + if (e instanceof Error) { + toast.error(e.message, 5000); + } else { + toast.error('Failed to update event', 5000); + } + throw e; + } + }; + + return ( + + ); +}; + +export default EditEventModal; diff --git a/src/frontend/src/pages/CalendarPage/Components/EditSeriesConfirmationModal.tsx b/src/frontend/src/pages/CalendarPage/Components/EditSeriesConfirmationModal.tsx new file mode 100644 index 0000000000..ea388a8e46 --- /dev/null +++ b/src/frontend/src/pages/CalendarPage/Components/EditSeriesConfirmationModal.tsx @@ -0,0 +1,205 @@ +import React, { useState, useMemo } from 'react'; +import { + Dialog, + DialogTitle, + DialogContent, + DialogActions, + FormControl, + FormControlLabel, + Radio, + RadioGroup, + Typography, + Box +} from '@mui/material'; +import ArrowForwardIcon from '@mui/icons-material/ArrowForward'; +import NERFailButton from '../../../components/NERFailButton'; +import NERSuccessButton from '../../../components/NERSuccessButton'; +import { ScheduleSlot } from 'shared'; + +const headerBackground = '#ef4345'; + +export interface EditSeriesConfirmationModalProps { + open: boolean; + onCancel: () => void; + onConfirm: (editAllInSeries: boolean) => void; + affectedSlots?: ScheduleSlot[]; + originalStartTime?: Date; + originalEndTime?: Date; + newStartTime?: Date; + newEndTime?: Date; +} + +/** + * Formats a date for display (e.g., "Mon, Jan 15") + */ +const formatDate = (date: Date): string => { + return date.toLocaleDateString('en-US', { + weekday: 'short', + month: 'short', + day: 'numeric' + }); +}; + +/** + * Formats a time for display (e.g., "2:30 PM") + */ +const formatTime = (date: Date): string => { + return date.toLocaleTimeString('en-US', { + hour: 'numeric', + minute: '2-digit', + hour12: true + }); +}; + +/** + * Formats a time range for display (e.g., "2:30 PM - 4:00 PM") + */ +const formatTimeRange = (start: Date, end: Date): string => { + return `${formatTime(start)} - ${formatTime(end)}`; +}; + +const EditSeriesConfirmationModal: React.FC = ({ + open, + onCancel, + onConfirm, + affectedSlots = [], + originalStartTime, + originalEndTime, + newStartTime, + newEndTime +}) => { + const [editAllInSeries, setEditAllInSeries] = useState(false); + + // Calculate new times for each affected slot based on the time offset + const affectedSlotsWithNewTimes = useMemo(() => { + if (!originalStartTime || !originalEndTime || !newStartTime || !newEndTime) { + return []; + } + + // Get the new time-of-day to apply to each slot + const newStartHours = newStartTime.getHours(); + const newStartMinutes = newStartTime.getMinutes(); + const newEndHours = newEndTime.getHours(); + const newEndMinutes = newEndTime.getMinutes(); + + return affectedSlots.map((slot) => { + const slotStart = new Date(slot.startTime); + const slotEnd = new Date(slot.endTime); + + // Apply the time-of-day change to each slot + const newSlotStart = new Date(slotStart); + newSlotStart.setHours(newStartHours, newStartMinutes, 0, 0); + + const newSlotEnd = new Date(slotEnd); + newSlotEnd.setHours(newEndHours, newEndMinutes, 0, 0); + + return { + ...slot, + originalStartTime: slotStart, + originalEndTime: slotEnd, + newStartTime: newSlotStart, + newEndTime: newSlotEnd + }; + }); + }, [affectedSlots, originalStartTime, originalEndTime, newStartTime, newEndTime]); + + const handleConfirm = () => { + onConfirm(editAllInSeries); + setEditAllInSeries(false); + }; + + const handleCancel = () => { + onCancel(); + setEditAllInSeries(false); + }; + + return ( + + Edit Time + + + You have changed the time for this event. Would you like to apply this change to all events in this series? + + + setEditAllInSeries(e.target.value === 'true')}> + } + label="This event only" + /> + } + label={`All events in the series (${affectedSlots.length + 1} events)`} + /> + + + + {/* Show affected events when "all events in series" is selected */} + {editAllInSeries && affectedSlotsWithNewTimes.length > 0 && ( + + + The following events will also be updated: + + + {affectedSlotsWithNewTimes + .sort((a, b) => a.originalStartTime.getTime() - b.originalStartTime.getTime()) + .map((slot) => ( + + + {formatDate(slot.originalStartTime)} + + + {formatTimeRange(slot.originalStartTime, slot.originalEndTime)} + + + + {formatTimeRange(slot.newStartTime, slot.newEndTime)} + + + ))} + + + )} + + + + + Cancel + + + Confirm + + + + + ); +}; + +export default EditSeriesConfirmationModal; diff --git a/src/frontend/src/pages/CalendarPage/Components/EventAvailabilityPage.tsx b/src/frontend/src/pages/CalendarPage/Components/EventAvailabilityPage.tsx new file mode 100644 index 0000000000..2db6780123 --- /dev/null +++ b/src/frontend/src/pages/CalendarPage/Components/EventAvailabilityPage.tsx @@ -0,0 +1,340 @@ +/* + * 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, EventWithMembers } 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 { useMarkUserConfirmed, useSingleEventWithMembers } from '../../../hooks/calendar.hooks'; +import { useParams, useHistory } from 'react-router-dom'; +import { eventNamePipe, fullNamePipe } 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 SingleAvailabilityModal from '../../SettingsPage/UserScheduleSettings/Availability/SingleAvailabilityModal'; +import AvailabilityEditModal from '../../SettingsPage/UserScheduleSettings/Availability/AvailabilityEditModal'; +import AvailabilityScheduleView from '../AvailabilityScheduleView'; + +const isUserOnEvent = (user: User, event: EventWithMembers): boolean => { + const isDirectMember = + event.requiredMembers?.some((member: User) => member.userId === user.userId) || + event.optionalMembers?.some((member: User) => member.userId === user.userId); + + if (isDirectMember) return true; + + const isOnEventTeam = event.teams?.some( + (team) => + team.members?.some((member: User) => member.userId === user.userId) || + team.leads?.some((lead: User) => lead.userId === user.userId) || + team.head?.userId === user.userId + ); + + if (isOnEventTeam) return true; + + if (event.teamType?.teams) { + const isOnTeamType = event.teamType.teams.some( + (team) => + team.members?.some((member: User) => member.userId === user.userId) || + team.leads?.some((lead: User) => lead.userId === user.userId) || + team.head?.userId === user.userId + ); + if (isOnTeamType) return true; + } + + if (event.userCreated?.userId === user.userId) return true; + + return false; +}; + +export 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 + } = useSingleEventWithMembers(eventId); + + const { + data: userScheduleSettings, + isLoading: settingsLoading, + isError: settingsIsError, + error: settingsError + } = useUserScheduleSettings(currentUser.userId); + + const allRelevantUserIds = useMemo(() => { + if (!event) return []; + + const userIds = new Set(); + + // Add required and optional members + event.requiredMembers.forEach((m) => userIds.add(m.userId)); + event.optionalMembers.forEach((m) => userIds.add(m.userId)); + + // Add creator + userIds.add(event.userCreated.userId); + + // Add team members + event.teams.forEach((team) => { + team.members.forEach((m) => userIds.add(m.userId)); + team.leads.forEach((l) => userIds.add(l.userId)); + userIds.add(team.head.userId); + }); + + // Add team type members + if (event.teamType) { + event.teamType.teams.forEach((team) => { + team.members.forEach((m) => userIds.add(m.userId)); + team.leads.forEach((l) => userIds.add(l.userId)); + userIds.add(team.head.userId); + }); + } + + return Array.from(userIds); + }, [event]); + + const { + data: relevantUsers, + isLoading: usersLoading, + isError: usersError, + error: usersErrorMsg + } = useManyUsersWithScheduleSettings(allRelevantUserIds); + + const { mutateAsync: markUserConfirmed } = useMarkUserConfirmed(eventId); + + const displayDate = useMemo(() => { + if (dateParam) { + return new Date(dateParam); + } + return event?.initialDateScheduled ?? new Date(); + }, [dateParam, event]); + + const isUserMember = useMemo(() => { + if (!event) return false; + return isUserOnEvent(currentUser, event); + }, [currentUser, event]); + + useEffect(() => { + if (userScheduleSettings && userScheduleSettings.availabilities.length > 0) { + const confirmed = getMostRecentAvailabilities(userScheduleSettings.availabilities, displayDate); + setConfirmedAvailabilities(new Map(confirmed.map((availability) => [availability.dateSet.getTime(), availability]))); + } else { + setConfirmedAvailabilities(new Map()); + } + }, [userScheduleSettings, displayDate]); + + if (eventLoading || !event) return ; + if (eventError) return ; + + if (settingsLoading) return ; + if (settingsIsError || !userScheduleSettings) return ; + + if (usersLoading || !relevantUsers) return ; + if (usersError) return ; + + 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 Confirmed!'); + setEditAvailabilityOpen(false); + } catch (e) { + if (e instanceof Error) { + toast.error(e.message); + } + } + }; + + const handleClose = () => { + history.push(routes.NEW_CALENDAR); + }; + + const availableUsers = new Map(); + const unavailableUsers = new Map(); + const usersToAvailabilities = new Map(); + + relevantUsers.forEach((user: UserWithScheduleSettings) => { + const availability = getMostRecentAvailabilities(user.scheduleSettings?.availabilities ?? [], displayDate); + usersToAvailabilities.set(user, 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`; + }; + + // RENDER + return ( + + + + {/* My Availability Section */} + + + My Availability + + + {getAvailabilitySummary()} + + + setViewAvailabilityOpen(true)} + disabled={userScheduleSettings.availabilities.length === 0} + > + View My Availability + + setEditAvailabilityOpen(true)} disabled={!isUserMember}> + Edit My Availability + + + {!isUserMember && ( + + You must be a member of this event to edit availability. + + )} + + + + + + + {eventNamePipe(event)} Availability + + + {currentAvailableUsers.length > 0 + ? `${currentAvailableUsers.length}/${relevantUsers.length} available` + : 'Click a time slot to see availability'} + + + + + + Available + + + {currentAvailableUsers.length > 0 ? ( + currentAvailableUsers.map((user) => ( + + {fullNamePipe(user)} + + )) + ) : ( + + Click a time slot to see availability + + )} + + + + + + Unavailable + + + {currentUnavailableUsers.length > 0 ? ( + currentUnavailableUsers.map((user) => ( + + {fullNamePipe(user)} + + )) + ) : ( + + Click a time slot to see availability + + )} + + + + + + + + + + + + + + + Close + + + setViewAvailabilityOpen(false)} + header="My Availability" + availabilites={userScheduleSettings.availabilities} + initialDate={displayDate} + /> + + setEditAvailabilityOpen(false)} + header={editModalTitle} + confirmedAvailabilities={confirmedAvailabilities} + setConfirmedAvailabilities={setConfirmedAvailabilities} + totalAvailabilities={deeplyCopy(userScheduleSettings.availabilities, availabilityTransformer) as Availability[]} + initialDate={displayDate} + onSubmit={handleConfirm} + canChangeDateRange={false} + /> + + ); +}; diff --git a/src/frontend/src/pages/CalendarPage/Components/EventModal.tsx b/src/frontend/src/pages/CalendarPage/Components/EventModal.tsx new file mode 100644 index 0000000000..cc55c38e60 --- /dev/null +++ b/src/frontend/src/pages/CalendarPage/Components/EventModal.tsx @@ -0,0 +1,1459 @@ +import React, { useEffect, useMemo, useState } from 'react'; +import { + Box, + FormControl, + FormHelperText, + MenuItem, + Select, + TextField, + Typography, + Autocomplete, + Chip, + IconButton, + Button, + Stack, + Checkbox, + FormControlLabel +} from '@mui/material'; +import { DatePicker, TimePicker } from '@mui/x-date-pickers'; +import { Controller, useForm } from 'react-hook-form'; +import * as yup from 'yup'; +import { yupResolver } from '@hookform/resolvers/yup'; +import { + DayOfWeek, + EventDocumentUploadArgs, + WbsElementStatus, + wbsNamePipe, + EventType, + isHead, + MAX_FILE_SIZE, + getNextSevenDays, + ScheduleSlot +} from 'shared'; +import { useToast } from '../../../hooks/toasts.hooks'; +import { useAllUsers, useCurrentUser } from '../../../hooks/users.hooks'; +import { useAllWorkPackagesPreview } from '../../../hooks/work-packages.hooks'; +import { useAllTeamPreviews } from '../../../hooks/teams.hooks'; +import { userToAutocompleteOption } from '../../../utils/teams.utils'; +import ErrorPage from '../../ErrorPage'; +import NERFormModal from '../../../components/NERFormModal'; +import CalendarTodayIcon from '@mui/icons-material/CalendarToday'; +import PeopleIcon from '@mui/icons-material/People'; +import LocationOnIcon from '@mui/icons-material/LocationOn'; +import VideocamIcon from '@mui/icons-material/Videocam'; +import WorkIcon from '@mui/icons-material/Work'; +import DescriptionIcon from '@mui/icons-material/Description'; +import BusinessIcon from '@mui/icons-material/Business'; +import { useAllTeamTypes } from '../../../hooks/team-types.hooks'; +import { ClearIcon } from '@mui/x-date-pickers'; +import { useAllMachines, useAllShops, usePreviewScheduleSlotRecurringEdits } from '../../../hooks/calendar.hooks'; +import StoreIcon from '@mui/icons-material/Store'; +import PrecisionManufacturingIcon from '@mui/icons-material/PrecisionManufacturing'; +import ArrowForwardIcon from '@mui/icons-material/ArrowForward'; +import HelpOutlineIcon from '@mui/icons-material/HelpOutline'; +import Tooltip from '@mui/material/Tooltip'; +import { convertDayToInt, convertIntToDay } from '../../../utils/calendar.utils'; +import { getDay } from 'date-fns'; +import EditSeriesConfirmationModal from './EditSeriesConfirmationModal'; + +export interface EventFormValues { + title: string; + eventTypeId: string; + requiredMemberIds: string[]; + optionalMemberIds: string[]; + teamIds: string[]; + teamTypeId?: string; + location?: string; + zoomLink?: string; + shopIds: string[]; + machineryIds: string[]; + workPackageIds: string[]; + documentFiles: EventDocumentUploadArgs[]; + questionDocumentLink?: string; + description?: string; + scheduleDate: Date; + startTime: Date; + endTime: Date; + allDay: boolean; + recurrenceNumber: number; + days: DayOfWeek[]; + selectedScheduleSlotId?: string; +} + +export interface EventPayload { + title: string; + eventTypeId: string; + requiredMemberIds: string[]; + optionalMemberIds: string[]; + teamIds: string[]; + teamTypeId?: string; + location?: string; + zoomLink?: string; + shopIds: string[]; + machineryIds: string[]; + workPackageIds: string[]; + documentFiles: EventDocumentUploadArgs[]; + questionDocumentLink?: string; + description?: string; + // If the event type requires confirmation, only intialDateScheduled will be populated. If not, + // scheduleSlots will be populated based on if the event is being editted or created + initialDateScheduled?: Date; + createScheduleSlotArgs?: { + startTime: Date; + endTime: Date; + days: DayOfWeek[]; + recurrenceNumber: number; + allDay: boolean; + }; + // For editing, only single schedule slots can be editted (and optionally propogated) + editScheduleSlotArgs?: { + scheduleSlotId: string; + newStartTime: Date; + newEndTime: Date; + newAllDay: boolean; + editAllInSeries: boolean; + }; +} + +const schema = yup.object().shape({ + title: yup.string().required('Title is required'), + eventTypeId: yup.string().required('Event Type is required'), + requiredMemberIds: yup.array().of(yup.string().required()).default([]), + optionalMemberIds: yup.array().of(yup.string().required()).default([]), + teamIds: yup.array().of(yup.string().required()).default([]), + teamTypeId: yup.string().optional(), + location: yup.string().optional(), + zoomLink: yup.string().url('Must be a valid URL').optional(), + shopIds: yup.array().of(yup.string().required()).default([]), + machineryIds: yup.array().of(yup.string().required()).default([]), + workPackageIds: yup.array().of(yup.string().required()).default([]), + documentFiles: yup.array().of(yup.mixed().required()).default([]), + questionDocumentLink: yup.string().optional(), + description: yup.string().optional(), + scheduleDate: yup.date().required('Date is required'), + startTime: yup.date().required('Start time is required'), + endTime: yup + .date() + .required('End time is required') + .test('is-after-start', 'End time must be after start time', function (value) { + const { startTime } = this.parent; + if (!value || !startTime) return true; + return new Date(value).getTime() > new Date(startTime).getTime(); + }), + allDay: yup.boolean().required(), + recurrenceNumber: yup.number().min(0).required('Recurrence is required'), + days: yup.array().of(yup.mixed().required()).default([]), + selectedScheduleSlotId: yup.string().optional() +}); + +export interface BaseEventModalProps { + open: boolean; + onClose: () => void; + onSubmit: (data: EventPayload) => Promise | unknown; + initialValues?: Partial; + eventTypes: EventType[]; + defaultDate?: Date; + eventId?: string; // Required for edit mode to fetch preview of affected schedule slots +} + +/** + * Checks if the time has changed between initial values and current form data + */ +const hasTimeChanged = (initialValues: Partial | undefined, currentData: EventFormValues): boolean => { + if (!initialValues?.startTime || !initialValues?.endTime) return false; + + const originalStartTime = new Date(initialValues.startTime).getTime(); + const originalEndTime = new Date(initialValues.endTime).getTime(); + const currentStartTime = new Date(currentData.startTime).getTime(); + const currentEndTime = new Date(currentData.endTime).getTime(); + + return ( + originalStartTime !== currentStartTime || + originalEndTime !== currentEndTime || + initialValues.allDay !== currentData.allDay + ); +}; + +/** + * Calculate default start and end times based on current time. + * Start time is the current hour (rounded up), end time is 1 hour later. + */ +const getDefaultTimes = (): { startTime: Date; endTime: Date } => { + const startTime = new Date(); + startTime.setMinutes(0, 0, 0); + startTime.setHours(startTime.getHours() + 1); // Default to next hour; + + const endTime = new Date(startTime); + endTime.setHours(endTime.getHours() + 1); // Default to 1 hour duration + + return { startTime, endTime }; +}; + +const EventModal: React.FC = ({ + open, + onClose, + onSubmit, + initialValues, + eventTypes, + defaultDate = new Date(), + eventId +}) => { + const toast = useToast(); + const user = useCurrentUser(); + const [datePickerOpen, setDatePickerOpen] = useState(false); + const [startTimePickerOpen, setStartTimePickerOpen] = useState(false); + const [endTimePickerOpen, setEndTimePickerOpen] = useState(false); + const [showRecurringOptions, setShowRecurringOptions] = useState(false); + const [requiredMembers, setRequiredMembers] = useState>([]); + const [optionalMembers, setOptionalMembers] = useState>([]); + const [selectedTeams, setSelectedTeams] = useState>([]); + + // State for the series confirmation modal (only used in edit mode when time changes) + const [showSeriesConfirmModal, setShowSeriesConfirmModal] = useState(false); + const [pendingPayload, setPendingPayload] = useState(null); + const [pendingFormData, setPendingFormData] = useState(null); + + // Fetch preview of other schedule slots that would be affected when editing with "edit all in series" + const isEditMode = !!initialValues; + const scheduleSlotId = initialValues?.selectedScheduleSlotId; + const { data: affectedSlots = [] } = usePreviewScheduleSlotRecurringEdits( + eventId ?? '', + scheduleSlotId ?? '', + isEditMode && !!eventId && !!scheduleSlotId + ); + + // Lazy load all data needed for the form so users can start filling out instantly + const { isLoading: usersLoading, isError: usersError, error: usersErrorMsg, data: users } = useAllUsers(); + const { isLoading: shopsLoading, isError: shopsError, error: shopsErrorMsg, data: shops } = useAllShops(); + const { isError: machineryError, error: machineryErrorMsg, data: machinery } = useAllMachines(); + const { + isLoading: workPackagesLoading, + isError: workPackagesError, + error: workPackagesErrorMsg, + data: allWorkPackages + } = useAllWorkPackagesPreview(); + const { isLoading: teamsLoading, isError: teamsError, error: teamsErrorMsg, data: teams } = useAllTeamPreviews(); + const { isError: teamTypesError, error: teamTypesErrorMsg, data: teamTypes } = useAllTeamTypes(); + + // Compute default form values - memo ensures stable reference + const defaultFormData = useMemo(() => { + const defaultTimes = getDefaultTimes(); + return { + title: initialValues?.title ?? '', + eventTypeId: initialValues?.eventTypeId ?? '', + requiredMemberIds: initialValues?.requiredMemberIds ?? [], + optionalMemberIds: initialValues?.optionalMemberIds ?? [], + teamIds: initialValues?.teamIds ?? [], + teamTypeId: initialValues?.teamTypeId, + location: initialValues?.location, + zoomLink: initialValues?.zoomLink, + shopIds: initialValues?.shopIds ?? [], + machineryIds: initialValues?.machineryIds ?? [], + workPackageIds: initialValues?.workPackageIds ?? [], + documentFiles: initialValues?.documentFiles ?? [], + questionDocumentLink: initialValues?.questionDocumentLink, + description: initialValues?.description, + scheduleDate: initialValues?.scheduleDate ?? defaultDate, + startTime: initialValues?.startTime ?? defaultTimes.startTime, + endTime: initialValues?.endTime ?? defaultTimes.endTime, + allDay: initialValues?.allDay ?? false, + recurrenceNumber: 0, + days: [], + selectedScheduleSlotId: initialValues?.selectedScheduleSlotId + }; + }, [initialValues, defaultDate]); + + const allowedEventTypes = useMemo(() => { + return eventTypes.filter((et) => { + if (!et.onlyHeadsOrAboveForEventCreation) return true; + return isHead(user.role); + }); + }, [eventTypes, user]); + + const { + handleSubmit, + control, + reset, + watch, + setValue, + formState: { errors } + } = useForm({ + resolver: yupResolver(schema), + defaultValues: defaultFormData + }); + + const shopIds = watch('shopIds'); + const selectedEventTypeId = watch('eventTypeId'); + const documentFiles = watch('documentFiles'); + + const selectedEventType = useMemo( + () => allowedEventTypes.find((et) => et.eventTypeId === selectedEventTypeId), + [allowedEventTypes, selectedEventTypeId] + ); + + // Filter machinery based on selected shops + const filteredMachineryOptions = useMemo(() => { + if (!machinery || !shops) { + return []; + } + + const allMachineryOptions = machinery.map((m) => ({ id: m.machineryId, label: m.name })); + + if (shopIds.length === 0) { + return allMachineryOptions; + } + + return allMachineryOptions.filter((machineOption) => { + const machine = machinery.find((m) => m.machineryId === machineOption.id); + if (!machine) return false; + + return machine.shops.some((shopMachinery) => shopIds.includes(shopMachinery.shop.shopId)); + }); + }, [machinery, shops, shopIds]); + + // Initialize autocomplete states when data loads - runs only on mount or when dependencies change + useEffect(() => { + // Set autocomplete state for required members + if (initialValues?.requiredMemberIds && users) { + const reqMembers = users + .filter((u) => initialValues.requiredMemberIds?.includes(u.userId)) + .map(userToAutocompleteOption); + setRequiredMembers(reqMembers); + } + + // Set autocomplete state for optional members + if (initialValues?.optionalMemberIds && users) { + const optMembers = users + .filter((u) => initialValues.optionalMemberIds?.includes(u.userId)) + .map(userToAutocompleteOption); + setOptionalMembers(optMembers); + } + + // Set autocomplete state for teams + if (initialValues?.teamIds && teams) { + const teamOptions = teams + .filter((t) => initialValues.teamIds?.includes(t.teamId)) + .map((t) => ({ id: t.teamId, label: t.teamName })); + setSelectedTeams(teamOptions); + } + + // Set recurring options visibility + if (initialValues?.days && initialValues.days.length > 0) { + setShowRecurringOptions(true); + } + }, [initialValues, users, teams]); + + const computedTitle = isEditMode ? 'Edit Event' : 'Add Event'; + + // Handle recurring dropdown toggle + const handleRecurringToggle = () => { + if (showRecurringOptions) { + // Closing the dropdown - reset to 0 + setValue('recurrenceNumber', 0); + setValue('days', []); + } else { + // Opening the dropdown - set to 1 + setValue('recurrenceNumber', 1); + const startDate = watch('scheduleDate') ?? new Date(); + setValue('days', [convertIntToDay(getDay(startDate))]); + } + setShowRecurringOptions(!showRecurringOptions); + }; + + // Handle event type change + const handleEventTypeChange = (newEventTypeId: string) => { + const newEventType = allowedEventTypes.find((et) => et.eventTypeId === newEventTypeId); + + const selectedDate = watch('scheduleDate'); + + // If switching to a confirmation event type, clear time-related fields + if (newEventType?.requiresConfirmation) { + setValue('startTime', selectedDate); + setValue('endTime', selectedDate); + setValue('allDay', false); + setValue('recurrenceNumber', 0); + setValue('days', []); + setShowRecurringOptions(false); + } + + // Update the event type + setValue('eventTypeId', newEventTypeId); + }; + + // Calculate the last occurrence date for recurring events + const calculateLastOccurrenceDate = () => { + const recurrenceNum = watch('recurrenceNumber'); + const startDate = watch('scheduleDate'); + const selectedDays = watch('days'); + + if (!recurrenceNum || recurrenceNum === 0 || selectedDays.length === 0) return null; + + // Convert to day indices (0 = Sunday, 1 = Monday, etc.) + const dayIndices: number[] = selectedDays.map(convertDayToInt).sort((a, b) => a - b); + + // Start from the initial date + const currentDate = new Date(startDate); + let occurrencesFound = 0; + let lastOccurrenceDate = currentDate; + + // Find all occurrences + const searchDate = new Date(currentDate); + + // Search for up to a year (52 weeks * 7 days = 364 days) + const maxDaysToSearch = 365; + let daysSearched = 0; + + while (occurrencesFound < recurrenceNum && daysSearched < maxDaysToSearch) { + const currentDayIndex = searchDate.getDay(); + + if (dayIndices.includes(currentDayIndex)) { + occurrencesFound++; + lastOccurrenceDate = new Date(searchDate); + + if (occurrencesFound >= recurrenceNum) { + break; + } + } + + searchDate.setDate(searchDate.getDate() + 1); + daysSearched++; + } + + return lastOccurrenceDate; + }; + + const handleClose = () => { + reset(); + onClose(); + }; + + const handleDocumentRemove = (index: number) => { + const currentFiles = watch('documentFiles'); + setValue( + 'documentFiles', + currentFiles.filter((_, i) => i !== index) + ); + }; + + const handleDocumentUpload = (e: React.ChangeEvent) => { + if (e.target.files) { + const currentFiles = watch('documentFiles'); + [...e.target.files].forEach((file) => { + if (file.size >= MAX_FILE_SIZE) { + toast.error(`Error uploading ${file.name}; file must be less than ${MAX_FILE_SIZE / 1024 / 1024} MB`, 5000); + } else { + setValue('documentFiles', [ + ...currentFiles, + { + file, + name: file.name, + googleFileId: '' + } + ]); + } + }); + // Clear input so same file can be uploaded again if needed + e.target.value = ''; + } + }; + + /** + * Builds the payload from form data + */ + const buildPayload = (data: EventFormValues, editAllInSeries: boolean = false): EventPayload => { + const requiresConfirmation = selectedEventType?.requiresConfirmation ?? false; + + const payload: EventPayload = { + title: data.title, + eventTypeId: data.eventTypeId, + requiredMemberIds: requiredMembers.map((m) => m.id), + optionalMemberIds: optionalMembers.map((m) => m.id), + teamIds: selectedTeams.map((t) => t.id), + teamTypeId: data.teamTypeId, + location: data.location, + zoomLink: data.zoomLink, + shopIds: data.shopIds, + machineryIds: data.machineryIds, + workPackageIds: data.workPackageIds, + documentFiles: data.documentFiles, + questionDocumentLink: data.questionDocumentLink, + description: data.description + }; + + // If the event requires confirmation, only populate initialDateScheduled + if (requiresConfirmation) { + payload.initialDateScheduled = data.scheduleDate; + } else if (isEditMode && data.selectedScheduleSlotId) { + // For edit mode, populate editScheduleSlotArgs + payload.editScheduleSlotArgs = { + scheduleSlotId: data.selectedScheduleSlotId, + newStartTime: data.startTime, + newEndTime: data.endTime, + newAllDay: data.allDay, + editAllInSeries + }; + } else if (!isEditMode) { + // For create mode, populate createScheduleSlotArgs + payload.createScheduleSlotArgs = { + startTime: data.startTime, + endTime: data.endTime, + days: data.days.length > 0 ? data.days : [convertIntToDay(data.scheduleDate.getDay())], + recurrenceNumber: data.recurrenceNumber > 0 ? data.recurrenceNumber : 1, + allDay: data.allDay + }; + } + + return payload; + }; + + const onFormSubmit = async (data: EventFormValues) => { + // In edit mode, check if time has changed AND there are other affected slots + // Only show the confirmation modal if there are other slots that would be affected + if (isEditMode && data.selectedScheduleSlotId && hasTimeChanged(initialValues, data) && affectedSlots.length > 0) { + const payload = buildPayload(data, false); + setPendingPayload(payload); + setPendingFormData(data); + setShowSeriesConfirmModal(true); + // Return early - form stays open, confirmation modal will handle submission + return; + } + + // No time change, not in edit mode, or no other affected slots - submit directly + const payload = buildPayload(data, false); + await onSubmit(payload); + handleClose(); + }; + + /** + * Handles confirmation from the series confirmation modal + */ + const handleSeriesConfirmation = async (editAllInSeries: boolean) => { + if (!pendingPayload) return; + + // Update the payload with the user's choice + const updatedPayload: EventPayload = { + ...pendingPayload, + editScheduleSlotArgs: pendingPayload.editScheduleSlotArgs + ? { + ...pendingPayload.editScheduleSlotArgs, + editAllInSeries + } + : undefined + }; + + setShowSeriesConfirmModal(false); + setPendingPayload(null); + setPendingFormData(null); + + await onSubmit(updatedPayload); + handleClose(); + }; + + /** + * Handles cancellation of the series confirmation modal + */ + const handleSeriesCancelConfirmation = () => { + setShowSeriesConfirmModal(false); + setPendingPayload(null); + setPendingFormData(null); + // Form stays open with user's changes preserved + }; + + // When data loads from endpoint, update the options for the autocomplete fields + const memberOptions = useMemo(() => { + if (usersLoading || !users) return [{ id: 'loading', label: 'Loading users...' }]; + return users.map(userToAutocompleteOption); + }, [users, usersLoading]); + + const teamOptions = useMemo(() => { + if (teamsLoading || !teams) return [{ id: 'loading', label: 'Loading teams...' }]; + return teams.map((t) => ({ id: t.teamId, label: t.teamName })); + }, [teams, teamsLoading]); + + const shopOptions = useMemo(() => { + if (shopsLoading || !shops) return [{ id: 'loading', label: 'Loading shops...' }]; + return shops.map((s) => ({ id: s.shopId, label: s.name })); + }, [shops, shopsLoading]); + + if (usersError) return ; + if (workPackagesError) return ; + if (teamsError) return ; + if (teamTypesError) return ; + if (shopsError) return ; + if (machineryError) return ; + + const workPackageOptions = workPackagesLoading + ? [{ id: 'loading', label: 'Loading work packages...' }] + : (allWorkPackages || []) + .filter((wp) => wp.status === WbsElementStatus.Active) + .map((wp) => ({ + label: wbsNamePipe(wp), + id: wp.id, + wbsNum: wp.wbsNum + })) + .sort((a, b) => { + if (a.wbsNum.carNumber !== b.wbsNum.carNumber) return b.wbsNum.carNumber - a.wbsNum.carNumber; + if (a.wbsNum.projectNumber !== b.wbsNum.projectNumber) return b.wbsNum.projectNumber - a.wbsNum.projectNumber; + return b.wbsNum.workPackageNumber - a.wbsNum.workPackageNumber; + }); + + return ( + <> + + {}} + handleUseFormSubmit={handleSubmit} + onFormSubmit={onFormSubmit} + formId="event-form" + showCloseButton + > + + {/* Title Input with red placeholder styling */} + + ( + + )} + /> + {errors.title?.message} + + {/* Event Type Tabs */} + + + {allowedEventTypes.map((et) => ( + + ))} + + {errors.eventTypeId?.message} + + {/* Date and Time Section - Only show when event type is selected */} + {selectedEventType && ( + + {selectedEventType.requiresConfirmation ? ( + + {/* Header with info tooltip */} + + + + + + To be scheduled within: + + + + + ( + setDatePickerOpen(false)} + onOpen={() => setDatePickerOpen(true)} + onChange={(newValue) => onChange(newValue ?? defaultDate)} + slotProps={{ + textField: { + variant: 'standard', + error: !!errors.scheduleDate, + onClick: () => setDatePickerOpen(true), + sx: { minWidth: 150 } + }, + day: { + sx: { + '&.Mui-selected': { + backgroundColor: '#EF4345 !important', + '&:hover': { + backgroundColor: '#d32f2f !important' + }, + '&:focus': { + backgroundColor: '#EF4345 !important' + } + } + } + } + }} + /> + )} + /> + + { + const weekDates = getNextSevenDays(value); + const endDate = weekDates.at(-1); + return ( + + ); + }} + /> + + + ) : ( + /* Normal Event Type - Full date/time selection */ + <> + {/* Date and Time Row */} + + + ( + setDatePickerOpen(false)} + onOpen={() => setDatePickerOpen(true)} + onChange={(newValue) => onChange(newValue ?? defaultDate)} + slotProps={{ + textField: { + variant: 'standard', + error: !!errors.scheduleDate, + onClick: () => setDatePickerOpen(true), + sx: { minWidth: 150 } + }, + day: { + sx: { + '&.Mui-selected': { + backgroundColor: '#EF4345 !important', + '&:hover': { + backgroundColor: '#d32f2f !important' + }, + '&:focus': { + backgroundColor: '#EF4345 !important' + } + } + } + } + }} + /> + )} + /> + + {!watch('allDay') && ( + <> + ( + setStartTimePickerOpen(false)} + onOpen={() => setStartTimePickerOpen(true)} + onChange={(newValue) => onChange(newValue)} + slotProps={{ + textField: { + variant: 'standard', + error: !!errors.startTime, + onClick: () => setStartTimePickerOpen(true), + sx: { width: 100 } + }, + layout: { + sx: { + '& .MuiPickersLayout-contentWrapper .MuiClock-pin': { + backgroundColor: '#EF4345' + }, + '& .MuiPickersLayout-contentWrapper .MuiClockPointer-root': { + backgroundColor: '#EF4345' + }, + '& .MuiPickersLayout-contentWrapper .MuiClockPointer-thumb': { + backgroundColor: '#EF4345', + borderColor: '#EF4345' + }, + '& .MuiPickersLayout-contentWrapper .MuiClockNumber-root.Mui-selected': { + backgroundColor: '#EF4345 !important' + }, + '& .MuiPickersLayout-contentWrapper .MuiPickersArrowSwitcher-button.Mui-selected': { + backgroundColor: '#EF4345 !important', + color: 'white' + }, + '& .MuiMultiSectionDigitalClock-root .MuiMenuItem-root.Mui-selected': { + backgroundColor: '#EF4345 !important', + color: 'white' + } + } + } + }} + /> + )} + /> + - + ( + setEndTimePickerOpen(false)} + onOpen={() => setEndTimePickerOpen(true)} + onChange={(newValue) => onChange(newValue)} + slotProps={{ + textField: { + variant: 'standard', + error: !!errors.endTime, + onClick: () => setEndTimePickerOpen(true), + sx: { width: 100 } + }, + layout: { + sx: { + '& .MuiPickersLayout-contentWrapper .MuiClock-pin': { + backgroundColor: '#EF4345' + }, + '& .MuiPickersLayout-contentWrapper .MuiClockPointer-root': { + backgroundColor: '#EF4345' + }, + '& .MuiPickersLayout-contentWrapper .MuiClockPointer-thumb': { + backgroundColor: '#EF4345', + borderColor: '#EF4345' + }, + '& .MuiPickersLayout-contentWrapper .MuiClockNumber-root.Mui-selected': { + backgroundColor: '#EF4345 !important' + }, + '& .MuiPickersLayout-contentWrapper .MuiPickersArrowSwitcher-button.Mui-selected': { + backgroundColor: '#EF4345 !important', + color: 'white' + }, + '& .MuiMultiSectionDigitalClock-root .MuiMenuItem-root.Mui-selected': { + backgroundColor: '#EF4345 !important', + color: 'white' + } + } + } + }} + /> + )} + /> + + )} + + + {/* All Day and Recurring Row */} + + ( + onChange(e.target.checked)} />} + label="All Day" + /> + )} + /> + + {/* Hide recurring options when editing */} + {!isEditMode && ( + + )} + + + {/* Recurring Options */} + {showRecurringOptions && ( + + + + Repeat + + ( + + )} + /> + + more time(s) + + + + + Repeat on: + + { + const weekDays: DayOfWeek[] = [ + DayOfWeek.SUNDAY, + DayOfWeek.MONDAY, + DayOfWeek.TUESDAY, + DayOfWeek.WEDNESDAY, + DayOfWeek.THURSDAY, + DayOfWeek.FRIDAY, + DayOfWeek.SATURDAY + ]; + const dayLabels = ['S', 'M', 'T', 'W', 'TH', 'F', 'S']; + + const toggleDay = (day: DayOfWeek) => { + const currentDays = value || []; + if (currentDays.includes(day)) { + onChange(currentDays.filter((d) => d !== day)); + } else { + onChange([...currentDays, day]); + } + }; + + return ( + + {weekDays.map((day, index) => { + const isSelected = (value || []).includes(day); + return ( + + ); + })} + + ); + }} + /> + + {errors.days && ( + + {errors.days.message} + + )} + + {/* Last Occurrence Info */} + {watch('recurrenceNumber') > 0 && watch('days').length > 0 ? ( + + + Last occurrence:{' '} + {calculateLastOccurrenceDate()?.toLocaleDateString('en-US', { + weekday: 'long', + month: 'long', + day: 'numeric', + year: 'numeric' + })} + + + ) : ( + + {watch('days').length === 0 + ? 'Select at least one day to see the last occurrence date.' + : "Select the number of times you'd like this event to recur."} + + )} + + )} + + )} + + )} + {/* Required Members Section */} + {selectedEventType?.requiredMembers && ( + + + + + + {requiredMembers.map((member) => ( + setRequiredMembers((prev) => prev.filter((m) => m.id !== member.id))} + sx={{ bgcolor: 'grey.700' }} + /> + ))} + !requiredMembers.find((rm) => rm.id === m.id))} + onChange={(_, newValue) => { + if (newValue) setRequiredMembers((prev) => [...prev, newValue]); + }} + getOptionLabel={(option) => option.label} + renderInput={(params) => ( + + )} + sx={{ flex: 1, minWidth: 150 }} + /> + + + + + )} + {/* Optional Members Section */} + {selectedEventType?.optionalMembers && ( + + + + + + {optionalMembers.map((member) => ( + setOptionalMembers((prev) => prev.filter((m) => m.id !== member.id))} + sx={{ bgcolor: 'grey.700', opacity: 0.7 }} + /> + ))} + !optionalMembers.find((om) => om.id === m.id))} + onChange={(_, newValue) => { + if (newValue) setOptionalMembers((prev) => [...prev, newValue]); + }} + getOptionLabel={(option) => option.label} + renderInput={(params) => ( + + )} + sx={{ flex: 1, minWidth: 150 }} + /> + + + + + )} + {/* Teams Section */} + {selectedEventType?.teams && ( + + + + + + {selectedTeams.map((team) => ( + setSelectedTeams((prev) => prev.filter((t) => t.id !== team.id))} + sx={{ bgcolor: 'grey.700', opacity: 0.7 }} + /> + ))} + !selectedTeams.find((st) => st.id === t.id))} + onChange={(_, newValue) => { + if (newValue) setSelectedTeams((prev) => [...prev, newValue]); + }} + getOptionLabel={(option) => option.label} + renderInput={(params) => ( + + )} + sx={{ flex: 1, minWidth: 150 }} + /> + + + + + )} + {/* Team Type */} + {selectedEventType?.teamType && ( + + + ( + + )} + /> + + )} + {/* Location */} + {selectedEventType?.location && ( + + + ( + + )} + /> + + )} + {/* Zoom Link */} + {selectedEventType?.zoomLink && ( + + + + ( + + )} + /> + {errors.zoomLink?.message} + + + )} + {selectedEventType?.shop && ( + + + + ( + value?.[0] === s.id) || null} + onChange={(_, newValue) => onChange(newValue ? [newValue.id] : [])} + getOptionLabel={(option) => option.label} + renderInput={(params) => } + sx={{ flex: 1 }} + /> + )} + /> + + {shopIds.length > 0 && ( + + {(() => { + const shop = shops?.find((s) => s.shopId === shopIds[0]); + if (!shop || !shop.description) return null; + return ( + + + Reserve on Robin: + + + {shop.description} + + + ); + })()} + + )} + + )} + {selectedEventType?.machinery && ( + + + ( + value?.[0] === m.id) || null} + onChange={(_, newValue) => onChange(newValue ? [newValue.id] : [])} + getOptionLabel={(option) => option.label} + renderInput={(params) => } + sx={{ flex: 1 }} + /> + )} + /> + + )} + {selectedEventType?.workPackage && ( + + + ( + value?.[0] === wp.id) || null} + onChange={(_, newValue) => { + if (newValue?.id !== 'loading') { + onChange(newValue ? [newValue.id] : []); + } + }} + getOptionLabel={(option) => option.label} + getOptionDisabled={(option) => option.id === 'loading'} + renderInput={(params) => } + sx={{ flex: 1 }} + /> + )} + /> + + )} + {/* Question Document Link */} + {selectedEventType?.questionDocument && ( + + + ( + + )} + /> + + )} + {/* Documents */} + {selectedEventType?.documents && ( + + + + + Documents + + +
    + {documentFiles.map((docFile, index) => ( +
  • + + + {docFile.name} + + handleDocumentRemove(index)} + sx={{ + padding: '0px', + marginLeft: '2px' + }} + > + + + +
  • + ))} +
+ + {errors.documentFiles?.message} +
+
+
+ )} + {/* Description */} + {selectedEventType?.description && ( + + + ( + + )} + /> + + )} +
+
+ + ); +}; +export default EventModal; diff --git a/src/frontend/src/pages/CalendarPage/Components/EventTimeSlot.tsx b/src/frontend/src/pages/CalendarPage/Components/EventTimeSlot.tsx new file mode 100644 index 0000000000..386e447eae --- /dev/null +++ b/src/frontend/src/pages/CalendarPage/Components/EventTimeSlot.tsx @@ -0,0 +1,41 @@ +import { Box } from '@mui/system'; + +interface EventTimeSlotProps { + backgroundColor?: string; + onClick?: () => void; + selected?: boolean; + onMouseDown?: (e: React.MouseEvent) => void; + onMouseEnter?: (e: React.MouseEvent) => void; + onMouseUp?: () => void; +} + +const EventTimeSlot: React.FC = ({ + backgroundColor, + onClick, + selected = false, + onMouseDown, + onMouseEnter, + onMouseUp +}) => { + return ( + + + + ); +}; + +export default EventTimeSlot; diff --git a/src/frontend/src/pages/CalendarPage/DesignReviewAvailabilityInfo.tsx b/src/frontend/src/pages/CalendarPage/DesignReviewAvailabilityInfo.tsx deleted file mode 100644 index 29a9d3e11e..0000000000 --- a/src/frontend/src/pages/CalendarPage/DesignReviewAvailabilityInfo.tsx +++ /dev/null @@ -1,77 +0,0 @@ -import { DesignReview } from 'shared'; -import { Grid, Table, TableBody, TableCell, TableContainer, TableHead, TableRow, Typography } from '@mui/material'; -import ColumnHeader from '../FinancePage/FinanceComponents/ColumnHeader'; -import { fullNamePipe } from '../../utils/pipes'; - -interface DesignReviewAvailabilityInfoProps { - designReview: DesignReview; -} - -export const DesignReviewAvailabilityInfo: React.FC = ({ designReview }) => { - return ( - - - - - - - Required - - - - - - - - {designReview.requiredMembers.map((member) => ( - - - {fullNamePipe(member)} - - - - {designReview.confirmedMembers.some((confirmedMember) => confirmedMember.userId === member.userId) - ? 'Yes' - : 'No'} - - - - ))} - -
-
-
- - - - - - Optional - - - - - - - - {designReview.optionalMembers.map((member) => ( - - - {fullNamePipe(member)} - - - - {designReview.confirmedMembers.some((confirmedMember) => confirmedMember.userId === member.userId) - ? 'Yes' - : 'No'} - - - - ))} - -
-
-
-
- ); -}; diff --git a/src/frontend/src/pages/CalendarPage/DesignReviewCreateModal.tsx b/src/frontend/src/pages/CalendarPage/DesignReviewCreateModal.tsx deleted file mode 100644 index 7050a8392e..0000000000 --- a/src/frontend/src/pages/CalendarPage/DesignReviewCreateModal.tsx +++ /dev/null @@ -1,318 +0,0 @@ -import NERFormModal from '../../components/NERFormModal'; -import * as yup from 'yup'; -import { yupResolver } from '@hookform/resolvers/yup'; -import { Controller, useForm } from 'react-hook-form'; -import { - Autocomplete, - Box, - FormControl, - FormHelperText, - FormLabel, - MenuItem, - Select, - SelectChangeEvent, - TextField, - Typography -} from '@mui/material'; -import { DatePicker } from '@mui/x-date-pickers'; -import { useToast } from '../../hooks/toasts.hooks'; -import { useEffect, useMemo, useState } from 'react'; -import { - TeamType, - WbsElementStatus, - WbsNumber, - WorkPackage, - validateWBS, - wbsNamePipe, - wbsNumComparator, - wbsPipe -} from 'shared'; -import { useCreateDesignReviews } from '../../hooks/design-reviews.hooks'; -import { useAllMembers } from '../../hooks/users.hooks'; -import ErrorPage from '../ErrorPage'; -import LoadingIndicator from '../../components/LoadingIndicator'; -import { userToAutocompleteOption } from '../../utils/teams.utils'; -import { useQuery } from '../../hooks/utils.hooks'; -import NERAutocomplete from '../../components/NERAutocomplete'; -import { useAllWorkPackages } from '../../hooks/work-packages.hooks'; - -const schema = yup.object().shape({ - date: yup.date().required('Date is required'), - startTime: yup.number().required('Start time is required'), - teamTypeId: yup.string().required('Team Type is required'), - endTime: yup - .number() - .moreThan(yup.ref('startTime'), `End time must be after the start time`) - .required('End time is required'), - wbsNum: yup.string().required('Work Package is required'), - optionalMemberIds: yup.array().of(yup.string().required()).required(), - requiredMemberIds: yup.array().of(yup.string().required()).required() -}); - -interface CreateDesignReviewFormInput { - date: Date; - startTime: number; - endTime: number; - teamTypeId: string; - requiredMemberIds: string[]; - optionalMemberIds: string[]; - wbsNum: string; -} - -interface DesignReviewCreateModalProps { - showModal: boolean; - handleClose: () => void; - teamTypes: TeamType[]; - defaultDate: Date; - defaultWbsNum?: WbsNumber; -} - -export const DesignReviewCreateModal: React.FC = ({ - showModal, - handleClose, - teamTypes, - defaultDate, - defaultWbsNum -}) => { - const query = useQuery(); - - const toast = useToast(); - const [datePickerOpen, setDatePickerOpen] = useState(false); - const [requiredMembers, setRequiredMembers] = useState([].map(userToAutocompleteOption)); - const [optionalMembers, setOptionalMembers] = useState([].map(userToAutocompleteOption)); - const { isLoading: allUsersIsLoading, isError: allUsersIsError, error: allUsersError, data: users } = useAllMembers(); - - const { - isLoading: allWorkPackagesIsLoading, - isError: allWorkPackagesIsError, - error: allWorkPackagesError, - data: allWorkPackages - } = useAllWorkPackages(); - - const { mutateAsync, isLoading } = useCreateDesignReviews(); - - const defaultFormData = useMemo(() => { - return { - date: defaultDate, - startTime: 0, - endTime: 1, - teamTypeId: '', - wbsNum: defaultWbsNum ? wbsPipe(defaultWbsNum) : query.get('wbsNum') || '', - requiredMemberIds: [], - optionalMemberIds: [] - }; - }, [defaultDate, defaultWbsNum, query]); - - const onSubmit = async (data: CreateDesignReviewFormInput) => { - try { - await mutateAsync({ - dateScheduled: data.date, - teamTypeId: data.teamTypeId, - requiredMemberIds: requiredMembers.map((member) => member.id), - optionalMemberIds: optionalMembers.map((member) => member.id), - wbsNum: validateWBS(data.wbsNum), - meetingTimes: [0] - }); - } catch (error: unknown) { - if (error instanceof Error) { - toast.error(error.message); - } - } - handleClose(); - }; - - const { - handleSubmit, - control, - reset, - setValue, - formState: { errors } - } = useForm({ - resolver: yupResolver(schema), - defaultValues: defaultFormData - }); - - useEffect(() => { - reset(defaultFormData); - }, [defaultDate, reset, defaultFormData]); - - if (allUsersIsError) return ; - if (allWorkPackagesIsError) return ; - if (allUsersIsLoading || allWorkPackagesIsLoading || !allWorkPackages || !users || isLoading) return ; - - const memberOptions = users.map(userToAutocompleteOption); - - const wbsDropdownOptions: { label: string; id: string }[] = []; - - allWorkPackages.forEach((workPackage: WorkPackage) => { - if (workPackage.status === WbsElementStatus.Active) { - wbsDropdownOptions.push({ - label: `${wbsNamePipe(workPackage)}`, - id: wbsPipe(workPackage.wbsNum) - }); - } - }); - - wbsDropdownOptions.sort((wp1, wp2) => wbsNumComparator(wp2.id, wp1.id)); - - return ( - reset({ date: defaultDate })} - handleUseFormSubmit={handleSubmit} - onFormSubmit={onSubmit} - formId="create-design-review-form" - showCloseButton - > - - Work Package - { - const onClear = () => { - setValue('wbsNum', ''); - onChange(''); - setValue('teamTypeId', ''); - }; - - const handleWorkPackageSelect = async (selectedValue: string) => { - onChange(selectedValue); - setValue('wbsNum', selectedValue); - - const workPackage = allWorkPackages.find((wp) => wbsPipe(wp.wbsNum) === selectedValue); - - if (workPackage) { - const { teamTypes } = workPackage; - - if (teamTypes.length > 0) { - const [teamType] = teamTypes; - setValue('teamTypeId', teamType ? teamType.teamTypeId : ''); - } else { - setValue('teamTypeId', ''); - } - } - }; - - return ( - { - newValue ? handleWorkPackageSelect(newValue.id) : onClear(); - }} - options={wbsDropdownOptions} - size="medium" - placeholder="Select a work package" - value={wbsDropdownOptions.find((element) => element.id === value) || null} - /> - ); - }} - /> - {errors.wbsNum?.message} - - - - Design Review Date - ( - setDatePickerOpen(false)} - onOpen={() => setDatePickerOpen(true)} - onChange={(newValue) => { - onChange(newValue ?? defaultDate); - }} - slotProps={{ - textField: { - error: !!errors.date, - helperText: errors.date?.message, - onClick: () => setDatePickerOpen(true), - inputProps: { readOnly: true }, - fullWidth: true - } - }} - /> - )} - /> - {errors.date?.message} - - - Team - ( - - )} - /> - {errors.teamTypeId?.message} - - - - Required Members - option.id === value.id} - filterSelectedOptions - multiple - id="tags-standard" - options={memberOptions} - value={requiredMembers} - onChange={(_event, newValue) => setRequiredMembers(newValue)} - getOptionLabel={(option) => option.label} - renderInput={(params) => } - /> - - - Optional Members - option.id === value.id} - filterSelectedOptions - multiple - id="tags-standard" - options={memberOptions} - value={optionalMembers} - onChange={(_event, newValue) => setOptionalMembers(newValue)} - getOptionLabel={(option) => option.label} - renderInput={(params) => } - /> - - - ); -}; diff --git a/src/frontend/src/pages/CalendarPage/DesignReviewDetailPage/AvailabilityScheduleView.tsx b/src/frontend/src/pages/CalendarPage/DesignReviewDetailPage/AvailabilityScheduleView.tsx deleted file mode 100644 index ac66fd3829..0000000000 --- a/src/frontend/src/pages/CalendarPage/DesignReviewDetailPage/AvailabilityScheduleView.tsx +++ /dev/null @@ -1,119 +0,0 @@ -import { Grid } from '@mui/material'; -import { Availability, DesignReview, getDayOfWeek, getNextSevenDays, User } from 'shared'; -import { - enumToArray, - REVIEW_TIMES, - HeatmapColors, - getBackgroundColor, - NUMBER_OF_TIME_SLOTS -} from '../../../utils/design-review.utils'; -import TimeSlot from '../../../components/TimeSlot'; -import React, { useState } from 'react'; -import { datePipe } from '../../../utils/pipes'; - -interface AvailabilityScheduleViewProps { - availableUsers: Map; - unavailableUsers: Map; - usersToAvailabilities: Map; - existingMeetingData: Map; - setCurrentAvailableUsers: (val: User[]) => void; - setCurrentUnavailableUsers: (val: User[]) => void; - onSelectedTimeslotChanged: (val: number | null, day: Date | null) => void; - dateRangeTitle: string; - designReview: DesignReview; -} - -const AvailabilityScheduleView: React.FC = ({ - availableUsers, - unavailableUsers, - usersToAvailabilities, - existingMeetingData, - setCurrentAvailableUsers, - setCurrentUnavailableUsers, - dateRangeTitle, - onSelectedTimeslotChanged, - designReview -}) => { - const totalUsers = usersToAvailabilities.size; - const [selectedTimeslot, setSelectedTimeslot] = useState(null); - const potentialDays = getNextSevenDays(designReview.initialDate); - - const handleTimeslotClick = (index: number, day: Date) => { - if (selectedTimeslot === index) { - setSelectedTimeslot(null); // unselect - setCurrentAvailableUsers([]); - setCurrentUnavailableUsers([]); - } else { - setSelectedTimeslot(index); // select - setCurrentAvailableUsers(availableUsers.get(index) || []); - setCurrentUnavailableUsers(unavailableUsers.get(index) || []); - } - - onSelectedTimeslotChanged(index, day); - }; - - const handleOnMouseOver = (index: number) => { - setCurrentAvailableUsers(availableUsers.get(index) || []); - setCurrentUnavailableUsers(unavailableUsers.get(index) || []); - }; - - const handleOnMouseLeave = (): void => { - if (selectedTimeslot === null) { - setCurrentAvailableUsers([]); - setCurrentUnavailableUsers([]); - } - }; - - // Populates the availableUsers map - for (let time = 0; time < NUMBER_OF_TIME_SLOTS; time++) { - availableUsers.set(time, []); - } - usersToAvailabilities.forEach((availabilities, user) => { - let i = 0; - availabilities.forEach((availability) => { - availability.availability.forEach((time) => { - const usersAtTime = availableUsers.get(enumToArray(REVIEW_TIMES).length * i + time) || []; - usersAtTime.push(user); - availableUsers.set(enumToArray(REVIEW_TIMES).length * i + time, usersAtTime); - }); - i++; - }); - }); - - // Populates the unavailableUsers map - const allUsers = [...usersToAvailabilities.keys()]; - for (let time = 0; time < NUMBER_OF_TIME_SLOTS; time++) { - const currentUsers = availableUsers.get(time) || []; - const currentUnavailableUsers = allUsers.filter((user) => !currentUsers.includes(user)); - unavailableUsers.set(time, currentUnavailableUsers); - } - - return ( - - - {potentialDays.map((day) => ( - - ))} - {enumToArray(REVIEW_TIMES).map((time, timeIndex) => ( - - - {potentialDays.map((day, dayIndex) => { - const index = dayIndex * enumToArray(REVIEW_TIMES).length + timeIndex; - return ( - handleTimeslotClick(index, day)} - onMouseOver={() => handleOnMouseOver(index)} - icon={existingMeetingData.get(index)} - /> - ); - })} - - ))} - - ); -}; - -export default AvailabilityScheduleView; diff --git a/src/frontend/src/pages/CalendarPage/DesignReviewDetailPage/AvailabilityView.tsx b/src/frontend/src/pages/CalendarPage/DesignReviewDetailPage/AvailabilityView.tsx deleted file mode 100644 index 923c758933..0000000000 --- a/src/frontend/src/pages/CalendarPage/DesignReviewDetailPage/AvailabilityView.tsx +++ /dev/null @@ -1,140 +0,0 @@ -import { Grid } from '@mui/material'; -import { - Availability, - DesignReview, - DesignReviewStatus, - getMostRecentAvailabilities, - isSameDay, - User, - UserWithScheduleSettings -} from 'shared'; -import { useState } from 'react'; -import AvailabilityScheduleView from './AvailabilityScheduleView'; -import UserAvailabilites from './UserAvailabilitesView'; -import { getWeekDateRange } from '../../../utils/design-review.utils'; -import { dateRangePipe } from '../../../utils/pipes'; -import { FinalizeReviewInformation } from './DesignReviewDetailPage'; -import { useManyUsersWithScheduleSettings } from '../../../hooks/users.hooks'; -import LoadingIndicator from '../../../components/LoadingIndicator'; -import ErrorPage from '../../ErrorPage'; - -interface AvailabilityViewProps { - designReview: DesignReview; - allDesignReviews: DesignReview[]; - handleEdit: (data?: FinalizeReviewInformation) => void; - selectedDate: Date; - setSelectDate: (date: Date) => void; - startTime: number; - endTime: number; - setStartTime: (time: number) => void; - setEndTime: (time: number) => void; - requiredUserIds: string[]; - optionalUserIds: string[]; -} - -const AvailabilityView: React.FC = ({ - designReview, - allDesignReviews, - handleEdit, - selectedDate, - setSelectDate, - startTime, - endTime, - setStartTime, - setEndTime, - requiredUserIds, - optionalUserIds -}) => { - const { - data: relevantUsers, - isLoading, - isError, - error - } = useManyUsersWithScheduleSettings([...requiredUserIds, ...optionalUserIds]); - - const availableUsers = new Map(); - const unavailableUsers = new Map(); - const existingMeetingData = new Map(); - const usersToAvailabilities = new Map(); - - const [currentAvailableUsers, setCurrentAvailableUsers] = useState([]); - const [currentUnavailableUsers, setCurrentUnavailableUsers] = useState([]); - const [startDateRange, endDateRange] = getWeekDateRange(selectedDate); - - if (isLoading || !relevantUsers) return ; - if (isError) return ; - - const currentWeekDesignReviews = allDesignReviews.filter((currDr) => { - const drDate = new Date(currDr.dateScheduled).getTime(); - const startRange = startDateRange.getTime(); - const endRange = endDateRange.getTime(); - - return drDate >= startRange && drDate <= endRange; - }); - - const onSelectedTimeslotChanged = (index: number | null, day: Date | null) => { - if (index === null || day === null) return; - setStartTime(index); - setEndTime(index + 1); - setSelectDate(day); - }; - - const conflictingDesignReviews = allDesignReviews.filter((currDr) => { - const times = []; - for (let i = startTime; i < endTime; i++) { - times.push(i); - } - const cleanDate = new Date(currDr.dateScheduled.getTime() - currDr.dateScheduled.getTimezoneOffset() * -60000); - return ( - currDr.status === DesignReviewStatus.SCHEDULED && - cleanDate.toLocaleDateString() === selectedDate.toLocaleDateString() && - times.some((time) => isSameDay(currDr.dateScheduled, selectedDate) && currDr.meetingTimes.includes(time)) && - currDr.designReviewId !== designReview.designReviewId - ); - }); - - currentWeekDesignReviews.forEach((dr) => - dr.meetingTimes.forEach((meetingTime) => { - if (dr.status === DesignReviewStatus.SCHEDULED && dr.designReviewId !== designReview.designReviewId) - existingMeetingData.set(meetingTime, dr.teamType.iconName); - }) - ); - - relevantUsers.forEach((user: UserWithScheduleSettings) => { - const availability = getMostRecentAvailabilities(user.scheduleSettings?.availabilities ?? [], designReview.initialDate); - - usersToAvailabilities.set(user, availability ?? []); - }); - - return ( - - - - - - - - - ); -}; - -export default AvailabilityView; diff --git a/src/frontend/src/pages/CalendarPage/DesignReviewDetailPage/DesignReviewDetailPage.tsx b/src/frontend/src/pages/CalendarPage/DesignReviewDetailPage/DesignReviewDetailPage.tsx deleted file mode 100644 index ebd53f1152..0000000000 --- a/src/frontend/src/pages/CalendarPage/DesignReviewDetailPage/DesignReviewDetailPage.tsx +++ /dev/null @@ -1,301 +0,0 @@ -import { - Autocomplete, - Box, - Checkbox, - Grid, - MenuItem, - Select, - SelectChangeEvent, - TextField, - Typography, - useTheme -} from '@mui/material'; -import PageLayout from '../../../components/PageLayout'; -import AvailabilityView from './AvailabilityView'; -import { useAllMembers } from '../../../hooks/users.hooks'; -import LoadingIndicator from '../../../components/LoadingIndicator'; -import ErrorPage from '../../ErrorPage'; -import { userToAutocompleteOption } from '../../../utils/teams.utils'; -import { useState } from 'react'; -import CheckBoxIcon from '@mui/icons-material/CheckBox'; -import CheckBoxOutlineBlankIcon from '@mui/icons-material/CheckBoxOutlineBlank'; -import { DatePicker } from '@mui/x-date-pickers'; -import { DesignReview, DesignReviewStatus } from 'shared'; -import { EditDesignReviewPayload, useAllDesignReviews, useEditDesignReview } from '../../../hooks/design-reviews.hooks'; -import { designReviewNamePipe, meetingStartTimePipe } from '../../../utils/pipes'; -import { HOURS } from '../../../utils/design-review.utils'; -import { useHistory } from 'react-router-dom'; -import { useToast } from '../../../hooks/toasts.hooks'; -import { routes } from '../../../utils/routes'; - -export interface DesignReviewEditData { - requiredUserIds: string[]; - optionalUserIds: string[]; - selectedDate: Date; - startTime: number; - endTime: number; -} -interface DesignReviewDetailPageProps { - designReview: DesignReview; -} - -export interface FinalizeReviewInformation { - docTemplateLink: string; - zoomLink?: string; - location?: string; - meetingType: string[]; -} - -const DesignReviewDetailPage: React.FC = ({ designReview }) => { - const theme = useTheme(); - const [requiredUsers, setRequiredUsers] = useState(designReview.requiredMembers.map(userToAutocompleteOption)); - const [optionalUsers, setOptionalUsers] = useState(designReview.optionalMembers.map(userToAutocompleteOption)); - const [date, setDate] = useState( - new Date(designReview.dateScheduled.getTime() - designReview.dateScheduled.getTimezoneOffset() * -60000) - ); - const [startTime, setStateTime] = useState(designReview.meetingTimes[0] % 12); - const [endTime, setEndTime] = useState((designReview.meetingTimes[designReview.meetingTimes.length - 1] % 12) + 1); - const { mutateAsync: editDesignReview } = useEditDesignReview(designReview.designReviewId); - - const { isLoading: allUsersIsLoading, isError: allUsersIsError, error: allUsersError, data: allMembers } = useAllMembers(); - const { - data: allDesignReviews, - isError: allDesignReviewsIsError, - error: allDesignReviewsError, - isLoading: allDesignReviewsIsLoading - } = useAllDesignReviews(); - const history = useHistory(); - const toast = useToast(); - - if (allUsersIsError) return ; - if (allDesignReviewsIsError) return ; - if (allUsersIsLoading || !allMembers || allDesignReviewsIsLoading || !allDesignReviews) return ; - - const users = allMembers.map(userToAutocompleteOption); - - const handleDateChange = (newDate: Date | null) => { - if (newDate) { - const updatedDateTime = new Date(); - updatedDateTime.setFullYear(newDate.getFullYear(), newDate.getMonth(), newDate.getDate()); - setDate(updatedDateTime); - } - }; - - const handleSelectingRequiredUser = (newValue: { label: string; id: string }[]) => { - const newRequiredUserIds = new Set(newValue.map((user) => user.id)); - const filteredOptionalUsers = optionalUsers.filter((user) => !newRequiredUserIds.has(user.id)); - setOptionalUsers(filteredOptionalUsers); - setRequiredUsers(newValue); - }; - - const handleEdit = async (data?: FinalizeReviewInformation) => { - const times = []; - for (let i = startTime; i < endTime; i++) { - times.push(i % 12); - } - date.setHours(12); - - try { - const payload: EditDesignReviewPayload = { - dateScheduled: date, - teamTypeId: designReview.teamType.teamTypeId, - requiredMembersIds: requiredUsers.map((user) => user.id), - optionalMembersIds: optionalUsers.map((user) => user.id), - isOnline: data?.meetingType.includes('virtual') ?? false, - isInPerson: data?.meetingType.includes('inPerson') ?? false, - status: data ? DesignReviewStatus.SCHEDULED : designReview.status, - attendees: [], - meetingTimes: times, - docTemplateLink: data?.docTemplateLink ?? designReview.docTemplateLink, - zoomLink: data?.zoomLink ?? designReview.zoomLink, - location: data?.location ?? designReview.location - }; - await editDesignReview(payload); - history.push(routes.CALENDAR); - } catch (error: unknown) { - if (error instanceof Error) { - toast.error(error.message); - } - } - }; - - const DateField = () => { - return ; - }; - - // styling for the editable fields at the top of the page with light grey backgrounds - const EditableFieldStyle = { - fontSize: '16px', - backgroundColor: 'grey', - borderRadius: 3, - textAlign: 'left', - border: '2px solid', - width: '100%' - }; - - // styling for the non-editable fields at the top of the page with dark backgrounds - const NonEditableFieldStyle = { - padding: 1.5, - paddingTop: 1.5, - paddingBottom: 1.5, - fontSize: '1.2em', - backgroundColor: theme.palette.background.paper, - borderRadius: 3, - textAlign: 'center', - width: '100%', - border: 'none' - }; - - return ( - - - - Name - - - {designReviewNamePipe(designReview)} - - - - - - - - to - - - - - - - Required - - - - option.id === value.id} - multiple - disableCloseOnSelect - limitTags={1} - renderTags={() => null} - id="required-users" - options={users} - value={requiredUsers} - onChange={(_event, newValue) => handleSelectingRequiredUser(newValue)} - getOptionLabel={(option) => option.label} - renderOption={(props, option, { selected }) => ( -
  • - } - checkedIcon={} - style={{ marginRight: 8 }} - checked={selected} - /> - {option.label} -
  • - )} - renderInput={(params) => ( - - )} - /> -
    -
    - - Optional - - - - option.id === value.id} - multiple - disableCloseOnSelect - limitTags={1} - renderTags={() => null} - id="optional-users" - options={users.filter((user) => !requiredUsers.some((reqUser) => reqUser.id === user.id))} - value={optionalUsers} - onChange={(_event, newValue) => setOptionalUsers(newValue)} - getOptionLabel={(option) => option.label} - renderOption={(props, option, { selected }) => ( -
  • - } - checkedIcon={} - style={{ marginRight: 8 }} - checked={selected} - /> - {option.label} -
  • - )} - renderInput={(params) => ( - - )} - /> -
    -
    -
    -
    -
    - user.id)} - optionalUserIds={optionalUsers.map((user) => user.id)} - startTime={startTime} - endTime={endTime} - setStartTime={setStateTime} - setEndTime={setEndTime} - /> -
    - ); -}; - -export default DesignReviewDetailPage; diff --git a/src/frontend/src/pages/CalendarPage/DesignReviewDetailPage/DesignReviewDetails.tsx b/src/frontend/src/pages/CalendarPage/DesignReviewDetailPage/DesignReviewDetails.tsx deleted file mode 100644 index 6d23ff67b9..0000000000 --- a/src/frontend/src/pages/CalendarPage/DesignReviewDetailPage/DesignReviewDetails.tsx +++ /dev/null @@ -1,21 +0,0 @@ -/* - * 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 LoadingIndicator from '../../../components/LoadingIndicator'; -import { useParams } from 'react-router-dom'; -import ErrorPage from '../../ErrorPage'; -import DesignReviewDetailPage from './DesignReviewDetailPage'; -import { useSingleDesignReview } from '../../../hooks/design-reviews.hooks'; - -const DesignReviewDetails: React.FC = () => { - const { id } = useParams<{ id: string }>(); - const { data: designReview, isError, error, isLoading } = useSingleDesignReview(id); - - if (isError) return ; - if (!designReview || isLoading) return ; - - return ; -}; - -export default DesignReviewDetails; diff --git a/src/frontend/src/pages/CalendarPage/DesignReviewDetailPage/FinalizeDesignReviewDetailsModal.tsx b/src/frontend/src/pages/CalendarPage/DesignReviewDetailPage/FinalizeDesignReviewDetailsModal.tsx deleted file mode 100644 index f4aed95eb2..0000000000 --- a/src/frontend/src/pages/CalendarPage/DesignReviewDetailPage/FinalizeDesignReviewDetailsModal.tsx +++ /dev/null @@ -1,186 +0,0 @@ -import { Box, Grid, Link, ToggleButton, ToggleButtonGroup, Typography, Tooltip } from '@mui/material'; -import HelpIcon from '@mui/icons-material/Help'; -import React, { useState, useEffect } from 'react'; -import { DesignReview, wbsPipe } from 'shared'; -import { meetingStartTimePipe } from '../../../utils/pipes'; -import NERFormModal from '../../../components/NERFormModal'; -import ReactHookTextField from '../../../components/ReactHookTextField'; -import { useForm } from 'react-hook-form'; -import * as yup from 'yup'; -import { yupResolver } from '@hookform/resolvers/yup'; -import { FinalizeReviewInformation } from './DesignReviewDetailPage'; -import { useCurrentUser, useUserScheduleSettings } from '../../../hooks/users.hooks'; - -interface FinalizeDesignReviewProps { - open: boolean; - setOpen: (val: boolean) => void; - designReview: DesignReview; - conflictingDesignReviews: DesignReview[]; - startTime: number; - selectedDate: Date; - finalizeDesignReview: (data: FinalizeReviewInformation) => void; -} - -const FinalizeDesignReviewDetailsModal = ({ - open, - setOpen, - designReview, - conflictingDesignReviews, - finalizeDesignReview, - startTime, - selectedDate -}: FinalizeDesignReviewProps) => { - const [meetingType, setMeetingType] = useState([]); - const currentUser = useCurrentUser(); - const { data: userScheduleSettings } = useUserScheduleSettings(currentUser.userId); - - const createValidationSchema = () => - yup.object().shape({ - zoomLink: meetingType.includes('virtual') - ? yup.string().required('Meeting link is required for virtual meetings').url('Please enter a valid URL') - : yup.string().optional(), - location: yup.string().optional(), - docTemplateLink: yup.string().required('Question Doc is Required') - }); - - const title = `Finalize Design Review for ${designReview.wbsName}`; - - const designReviewConflicts = conflictingDesignReviews.map( - (designReview) => `${wbsPipe(designReview.wbsNum)} - ${designReview.wbsName} at ${meetingStartTimePipe([startTime])}` - ); - - const defaultValues = { - docTemplateLink: designReview.docTemplateLink ?? '', - zoomLink: designReview.zoomLink ?? userScheduleSettings?.personalZoomLink ?? '', - location: designReview.location ?? undefined - }; - - const { - handleSubmit, - control, - reset, - formState: { errors } - } = useForm({ - resolver: yupResolver(createValidationSchema()), - defaultValues, - mode: 'onChange' - }); - - const handleMeetingTypeChange = (_event: any, newMeetingType: string[]) => { - setMeetingType(newMeetingType); - reset(defaultValues); - }; - - const onSubmit = async (data: { docTemplateLink: string; zoomLink?: string; location?: string }) => { - finalizeDesignReview({ ...data, zoomLink: data.zoomLink ? data.zoomLink : undefined, meetingType }); - setOpen(false); - }; - - useEffect(() => { - if (userScheduleSettings && designReview.isOnline && !designReview.zoomLink) { - reset({ - docTemplateLink: designReview.docTemplateLink ?? '', - zoomLink: userScheduleSettings.personalZoomLink ?? '', - location: designReview.location ?? undefined - }); - } - if (designReview.zoomLink === '' && !designReview.isOnline) { - reset({ - zoomLink: undefined - }); - } - }, [userScheduleSettings, designReview, reset]); - - return ( - setOpen(false)} - title={title} - reset={() => reset(defaultValues)} - handleUseFormSubmit={handleSubmit} - onFormSubmit={onSubmit} - submitText="Schedule" - formId="finalize-design-review-form" - > - - Meeting Time: - {`${meetingStartTimePipe([ - startTime - ])} - ${selectedDate.toDateString()}`} - - - Meeting Type: - - Virtual - In-person - - - - - Question Doc: - - Doc Template - - - - - {meetingType.includes('virtual') && ( - - - Meeting Link: - - - - - - - )} - {meetingType.includes('inPerson') && ( - - Location: - - - )} - - {designReviewConflicts && designReviewConflicts.length > 0 && ( - - - Design Review Conflicts - - - - {designReviewConflicts.map((conflictDesign, index) => ( - - {conflictDesign} - - ))} - - - - )} - - - ); -}; -export default FinalizeDesignReviewDetailsModal; diff --git a/src/frontend/src/pages/CalendarPage/DesignReviewDetailPage/UserAvailabilitesView.tsx b/src/frontend/src/pages/CalendarPage/DesignReviewDetailPage/UserAvailabilitesView.tsx deleted file mode 100644 index 32ef5f66f6..0000000000 --- a/src/frontend/src/pages/CalendarPage/DesignReviewDetailPage/UserAvailabilitesView.tsx +++ /dev/null @@ -1,152 +0,0 @@ -import { Typography } from '@mui/material'; -import { Box, useTheme } from '@mui/system'; -import { Availability, DesignReview, DesignReviewStatus, User } from 'shared'; -import { HeatmapColors } from '../../../utils/design-review.utils'; -import { fullNamePipe } from '../../../utils/pipes'; -import NERFailButton from '../../../components/NERFailButton'; -import NERSuccessButton from '../../../components/NERSuccessButton'; -import { useState } from 'react'; -import FinalizeDesignReviewDetailsModal from './FinalizeDesignReviewDetailsModal'; -import { FinalizeReviewInformation } from './DesignReviewDetailPage'; -import { useHistory } from 'react-router-dom'; -import { routes } from '../../../utils/routes'; - -interface UserAvailabilitiesProps { - currentAvailableUsers: User[]; - currentUnavailableUsers: User[]; - usersToAvailabilities: Map; - designReview: DesignReview; - conflictingDesignReviews: DesignReview[]; - selectedDate: Date; - startTime: number; - handleEdit: (data?: FinalizeReviewInformation) => void; -} - -const UserAvailabilites: React.FC = ({ - currentAvailableUsers, - currentUnavailableUsers, - usersToAvailabilities, - designReview, - conflictingDesignReviews, - handleEdit, - selectedDate, - startTime -}) => { - const theme = useTheme(); - const history = useHistory(); - const [showFinalizeDesignReviewDetailsModal, setShowFinalizeDesignReviewDetailsModal] = useState(false); - const totalUsers = usersToAvailabilities.size; - - const handleCancel = () => { - history.push(routes.CALENDAR); - }; - - return ( - - - - 0/{totalUsers} - {Array.from({ length: 6 }, (_, i) => ( - - ))} - - {totalUsers}/{totalUsers} - - - - - - Available - - - {currentAvailableUsers.map((user) => ( - {fullNamePipe(user)} - ))} - - - - - Unavailable - - - {currentUnavailableUsers.map((user) => ( - {fullNamePipe(user)} - ))} - - - - - Cancel - handleEdit()}> - Save - - setShowFinalizeDesignReviewDetailsModal(true)} - > - Finalize - - - - - - ); -}; - -export default UserAvailabilites; diff --git a/src/frontend/src/pages/CalendarPage/DesignReviewSummaryModal.tsx b/src/frontend/src/pages/CalendarPage/DesignReviewSummaryModal.tsx deleted file mode 100644 index 9b3b1abdaa..0000000000 --- a/src/frontend/src/pages/CalendarPage/DesignReviewSummaryModal.tsx +++ /dev/null @@ -1,166 +0,0 @@ -import { DesignReview, DesignReviewStatus, TeamType, isAdmin, wbsPipe } from 'shared'; -import NERModal from '../../components/NERModal'; -import { Box, Chip, IconButton, Link, Typography } from '@mui/material'; -import EditIcon from '@mui/icons-material/Edit'; -import { useState } from 'react'; -import DesignReviewSummaryModalDetails from './SummaryComponents/DesignReviewSummaryModalDetails'; -import DesignReviewSummaryModalAttendees from './SummaryComponents/DesignReviewSummaryModalAttendees'; -import { getTeamTypeIcon } from './CalendarComponents/CalendarDayCard'; -import { Link as RouterLink, useHistory } from 'react-router-dom'; -import { routes } from '../../utils/routes'; -import { useCurrentUser } from '../../hooks/users.hooks'; -import DeleteIcon from '@mui/icons-material/Delete'; -import { useToast } from '../../hooks/toasts.hooks'; -import { useDeleteDesignReview } from '../../hooks/design-reviews.hooks'; -import { designReviewStatusColor, designReviewStatusPipe } from '../../utils/design-review.utils'; -import NERSuccessButton from '../../components/NERSuccessButton'; -import { DesignReviewAvailabilityInfo } from './DesignReviewAvailabilityInfo'; -import { CheckCircle } from '@mui/icons-material'; - -interface DRCSummaryModalProps { - open: boolean; - onHide: () => void; - designReview: DesignReview; - teamTypes: TeamType[]; - markedStatus?: DesignReviewStatus; - setMarkedStatus?: (_: DesignReviewStatus) => void; -} - -const DRCSummaryModal: React.FC = ({ - open, - onHide, - designReview, - teamTypes, - markedStatus = DesignReviewStatus.UNCONFIRMED, - setMarkedStatus = () => {} -}: DRCSummaryModalProps) => { - const user = useCurrentUser(); - const toast = useToast(); - const history = useHistory(); - const [showDeleteModal, setShowDeleteModal] = useState(false); - - const { mutateAsync: deleteDesignReview } = useDeleteDesignReview(designReview.designReviewId); - - const isDesignReviewCreator = user.userId === designReview.userCreated.userId; - - const isScheduled = - designReview.status === DesignReviewStatus.SCHEDULED || designReview.status === DesignReviewStatus.DONE; - - const handleDelete = () => { - try { - deleteDesignReview(); - history.push(routes.CALENDAR); - } catch (e: unknown) { - if (e instanceof Error) { - toast.error(e.message, 3000); - } - } - }; - - const DeleteModal = () => { - return ( - setShowDeleteModal(false)} - title="Warning!" - cancelText="No" - submitText="Yes" - onSubmit={handleDelete} - > - Are you sure you want to delete this design review? - - ); - }; - - return ( - - {(isDesignReviewCreator || isAdmin(user.role)) && ( - <> - setShowDeleteModal(true)}> - - - - - - - )} - attendee.userId === user.userId) || isScheduled - } - > - - - - } - > - - - - - - - - {`${designReview.wbsName}`} - - - - - {isScheduled && ( - - )} - {designReview.status === DesignReviewStatus.CONFIRMED && ( - - - {isDesignReviewCreator && ( - - - Schedule Design Review - - - )} - - )} - {designReview.status === DesignReviewStatus.UNCONFIRMED && ( - - )} - - - - ); -}; - -export default DRCSummaryModal; diff --git a/src/frontend/src/pages/CalendarPage/EventClickPopup.tsx b/src/frontend/src/pages/CalendarPage/EventClickPopup.tsx new file mode 100644 index 0000000000..30ee70e755 --- /dev/null +++ b/src/frontend/src/pages/CalendarPage/EventClickPopup.tsx @@ -0,0 +1,550 @@ +import React, { useState } from 'react'; +import { Box, Button, IconButton, Link, Popover, Stack, Typography, useTheme } from '@mui/material'; +import { Calendar, DayOfWeek, EventInstance, EventType } from 'shared'; +import { Link as RouterLink } from 'react-router-dom'; +import { routes } from '../../utils/routes'; +import { getTeamTypeIcon } from './CalendarDayCard'; +import AccessTimeIcon from '@mui/icons-material/AccessTime'; +import LocationOnIcon from '@mui/icons-material/LocationOn'; +import GroupIcon from '@mui/icons-material/Group'; +import GroupsIcon from '@mui/icons-material/Groups'; +import CheckCircleIcon from '@mui/icons-material/CheckCircle'; +import DoNotDisturbIcon from '@mui/icons-material/DoNotDisturb'; +import ConstructionIcon from '@mui/icons-material/Construction'; +import StorefrontIcon from '@mui/icons-material/Storefront'; +import BusinessCenterIcon from '@mui/icons-material/BusinessCenter'; +import LinkIcon from '@mui/icons-material/Link'; +import ArticleIcon from '@mui/icons-material/Article'; +import DescriptionIcon from '@mui/icons-material/Description'; +import HelpIcon from '@mui/icons-material/Help'; +import EditIcon from '@mui/icons-material/Edit'; +import PeopleIcon from '@mui/icons-material/People'; +import DeleteIcon from '@mui/icons-material/Delete'; +import NERSuccessButton from '../../components/NERSuccessButton'; +import NERFailButton from '../../components/NERFailButton'; +import { useApproveEvent, useDeleteEvent, useDeleteScheduleSlot, useDenyEvent } from '../../hooks/calendar.hooks'; +import EditEventModal from './Components/EditEventModal'; +import DeleteSeriesConfirmationModal from './Components/DeleteSeriesConfirmationModal'; +import { useToast } from '../../hooks/toasts.hooks'; +import NERDeleteModal from '../../components/NERDeleteModal'; +import { formatTime } from '../../utils/datetime.utils'; + +export const getStatusIcon = (status: string, isLarge?: boolean) => { + const statusIcons: Map = new Map([ + ['UNCONFIRMED', ], + ['CONFIRMED', ], + ['SCHEDULED', ], + ['DONE', ] + ]); + return statusIcons.get(status); +}; + +const stopClick: React.MouseEventHandler = (e) => { + e.stopPropagation(); +}; + +interface EventClickContentProps { + event: EventInstance; + eventTypes: EventType[]; + calendars: Calendar[]; + dayOfWeek?: DayOfWeek; + disable: boolean; + addApprovalButtons: boolean; + onClose: () => void; + onEdit: (event: EventInstance) => void; + onDelete: (event: EventInstance) => void; + clickedDate?: Date; +} + +const joinPeople = (members: { firstName: string; lastName: string }[]) => + members.map((m) => `${m.firstName} ${m.lastName}`).join(', '); + +const hasValue = (v?: string | null) => { + const s = (v ?? '').trim(); + return s.length > 0 && s.toLowerCase() !== 'n/a'; +}; + +export const EventClickContent: React.FC = ({ + event, + eventTypes, + calendars, + dayOfWeek, + disable, + addApprovalButtons, + onClose, + onEdit, + onDelete, + clickedDate +}) => { + const { mutateAsync: approveEvent } = useApproveEvent(event.eventId); + const { mutateAsync: denyEvent } = useDenyEvent(event.eventId); + + const theme = useTheme(); + + const name = event.workPackages?.[0]?.wbsElement?.name || event.title; + + const specificEventType = eventTypes.find((et) => et.eventTypeId === event.eventTypeId); + const specificCalendar = calendars.find((calendar) => + calendar.eventTypes.some((et) => et.eventTypeId === specificEventType?.eventTypeId) + ); + const calendarColor = specificCalendar?.color ?? 'gray'; + + const showAvailabilityButton = true; + + const eventDate = clickedDate || event.startTime; + + const availabilityUrl = `${routes.NEW_CALENDAR}/event/${event.eventId}?date=${eventDate.toISOString()}`; + + const requiredText = event.requiredMembers.length > 0 ? joinPeople(event.requiredMembers) : ''; + const optionalText = event.optionalMembers.length > 0 ? joinPeople(event.optionalMembers) : ''; + const confirmedText = event.confirmedMembers.length > 0 ? joinPeople(event.confirmedMembers) : ''; + const deniedText = event.deniedMembers.length > 0 ? joinPeople(event.deniedMembers) : ''; + + const teamsText = event.teams.length > 0 ? event.teams.map((t) => t.teamName).join(', ') : ''; + const machineryText = event.machinery.length > 0 ? event.machinery.map((m) => m.name || 'Machinery').join(', ') : ''; + const shopsText = event.shops.length > 0 ? event.shops.map((s) => s.name).join(', ') : ''; + const workPackagesText = + event.workPackages.length > 0 ? event.workPackages.map((wp) => wp.wbsElement?.name || 'Work package').join(', ') : ''; + + const descriptionText = (event.description ?? '').trim(); + const locationText = (event.location ?? '').trim(); + + return ( + + + {!disable && ( + + { + stopClick(e); + onEdit(event); + }} + sx={{ + color: theme.palette.grey[500], + '&:hover': { + color: theme.palette.common.white, + bgcolor: 'transparent' + } + }} + > + + + { + stopClick(e); + onDelete(event); + }} + sx={{ + color: theme.palette.grey[500], + '&:hover': { + color: '#ef5350', + bgcolor: 'transparent' + } + }} + > + + + + )} + + {getTeamTypeIcon(event.teamType?.name ?? '', true)} + + {name} + + + + + {dayOfWeek && } + {dayOfWeek && !event.allDay && ( + + {formatTime(event.startTime)} – {formatTime(event.endTime)} + + )} + {dayOfWeek && event.allDay && All day} + {!dayOfWeek && } + {hasValue(locationText) && ( + <> + + {locationText} + + )} + + + + + {/* Required */} + {hasValue(requiredText) && ( + + + + Required: {requiredText} + + + )} + + {/* Optional */} + {hasValue(optionalText) && ( + + + + Optional: {optionalText} + + + )} + + {/* Confirmed */} + {hasValue(confirmedText) && ( + + + + Confirmed: {confirmedText} + + + )} + + {/* Denied */} + {hasValue(deniedText) && ( + + + + Denied: {deniedText} + + + )} + + {specificEventType?.requiresConfirmation && showAvailabilityButton && ( + + + + + )} + + {/* Teams */} + {hasValue(teamsText) && ( + + + + Teams: {teamsText} + + + )} + + {/* Machinery */} + {hasValue(machineryText) && ( + + + + Machinery: {machineryText} + + + )} + + {/* Shops */} + {hasValue(shopsText) && ( + + + + Shops: {shopsText} + + + )} + + {/* Work packages */} + {hasValue(workPackagesText) && ( + + + + Work packages: {workPackagesText} + + + )} + + {/* Zoom link */} + {hasValue(event.zoomLink) && ( + + + {disable ? ( + + Zoom Link + + ) : ( + e.stopPropagation()}> + Zoom Link + + )} + + )} + + {/* Question document */} + {hasValue(event.questionDocumentLink) && ( + + + + Question doc:{' '} + {disable ? ( + + Question Document Link + + ) : ( + + {event.questionDocumentLink ? ( + e.stopPropagation()} + target="_blank" + rel="noopener" + > + Question Document Link + + ) : ( + 'N/A' + )} + + )} + + + )} + + {/* Description */} + {hasValue(descriptionText) && ( + + + + Description: {descriptionText} + + + )} + + {/* Status */} + {hasValue(event.status) && ( + + {getStatusIcon(event.status!, false) ?? } + + Status: {event.status} + + + )} + {addApprovalButtons && ( + + { + await approveEvent(); + onClose(); + }} + > + Approve + + { + await denyEvent(); + onClose(); + }} + > + Deny + + + )} + + + ); +}; + +export interface EventClickPopupProps { + clickedEvent?: EventInstance; + anchorPosition?: { top: number; left: number }; + onClose: () => void; + eventTypes: EventType[]; + calendars: Calendar[]; + dayOfWeek?: DayOfWeek; + disable?: boolean; + addApprovalButtons?: boolean; + clickedDate?: Date; +} + +export const EventClickPopup: React.FC = ({ + clickedEvent, + anchorPosition, + onClose, + eventTypes, + calendars, + dayOfWeek, + disable = false, + addApprovalButtons = false, + clickedDate +}) => { + const toast = useToast(); + const [showEditModal, setShowEditModal] = useState(false); + const [showDeleteModal, setShowDeleteModal] = useState(false); + const [showSeriesDeleteModal, setShowSeriesDeleteModal] = useState(false); + + const { mutateAsync: deleteEvent } = useDeleteEvent(clickedEvent?.eventId ?? ''); + const { mutateAsync: deleteScheduleSlot } = useDeleteScheduleSlot( + clickedEvent?.eventId ?? '', + clickedEvent?.scheduleSlotId ?? '' + ); + + const handleEdit = (_event: EventInstance) => { + setShowEditModal(true); + }; + + const handleDelete = () => { + // If the event is recurring, show the series delete modal. Otherwise only show regular confirmation modal + if (clickedEvent?.recurring) { + setShowSeriesDeleteModal(true); + } else { + setShowDeleteModal(true); + } + }; + + const handleDeleteConfirm = async () => { + try { + setShowDeleteModal(false); + onClose(); + await deleteEvent(); + toast.success('Event deleted successfully!'); + } catch (err) { + if (err instanceof Error) { + toast.error(err.message); + } + } + }; + + const handleSeriesDeleteConfirm = async (deleteEntireEvent: boolean) => { + try { + setShowSeriesDeleteModal(false); + onClose(); + if (deleteEntireEvent) { + await deleteEvent(); + toast.success('Event deleted successfully!'); + } else { + await deleteScheduleSlot(); + toast.success('Event occurrence deleted successfully!'); + } + } catch (err) { + if (err instanceof Error) { + toast.error(err.message); + } + } + }; + + return ( + + {clickedEvent && ( + + )} + {clickedEvent && showEditModal && ( + { + setShowEditModal(false); + onClose(); + }} + event={clickedEvent} + eventTypes={eventTypes} + /> + )} + + {clickedEvent && showDeleteModal && ( + { + setShowDeleteModal(false); + onClose(); + }} + formId="delete-event-form" + dataType={clickedEvent.title} + onFormSubmit={handleDeleteConfirm} + /> + )} + + {clickedEvent && showSeriesDeleteModal && ( + { + setShowSeriesDeleteModal(false); + onClose(); + }} + onConfirm={handleSeriesDeleteConfirm} + eventTitle={clickedEvent.title} + totalSlots={clickedEvent.totalScheduledSlots} + /> + )} + + ); +}; diff --git a/src/frontend/src/pages/CalendarPage/EventPartialInfoView.tsx b/src/frontend/src/pages/CalendarPage/EventPartialInfoView.tsx new file mode 100644 index 0000000000..0ec6347108 --- /dev/null +++ b/src/frontend/src/pages/CalendarPage/EventPartialInfoView.tsx @@ -0,0 +1,78 @@ +import AccessTimeIcon from '@mui/icons-material/AccessTime'; +import LocationOnIcon from '@mui/icons-material/LocationOn'; +import { Calendar, EventInstance, EventType } from 'shared'; +import GroupIcon from '@mui/icons-material/Group'; +import { Stack } from '@mui/system'; +import { getTeamTypeIcon } from './CalendarDayCard'; +import { Typography } from '@mui/material'; +import { formatTime } from '../../utils/datetime.utils'; + +interface EventInfoProps { + event: EventInstance; + eventTypes?: EventType[]; + calendars?: Calendar[]; + onClick: () => void; +} + +const EventPartialInfoView: React.FC = ({ event, eventTypes, calendars, onClick }) => { + const name = event.title; + const specificEventType = eventTypes?.find((eventType) => eventType.eventTypeId === event.eventTypeId); + const specificCalendar = calendars?.find((calendar) => + calendar.eventTypes.some((eventType) => eventType.eventTypeId === specificEventType?.eventTypeId) + ); + + return ( + { + e.stopPropagation(); + onClick(); + }} + sx={{ cursor: 'pointer', '&:hover': { opacity: 0.8 } }} + > + + + {getTeamTypeIcon(event.teamType?.name ?? '', false)} + + {name} + + + + + {event.allDay ? ( + + All Day + + ) : ( + + {formatTime(event.startTime)} - {formatTime(event.endTime)} + + )} + + + + + + + {event.location ?? 'N/A'} + + + + + + {event.requiredMembers[0] + ? `${event.requiredMembers[0].firstName} ${event.requiredMembers[0].lastName}...` + : 'N/A '} + + + + + ); +}; + +export default EventPartialInfoView; diff --git a/src/frontend/src/pages/CalendarPage/EventsTable.tsx b/src/frontend/src/pages/CalendarPage/EventsTable.tsx new file mode 100644 index 0000000000..99167bc393 --- /dev/null +++ b/src/frontend/src/pages/CalendarPage/EventsTable.tsx @@ -0,0 +1,427 @@ +/* + * 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 { + Box, + IconButton, + Link, + Table, + TableBody, + TableCell, + TableContainer, + TableHead, + TableRow, + Tooltip, + Typography +} from '@mui/material'; +import PageTitle from '../../layouts/PageTitle/PageTitle'; +import TableCellHuge from './YourEventsComponents/TableCellHuge'; +import React, { useEffect, useState } from 'react'; +import { Calendar, ConflictStatus, EventType } from 'shared'; +import { Event } from 'shared'; +import WarningTooltip from './YourEventsComponents/WarningTooltip'; +import { getMeetingDates } from '../../utils/calendar.utils'; +import { EventClickPopup } from './EventClickPopup'; +import { useDeleteEvent } from '../../hooks/calendar.hooks'; +import EditEventModal from './Components/EditEventModal'; +import { useToast } from '../../hooks/toasts.hooks'; +import EditIcon from '@mui/icons-material/Edit'; +import DeleteIcon from '@mui/icons-material/Delete'; +import NERDeleteModal from '../../components/NERDeleteModal'; + +interface YourEventsHeadCells { + id: string; + label: string; +} + +const getNextMeetingTime = (event: Event) => { + const times: Date[] = getMeetingDates(event); + + times.sort((a, b) => a.getUTCSeconds() - b.getUTCSeconds()); + let result = times[times.length - 1]; + times.forEach((date) => { + const now = new Date(); + const diffMs = date.getTime() - now.getTime(); + if (diffMs > 0 && date.getTime() < result.getTime()) { + result = date; + } + }); + + return result; +}; + +export interface EventTableArgs { + yourEvents: Event[]; + reviewEvents: Event[]; + allEventTypes: EventType[]; + allCalendars: Calendar[]; + tab: number; +} + +// trigger re-renders specifically for the timer +const CountdownElement = ({ targetDate }: { targetDate: Date }) => { + const [now, setNow] = useState(new Date()); + + useEffect(() => { + const timer = setInterval(() => { + setNow(new Date()); + }, 1000); + return () => clearInterval(timer); + }, []); + + const diffMs = targetDate.getTime() - now.getTime(); + + const seconds = Math.floor(diffMs / 1000); + const minutes = Math.floor(seconds / 60); + const hours = Math.floor(minutes / 60); + const days = Math.floor(hours / 24); + + const timeAway = { + passed: diffMs <= 0, + days, + hours: hours % 24, + minutes: minutes % 60, + seconds: seconds % 60 + }; + + if (timeAway.passed) { + return <>- Passed; + } + + return ( + <> + - In {timeAway.days}d {timeAway.hours}h {timeAway.minutes}m {timeAway.seconds}s + + ); +}; + +const EventsTable: React.FC = ({ tab, yourEvents, reviewEvents, allEventTypes, allCalendars }) => { + // Convert to include proper dates + // Done this way to allow the old events transformer to function properly + // but provide better utility to this file (without breaking other files that may rely on eventTransformer) + + const toast = useToast(); + + const [clickedEvent, setClickedEvent] = useState(); + const [anchorPosition, setAnchorPosition] = useState<{ top: number; left: number }>(); + + const [clickedEditEvent, setClickedEditEvent] = useState(); + const [showEditModal, setShowEditModal] = useState(false); + + const [eventToDelete, setEventToDelete] = useState(undefined); + + const handleOpenClickPopup = (event: Event) => { + setClickedEvent(event); + if (typeof window !== 'undefined') { + setAnchorPosition({ + top: window.innerHeight / 2, + left: window.innerWidth / 2 + }); + } else { + setAnchorPosition({ top: 0, left: 0 }); + } + }; + + const handleCloseClickPopup = () => { + setClickedEvent(undefined); + setAnchorPosition(undefined); + }; + + const handleEdit = (event: Event) => { + setClickedEditEvent(event); + setShowEditModal(true); + }; + + const handleCloseEdit = () => { + setClickedEditEvent(undefined); + setShowEditModal(false); + }; + + const { mutateAsync: deleteEvent } = useDeleteEvent(eventToDelete?.eventId ?? ''); + + const handleEventDelete = async () => { + if (!eventToDelete) return; + setEventToDelete(undefined); + try { + await deleteEvent(); + toast.success('Event deleted successfully'); + } catch (e: unknown) { + if (e instanceof Error) { + toast.error(e.message, 3000); + } else { + toast.error('Failed to delete event', 3000); + } + } + }; + + const headCells: readonly YourEventsHeadCells[] = [ + { + id: 'eventName', + label: 'Event Name' + }, + { + id: 'date', + label: 'Date' + }, + { + id: 'time', + label: 'Time' + }, + { + id: 'location', + label: 'Location' + }, + ...(tab === 2 + ? [ + { + id: 'attendees', + label: 'Attendees' + } + ] + : []), + ...(tab === 1 + ? [ + { + id: 'approvalBy', + label: 'Approval By' + } + ] + : []), + ...(tab === 2 + ? [ + { + id: 'seekingApproval', + label: 'Seeking Approval' + } + ] + : []), + ...(tab === 1 + ? [ + { + id: 'approvalStatus', + label: 'Approval Status' + } + ] + : []), + ...(tab === 2 + ? [ + { + id: 'approveEvent', + label: 'Approve?' + } + ] + : []) + ]; + + const events = tab === 1 ? yourEvents : reviewEvents; + + return ( + + + + + + + {headCells.map((headCell) => ( + + ))} + {tab === 1 && } + + + + {events?.map((event) => { + const earliestSchedule = new Date(getNextMeetingTime(event)); + + const attendeeNumber = + event.requiredMembers.length + event.optionalMembers.length - event.deniedMembers.length + 1; + + return ( + + {event.title} + + {new Date(earliestSchedule).toLocaleDateString()} {} + + + {new Date(earliestSchedule).toLocaleTimeString('en-US', { + hour: 'numeric', + minute: '2-digit' + })} + + + {event.location ? ( + event.location.includes('https://') ? ( + + {event.location} + + ) : ( + event.location + ) + ) : ( + 'N/A' + )} + + {tab === 2 && {attendeeNumber}} + {tab === 1 && ( + + {event.approvalRequiredFrom + ? `${event.approvalRequiredFrom.firstName} ${event.approvalRequiredFrom.lastName}` + : 'N/A'} + + )} + {tab === 2 && ( + {`${event.userCreated.firstName} ${event.userCreated.lastName}`} + )} + {tab === 1 && ( + + + + {event.approved === ConflictStatus.APPROVED + ? 'Approved' + : event.approved === ConflictStatus.PENDING + ? 'Pending' + : event.approved === ConflictStatus.DENIED + ? 'Denied' + : 'N/A'} + + {event.approved === ConflictStatus.DENIED && ( + + handleEdit(event)} + /> + + )} + + + )} + {tab === 2 && ( + + + {event.approved === ConflictStatus.PENDING + ? '...' + : event.approved === ConflictStatus.APPROVED + ? 'Yes' + : 'No'} + {event.approved === ConflictStatus.PENDING && ( + + { + handleOpenClickPopup(event); + }} + /> + + )} + + + )} + {tab === 1 && ( + + + + + { + handleEdit(event); + }} + > + + + + + + + setEventToDelete(event)} + > + + + + + + + )} + + ); + })} + + + + +
    +
    + 1, + totalScheduledSlots: clickedEvent.scheduledTimes.length + } + : undefined + } + anchorPosition={anchorPosition} + onClose={handleCloseClickPopup} + eventTypes={allEventTypes} + calendars={allCalendars} + /> + {clickedEditEvent && showEditModal && ( + 1, + totalScheduledSlots: clickedEditEvent.scheduledTimes.length + }} + eventTypes={allEventTypes} + /> + )} + setEventToDelete(undefined)} + formId="delete-event-form" + dataType={eventToDelete?.title || ''} + onFormSubmit={handleEventDelete} + /> +
    + ); +}; + +export default EventsTable; diff --git a/src/frontend/src/pages/CalendarPage/FilterModal.tsx b/src/frontend/src/pages/CalendarPage/FilterModal.tsx new file mode 100644 index 0000000000..f3334ad262 --- /dev/null +++ b/src/frontend/src/pages/CalendarPage/FilterModal.tsx @@ -0,0 +1,346 @@ +import React, { useState } from 'react'; +import { + Autocomplete, + Box, + Button, + Checkbox, + FormControlLabel, + Switch, + TextField, + Typography, + useTheme +} from '@mui/material'; +import NERModal from '../../components/NERModal'; +import PeopleIcon from '@mui/icons-material/People'; +import { useAllUsers, useCurrentUser } from '../../hooks/users.hooks'; +import { useAllTeams } from '../../hooks/teams.hooks'; +import ErrorPage from '../ErrorPage'; + +export interface FilterArgs { + memberIds: string[]; + teamIds: string[]; + showInvited: boolean; + showTeam: boolean; + allEventsMode: boolean; +} + +export interface BaseFilterModalProps { + open: boolean; + onClose: () => void; + filterValues?: FilterArgs; + setMemberIds: (ids: string[]) => void; + setTeamIds: (ids: string[]) => void; + setShowInvited: (changed: boolean) => void; + setShowTeam: (changed: boolean) => void; + setAllEventsMode: (enabled: boolean) => void; +} + +const FilterModal: React.FC = ({ + open, + onClose, + filterValues, + setMemberIds, + setTeamIds, + setShowInvited, + setShowTeam, + setAllEventsMode +}) => { + const [dropDownMembersOpen, setDropDownMembersOpen] = useState(false); + const [dropDownTeamOpen, setDropDownTeamOpen] = useState(false); + const currUser = useCurrentUser(); + const theme = useTheme(); + const allEventsMode = filterValues?.allEventsMode ?? false; + + const MemberDropdown = () => { + const memberIds = filterValues?.memberIds ?? []; + const { data: allUsers } = useAllUsers(); + + return ( + + {!dropDownMembersOpen && + memberIds.map((id) => { + const user = allUsers?.find((user) => user.userId === id); + return ( + + + {user?.firstName ?? 'John'} {user?.lastName ?? 'Doe'} {user?.userId === currUser.userId && '(You)'} + + { + setMemberIds(memberIds.filter((mid) => mid !== id)); + }} + style={{ + cursor: 'pointer', + fontSize: '16px', + lineHeight: 1 + }} + > + × + + + ); + })} + {!dropDownMembersOpen && ( + + )} + + {dropDownMembersOpen && ( + { + if (reason !== 'toggleInput') { + setDropDownMembersOpen(false); + } + }} + options={allUsers ?? []} + getOptionLabel={(option) => `${option.firstName} ${option.lastName}`} + value={allUsers?.filter((user) => memberIds.includes(user.userId)) ?? []} + onChange={(_, newValue) => setMemberIds(newValue.map((user) => user.userId))} + filterSelectedOptions + renderInput={(params) => ( + + )} + sx={{ + width: '100%', + '& .MuiAutocomplete-option': { + bgcolor: '#666', + color: 'white', + '&:hover': { + bgcolor: '#777' + } + } + }} + /> + )} + + ); + }; + + const TeamDropdown = () => { + const teamIds = filterValues?.teamIds ?? []; + const { data: allTeams, isError: allTeamsIsError, error: allTeamsError } = useAllTeams(); + + if (allTeamsIsError) ; + + const teamList = + allTeams + ?.filter((team) => { + return ( + team.head.userId === currUser.userId || + team.leads.map((user) => user.userId).includes(currUser.userId) || + team.members.map((user) => user.userId).includes(currUser.userId) + ); + }) + .map((team) => team.teamId) ?? []; + + return ( + + {!dropDownTeamOpen && + teamIds.map((id) => { + const team = allTeams?.find((team) => team?.teamId === id); + return ( + + {team?.teamName ?? 'Default Team'} {teamList.includes(id) && '(You)'} + { + setTeamIds(teamIds.filter((mid) => mid !== id)); + if (teamList.includes(id)) { + setShowTeam(false); + } + }} + style={{ + cursor: 'pointer', + fontSize: '16px', + lineHeight: 1 + }} + > + × + + + ); + })} + {!dropDownTeamOpen && ( + + )} + + {dropDownTeamOpen && ( + { + if (reason !== 'toggleInput') { + setDropDownTeamOpen(false); + } + }} + options={allTeams ?? []} + getOptionLabel={(team) => `${team.teamName}`} + value={allTeams?.filter((team) => teamIds.includes(team.teamId)) ?? []} + onChange={(_, newValue) => setTeamIds(newValue.map((team) => team.teamId))} + filterSelectedOptions + renderInput={(params) => ( + + )} + sx={{ + width: '100%', + '& .MuiAutocomplete-option': { + bgcolor: '#666', + color: 'white', + '&:hover': { + bgcolor: '#777' + } + } + }} + /> + )} + + ); + }; + + return ( + { + onClose(); + }} + title={'Filter Events'} + formId="shop-form" + showCloseButton + > + + setAllEventsMode(e.target.checked)} + sx={{ + '& .MuiSwitch-switchBase.Mui-checked': { + color: theme.palette.primary.main + }, + '& .MuiSwitch-switchBase.Mui-checked + .MuiSwitch-track': { + backgroundColor: theme.palette.primary.main + } + }} + /> + } + label={Show All Events} + /> + + + + + Attendees + + + + { + setShowInvited(e.target.checked); + }} + sx={{ color: 'white', '&.Mui-checked': { color: 'white' } }} + /> + + Show Events I Am Invited To + + + + + Team / Subteam + + + + { + setShowTeam(e.target.checked); + }} + sx={{ color: 'white', '&.Mui-checked': { color: 'white' } }} + /> + + Show Events For Teams I Am On + + + + + ); +}; + +export default FilterModal; diff --git a/src/frontend/src/pages/CalendarPage/NewCalendar.tsx b/src/frontend/src/pages/CalendarPage/NewCalendar.tsx new file mode 100644 index 0000000000..4b36809ab6 --- /dev/null +++ b/src/frontend/src/pages/CalendarPage/NewCalendar.tsx @@ -0,0 +1,22 @@ +/* + * 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 { Route, Switch } from 'react-router-dom'; +import { routes } from '../../utils/routes'; +import CalendarTab from './CalendarTab'; +import { EventAvailabilityPage } from './Components/EventAvailabilityPage'; + +const NewCalendar: React.FC = () => { + return ( + + + + + + + + ); +}; + +export default NewCalendar; diff --git a/src/frontend/src/pages/CalendarPage/NewCalendarPage.tsx b/src/frontend/src/pages/CalendarPage/NewCalendarPage.tsx new file mode 100644 index 0000000000..8b97d74832 --- /dev/null +++ b/src/frontend/src/pages/CalendarPage/NewCalendarPage.tsx @@ -0,0 +1,784 @@ +/* + * 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 { useEffect, useMemo, useState } from 'react'; +import { + Box, + Grid, + Stack, + Typography, + useMediaQuery, + useTheme, + Button, + Alert, + Checkbox, + FormControlLabel, + FormGroup +} from '@mui/material'; +import PageLayout from '../../components/PageLayout'; +import { Calendar, ConflictStatus, DayOfWeek, EventType, Event } from 'shared'; +import CalendarDayCard from './CalendarDayCard'; +import { DAY_NAMES, enumToArray, calendarPaddingDays, daysInMonth } from '../../utils/design-review.utils'; +import { useConflictingEvents, useFilterEvents } from '../../hooks/calendar.hooks'; +import ErrorPage from '../ErrorPage'; +import { datePipe } from '../../utils/pipes'; +import LoadingIndicator from '../../components/LoadingIndicator'; +import { useAllTeamTypes } from '../../hooks/team-types.hooks'; +import FilterModal from './FilterModal'; +import { DateCalendar } from '@mui/x-date-pickers'; +import { useCurrentUser } from '../../hooks/users.hooks'; +import { useGetUsersTeams } from '../../hooks/teams.hooks'; +import { convertIntToDay, getOverlapTime } from '../../utils/calendar.utils'; +import { filterEventTransformer } from '../../apis/transformers/calendar.transformer'; +import WarningIcon from '@mui/icons-material/Warning'; +import { useHistory } from 'react-router-dom'; +import UpcomingMeetingsCard from './UpcomingMeetingsCard'; +import CheckCircleOutlineIcon from '@mui/icons-material/CheckCircleOutline'; +import RadioButtonUncheckedIcon from '@mui/icons-material/RadioButtonUnchecked'; +import SchedulingConflictsWarning from './SchedulingConflictsWarning'; +import { EventInstance } from 'shared'; + +// localStorage key for calendar filters +const CALENDAR_FILTERS_KEY = 'calendar-filters'; + +// Interface for stored filter settings +interface CalendarFilterSettings { + allEventsMode: boolean; + memberIds: string[]; + teamIds: string[]; + showInvitedEvents: boolean; + showTeamEvents: boolean; + selectedCalendarIds: string[]; +} + +// Load filter settings from localStorage +const loadFilterSettings = (): Partial => { + try { + const stored = localStorage.getItem(CALENDAR_FILTERS_KEY); + if (stored) { + return JSON.parse(stored); + } + } catch { + // If parsing fails, return empty object + } + return {}; +}; + +// Save filter settings to localStorage +const saveFilterSettings = (settings: CalendarFilterSettings): void => { + localStorage.setItem(CALENDAR_FILTERS_KEY, JSON.stringify(settings)); +}; + +interface NewCalendarPageProps { + allEventTypes: EventType[]; + yourEvents: EventInstance[]; + reviewEvents: Event[]; + allCalendars: Calendar[]; + onCreateEventClick: (date: Date) => void; +} + +const NewCalendarPage: React.FC = ({ + allEventTypes, + yourEvents, + reviewEvents, + allCalendars, + onCreateEventClick +}) => { + const theme = useTheme(); + const history = useHistory(); + const { + data: allTeamTypes, + isLoading: allTeamTypesLoading, + isError: allTeamTypesIsError, + error: allTeamTypesError + } = useAllTeamTypes(); + + const user = useCurrentUser(); + + // Load initial filter settings from localStorage + const savedFilters = useMemo(() => loadFilterSettings(), []); + + const [memberIds, setMemberIds] = useState(savedFilters.memberIds ?? []); + const [teamIds, setTeamIds] = useState(savedFilters.teamIds ?? []); + const [displayMonthYear, setDisplayMonthYear] = useState(new Date()); + const [showInvitedEvents, setShowInvitedEvents] = useState(savedFilters.showInvitedEvents ?? true); + const [showTeamEvents, setShowTeamEvents] = useState(savedFilters.showTeamEvents ?? true); + const [openFilterModal, setOpenFilterModal] = useState(false); + const [additionalMemberIds, setAdditionalMemberIds] = useState([user.userId]); + const [additionalTeamIds, setAdditionalTeamIds] = useState([]); + const [allEventsMode, setAllEventsMode] = useState(savedFilters.allEventsMode ?? false); + const isLargerView = useMediaQuery(theme.breakpoints.up('md')); + const isExtraSmallView = useMediaQuery(theme.breakpoints.down('sm')); + + const calendars = allCalendars ?? []; + + const [selectedCalendarIds, setSelectedCalendarIds] = useState(() => { + // Use saved calendar IDs if they exist and are valid, otherwise default to all calendars + if (savedFilters.selectedCalendarIds && savedFilters.selectedCalendarIds.length > 0) { + // Filter to only include IDs that still exist in allCalendars + const validIds = savedFilters.selectedCalendarIds.filter((id) => allCalendars.some((c) => c.calendarId === id)); + return validIds.length > 0 ? validIds : allCalendars.map((c) => c.calendarId); + } + return allCalendars.map((c) => c.calendarId); + }); + + const { data: allTeams, isLoading: allTeamsLoading, isError: allTeamsIsError, error: allTeamsError } = useGetUsersTeams(); + + const teamList = useMemo(() => allTeams?.map((team) => team.teamId) ?? [], [allTeams]); + + // Save filter settings to localStorage when they change + useEffect(() => { + saveFilterSettings({ + allEventsMode, + memberIds, + teamIds, + showInvitedEvents, + showTeamEvents, + selectedCalendarIds + }); + }, [allEventsMode, memberIds, teamIds, showInvitedEvents, showTeamEvents, selectedCalendarIds]); + + useEffect(() => { + if (allTeams && additionalTeamIds.length === 0 && showTeamEvents && !allEventsMode) { + setAdditionalTeamIds(teamList); + } + }, [allTeams, teamList, additionalTeamIds.length, showTeamEvents, allEventsMode]); + + const startPeriod = new Date(displayMonthYear.getFullYear(), displayMonthYear.getMonth() - 1, 15); + const endPeriod = new Date(displayMonthYear.getFullYear(), displayMonthYear.getMonth() + 1, 15); + + // When allEventsMode is true, we don't filter by members/teams, but still filter by date and calendars + const filterArgs = allEventsMode + ? { + startPeriod, + endPeriod, + statuses: [ConflictStatus.APPROVED, ConflictStatus.NO_CONFLICT], + calendarIds: selectedCalendarIds + } + : { + startPeriod, + endPeriod, + memberIds: [...new Set(memberIds.concat(additionalMemberIds))], + teamIds: [...new Set(teamIds.concat(additionalTeamIds))], + statuses: [ConflictStatus.APPROVED, ConflictStatus.NO_CONFLICT], + calendarIds: selectedCalendarIds + }; + + const { isLoading, isError, error, data: allEvents } = useFilterEvents(filterArgs); + + const [pendingEvent, setPendingEvent] = useState( + yourEvents.filter((event) => event.approved === ConflictStatus.PENDING).length > 0 + ); + + const [deniedEvent, setDeniedEvent] = useState( + yourEvents.filter((event) => event.approved === ConflictStatus.DENIED).length > 0 + ); + + const [reviewEvent, setReviewEvent] = useState( + reviewEvents.filter((event) => event.approvalRequiredFrom?.userId === user.userId).length > 0 + ); + + const filteredToPending = yourEvents + .filter((event) => event.approved === ConflictStatus.PENDING) + .map((event) => event.eventId); + + const filteredToDenied = yourEvents + .filter((event) => event.approved === ConflictStatus.DENIED) + .map((event) => event.eventId); + + const { + data: conflictingEvents, + isLoading: conflictingEventsLoading, + isError: conflictingEventsIsError, + error: conflictingEventsError + } = useConflictingEvents(filteredToPending); + + const { + data: conflictingDeniedEvents, + isLoading: conflictingDeniedEventsLoading, + isError: conflictingDeniedEventsIsError, + error: conflictingDeniedEventsError + } = useConflictingEvents(filteredToDenied); + + const yourReviewEvents = reviewEvents.filter((event) => event.approvalRequiredFrom?.userId === user.userId); + const { + data: untransformedConflictingReviewEvents, + isLoading: conflictingReviewEventsLoading, + isError: conflictingReviewEventsIsError, + error: conflictingReviewEventsError + } = useConflictingEvents(yourReviewEvents.map((event) => event.eventId)); + + const conflictingReviewEvents = untransformedConflictingReviewEvents?.map(filterEventTransformer); + + const [upcomingStartPeriod] = useState(() => new Date()); + + const [upcomingEndPeriod] = useState(() => { + const d = new Date(); + d.setDate(d.getDate() + 7); + d.setHours(23, 59, 59, 999); + return d; + }); + + const { data: upcomingEvents } = useFilterEvents({ + startPeriod: upcomingStartPeriod, + endPeriod: upcomingEndPeriod, + memberIds: [...new Set(memberIds.concat(additionalMemberIds))], + teamIds: [...new Set(teamIds.concat(additionalTeamIds))] + }); + + const upcomingOccurences = upcomingEvents + ? upcomingEvents.flatMap((event) => + event.scheduledTimes.map((slot) => ({ + ...event, + ...slot, + recurring: event.scheduledTimes.length > 1, + totalScheduledSlots: event.scheduledTimes.length + })) + ) + : []; + + const toggleCalendar = (calendarId: string) => { + setSelectedCalendarIds((prev) => + prev.includes(calendarId) ? prev.filter((id) => id !== calendarId) : [...prev, calendarId] + ); + }; + + const updateAdditionalTeamIds = (changed: boolean) => { + setShowTeamEvents(changed); + + if (changed) { + setAdditionalTeamIds(teamList); + } else { + setAdditionalTeamIds([]); + } + }; + + const updateAdditionalMemberIds = (changed: boolean) => { + setShowInvitedEvents(changed); + + if (changed) { + setAdditionalMemberIds([user.userId]); + } else { + setAdditionalMemberIds([]); + } + }; + + const yourConflicts = useMemo( + () => conflictingEvents?.filter((event, i) => filteredToPending[i] !== event.eventId) ?? [], + [conflictingEvents, filteredToPending] + ); + + const yourConflictsDenied = useMemo( + () => conflictingDeniedEvents?.filter((event, i) => filteredToDenied[i] !== event.eventId) ?? [], + [conflictingDeniedEvents, filteredToDenied] + ); + + if ( + isLoading || + !allEvents || + conflictingEventsLoading || + !conflictingEvents || + conflictingEventsLoading || + !conflictingEvents || + conflictingDeniedEventsLoading || + !conflictingDeniedEvents || + conflictingReviewEventsLoading || + !conflictingReviewEvents + ) + return ; + + if (isError) return ; + if (conflictingEventsIsError) return ; + if (conflictingDeniedEventsIsError) return ; + if (conflictingReviewEventsIsError) return ; + + const transformedEvents = allEvents.map(filterEventTransformer); + + // Sort events by their first occurrence's start time + const sortedEvents = [...transformedEvents].sort((event1, event2) => { + const time1 = event1.scheduledTimes[0]?.startTime ? new Date(event1.scheduledTimes[0].startTime).getTime() : 0; + const time2 = event2.scheduledTimes[0]?.startTime ? new Date(event2.scheduledTimes[0].startTime).getTime() : 0; + return time1 - time2; + }); + + const eventDict = new Map(); + const dayDict = new Map(); + const eventInstances: EventInstance[] = sortedEvents.flatMap((event) => + event.scheduledTimes.map((slot) => ({ + ...event, + ...slot, + recurring: event.scheduledTimes.length > 1, + totalScheduledSlots: event.scheduledTimes.length + })) + ); + + eventInstances.forEach((event) => { + const eventDate = new Date(event.startTime); + const dateString = datePipe(eventDate); + eventDate.setHours(0, 0, 0, 0); + const day = convertIntToDay(eventDate.getDay()); + dayDict.set(dateString, day); + if (eventDict.has(dateString)) { + const existingEvents = eventDict.get(dateString)!; + existingEvents.push(event); + } else { + eventDict.set(dateString, [event]); + } + }); + + const startOfEachWeek = [0, 7, 14, 21, 28, 35]; + + const isDayInDifferentMonth = (day: number, week: number) => { + return day < week - 7 || day < 1 || day > week + 7; + }; + + const paddingArrayStart = [...Array(calendarPaddingDays(displayMonthYear)).keys()] + .map((day) => daysInMonth(new Date(displayMonthYear.getFullYear(), displayMonthYear.getMonth() - 1, 1)) - day) + .reverse(); + const paddingArrayEnd = [ + ...Array(7 - ((daysInMonth(displayMonthYear) + calendarPaddingDays(displayMonthYear)) % 7)).keys() + ].map((day) => day + 1); + const daysThisMonth = paddingArrayStart + .concat([...Array(daysInMonth(displayMonthYear)).keys()].map((day) => day + 1)) + .concat(paddingArrayEnd.length < 7 ? paddingArrayEnd : []); + + if (!allTeamTypes || allTeamTypesLoading) return ; + if (allTeamTypesIsError) return ; + + if (!allTeams || allTeamsLoading) return ; + if (allTeamsIsError) return ; + + return ( + <> + + {deniedEvent && yourConflictsDenied.length > 0 && ( + } + variant="filled" + severity="error" + onClick={() => setDeniedEvent(false)} + onClose={() => setDeniedEvent(false)} + sx={{ + cursor: 'pointer' + }} + > + + + {' '} + You have scheduled an event at the same time and location as {yourConflictsDenied[0].title} and was + denied. Edit the event to put it up for re-approval, or change the time/location to not conflict with other + events. + + + + + )} + {pendingEvent && yourConflicts.length > 0 && ( + } + variant="filled" + severity="error" + onClick={() => setPendingEvent(false)} + onClose={() => setPendingEvent(false)} + sx={{ + cursor: 'pointer' + }} + > + You have scheduled an event at the same time and location as {yourConflicts[0].title}.{' '} + + {yourConflicts[0].userCreated.firstName} {yourConflicts[0].userCreated.lastName} + {' '} + has been notified of this and must allow your event to take place in order to continue. + + )} + {reviewEvent && yourReviewEvents.length > 0 && ( + } + variant="filled" + severity="error" + onClick={() => setReviewEvent(false)} + onClose={() => setReviewEvent(false)} + sx={{ + cursor: 'pointer' + }} + > + + + {' '} + + {yourReviewEvents[0].userCreated.firstName} {yourReviewEvents[0].userCreated.lastName} + {' '} + has scheduled an event at the same time and location as your meeting at{' '} + {(() => { + const overlaps = getOverlapTime(conflictingReviewEvents[0], yourReviewEvents[0]); + const eventTime = overlaps[0].event1Time.start.toLocaleTimeString('en-US', { + hour: 'numeric', + minute: '2-digit' + }); + return eventTime; + })()}{' '} + in {conflictingReviewEvents[0].location}. + + + + + )} + + + + + + {enumToArray(DAY_NAMES).map((day, index) => ( + + + { + // Day of the week display based on current breakpoint + isLargerView ? day : isExtraSmallView ? day.charAt(0) : day.substring(0, 3) + } + + + ))} + + + {startOfEachWeek + .filter((week) => daysThisMonth.slice(week, week + 7).length > 0) + .map((week, weekIndex) => ( + + {daysThisMonth.slice(week, week + 7).map((day, dayIndex) => { + const cardDate = new Date( + displayMonthYear.getFullYear(), + displayMonthYear.getMonth() + (isDayInDifferentMonth(day, week) ? (day > 15 ? -1 : 1) : 0), + day + ); + return ( + + + + ); + })} + + ))} + + + + + setDisplayMonthYear(newDate)} + onChange={(newDate) => { + if (newDate) setDisplayMonthYear(newDate); + }} + slotProps={{ + day: { + sx: { + '&.Mui-selected': { + bgcolor: 'red', + '&:hover': { + bgcolor: 'darkred' + }, + '&:focus': { + bgcolor: 'red' + } + } + } + } + }} + /> + + + + + + {/* Upcoming Meetings Section */} + + + My Upcoming Meetings: + + + {upcomingOccurences && ( + + {upcomingOccurences?.map((event) => ( + + ))} + + )} + + + {/* Calendar Selector Section */} + + + t.typography.h4.fontFamily, + fontWeight: 400, + fontSize: 22 + }} + > + Calendars: + + + + + + {calendars.length > 0 && ( + + + {calendars.map((cal) => { + const { calendarId, color } = cal; + const checked = selectedCalendarIds.includes(calendarId); + return ( + toggleCalendar(calendarId)} + icon={} + checkedIcon={} + sx={{ + p: 0.5, + color, + '&.Mui-checked': { color } + }} + /> + } + label={ + t.typography.h6.fontFamily, + fontSize: 16, + color, + fontWeight: 500 + }} + > + {cal.name} + + } + /> + ); + })} + + + )} + + + + + setOpenFilterModal(false)} + filterValues={{ memberIds, teamIds, showInvited: showInvitedEvents, showTeam: showTeamEvents, allEventsMode }} + setMemberIds={(ids: string[]) => setMemberIds(ids)} + setTeamIds={(ids: string[]) => setTeamIds(ids)} + setShowInvited={(changed: boolean) => updateAdditionalMemberIds(changed)} + setShowTeam={(changed: boolean) => updateAdditionalTeamIds(changed)} + setAllEventsMode={(enabled: boolean) => setAllEventsMode(enabled)} + /> + + + ); +}; + +export default NewCalendarPage; diff --git a/src/frontend/src/pages/CalendarPage/SchedulingConflictsWarning.tsx b/src/frontend/src/pages/CalendarPage/SchedulingConflictsWarning.tsx new file mode 100644 index 0000000000..f57212b3b5 --- /dev/null +++ b/src/frontend/src/pages/CalendarPage/SchedulingConflictsWarning.tsx @@ -0,0 +1,89 @@ +import { Box, Stack, Typography } from '@mui/material'; +import WarningIcon from '@mui/icons-material/Warning'; +import { useFilterEvents } from '../../hooks/calendar.hooks'; +import { useHistory } from 'react-router-dom'; +import { ConflictStatus } from 'shared'; +import LoadingIndicator from '../../components/LoadingIndicator'; +import ErrorPage from '../ErrorPage'; + +interface SchedulingConflictsWarningProps { + memberIds: string[]; + teamIds: string[]; + startPeriod: Date; + endPeriod: Date; +} + +//Component for main new calendar page for scheduling conflicts +const SchedulingConflictsWarning: React.FC = ({ + memberIds, + teamIds, + startPeriod, + endPeriod +}) => { + const history = useHistory(); + + // Filter for events with pending conflicts using the same filters as the calendar + const { + data: conflicts, + isLoading, + isError, + error + } = useFilterEvents({ + statuses: [ConflictStatus.PENDING], + startPeriod, + endPeriod, + memberIds, + teamIds + }); + + if (isLoading || !conflicts) { + return ; + } + + // There are conflicts, but error fetching them + if (isError) { + return ; + } + + // If no conflicts, don't show the warning + if (conflicts.length === 0) { + return null; + } + + return ( + + + Scheduling Conflicts: + + history.push('/calendar/reviews')} + sx={{ + bgcolor: 'transparent', + border: '2px solid #FF4444', + borderRadius: 1.5, + padding: 2, + cursor: 'pointer', + transition: 'all 0.2s', + '&:hover': { + borderColor: '#FF6666', + transform: 'translateY(-1px)' + } + }} + > + + + + + Requires Action + + + Click to Resolve + + + + + + ); +}; + +export default SchedulingConflictsWarning; diff --git a/src/frontend/src/pages/CalendarPage/SummaryComponents/DesignReviewDelayModal.tsx b/src/frontend/src/pages/CalendarPage/SummaryComponents/DesignReviewDelayModal.tsx deleted file mode 100644 index 207625567d..0000000000 --- a/src/frontend/src/pages/CalendarPage/SummaryComponents/DesignReviewDelayModal.tsx +++ /dev/null @@ -1,48 +0,0 @@ -import { TextField, Link, FormLabel, FormControl } from '@mui/material'; -import { useState, ChangeEvent } from 'react'; -import { DesignReview, wbsPipe } from 'shared'; -import { Link as RouterLink } from 'react-router-dom'; -import NERModal from '../../../components/NERModal'; -import NERSuccessButton from '../../../components/NERSuccessButton'; -import { useToast } from '../../../hooks/toasts.hooks'; -import { routes } from '../../../utils/routes'; - -export const DesignReviewDelayModal: React.FC<{ open: boolean; onHide: () => void; designReview: DesignReview }> = ({ - open, - onHide, - designReview -}) => { - const toast = useToast(); - const [weeks, setWeeks] = useState(1); - const onChange = (e: ChangeEvent) => { - if (e.target.value === '' || parseInt(e.target.value) >= 1) { - setWeeks(parseInt(e.target.value)); - } else { - toast.error('If delaying, it must be by at least 1 week'); - } - }; - - return ( - - - Enter number of weeks - - - - = 1 ? weeks : 1}`} - > - Delay - - - - ); -}; diff --git a/src/frontend/src/pages/CalendarPage/SummaryComponents/DesignReviewPill.tsx b/src/frontend/src/pages/CalendarPage/SummaryComponents/DesignReviewPill.tsx deleted file mode 100644 index 21b7eb09e9..0000000000 --- a/src/frontend/src/pages/CalendarPage/SummaryComponents/DesignReviewPill.tsx +++ /dev/null @@ -1,23 +0,0 @@ -import { Typography, Box } from '@mui/material'; - -export const DesignReviewPill: React.FC<{ - icon: React.ReactNode; - displayText: string; -}> = ({ icon, displayText }) => { - return ( - - {icon} - - {displayText} - - - ); -}; diff --git a/src/frontend/src/pages/CalendarPage/SummaryComponents/DesignReviewSummaryModalAttendees.tsx b/src/frontend/src/pages/CalendarPage/SummaryComponents/DesignReviewSummaryModalAttendees.tsx deleted file mode 100644 index 3a684d30d9..0000000000 --- a/src/frontend/src/pages/CalendarPage/SummaryComponents/DesignReviewSummaryModalAttendees.tsx +++ /dev/null @@ -1,117 +0,0 @@ -import { Grid, Typography } from '@mui/material'; -import { Box } from '@mui/system'; -import { DesignReview, User } from 'shared'; -import { MemberPill } from '../../../components/MemberPill'; -import { useToast } from '../../../hooks/toasts.hooks'; -import { useCurrentUser } from '../../../hooks/users.hooks'; -import { useEditDesignReview } from '../../../hooks/design-reviews.hooks'; -import LoadingIndicator from '../../../components/LoadingIndicator'; - -interface DesignReviewSummaryModalAttendeesProps { - designReview: DesignReview; -} - -interface DesignReviewEditAttendeesProps { - requiredMembers: User[]; - optionalMembers: User[]; -} - -const DesignReviewSummaryModalAttendees: React.FC = ({ designReview }) => { - const toast = useToast(); - const { requiredMembers } = designReview; - const { optionalMembers } = designReview; - const currentUser = useCurrentUser(); - - const { isLoading: editDesignReviewIsLoading, mutateAsync: editDesignReview } = useEditDesignReview( - designReview.designReviewId - ); - - const handleRemoveRequiredMember = (user: User) => { - if (currentUser.userId === designReview.userCreated.userId) { - const updatedMembers = requiredMembers.filter((member) => member.userId !== user.userId); - saveMembers({ requiredMembers: updatedMembers, optionalMembers }); - } else { - toast.error('Only the creator of the Design Review can edit attendees'); - } - }; - - const handleRemoveOptionalMember = (user: User) => { - if (currentUser.userId === designReview.userCreated.userId) { - const updatedMembers = optionalMembers.filter((member) => member.userId !== user.userId); - saveMembers({ requiredMembers, optionalMembers: updatedMembers }); - } else { - toast.error('Only the creator of the Design Review can edit attendees'); - } - }; - - const saveMembers = async (payload: DesignReviewEditAttendeesProps) => { - try { - await editDesignReview({ - ...designReview, - teamTypeId: designReview.teamType.teamTypeId, - zoomLink: designReview.zoomLink ?? '', - location: designReview.location ?? '', - docTemplateLink: designReview.docTemplateLink ?? '', - attendees: designReview.attendees.map((user) => user.userId), - requiredMembersIds: payload.requiredMembers.map((member) => member.userId), - optionalMembersIds: payload.optionalMembers.map((member) => member.userId) - }); - } catch (e) { - if (e instanceof Error) { - toast.error(e.message); - } - } - }; - - if (editDesignReviewIsLoading) return ; - - return ( - - - - Required: - - - - {requiredMembers.map((member, index) => ( - - { - handleRemoveRequiredMember(member); - } - : undefined - } - /> - - ))} - - - - Optional: - - - {optionalMembers.map((member, index) => ( - - { - handleRemoveOptionalMember(member); - } - : undefined - } - /> - - ))} - - - - - ); -}; - -export default DesignReviewSummaryModalAttendees; diff --git a/src/frontend/src/pages/CalendarPage/SummaryComponents/DesignReviewSummaryModalButtons.tsx b/src/frontend/src/pages/CalendarPage/SummaryComponents/DesignReviewSummaryModalButtons.tsx deleted file mode 100644 index be31bb3b2c..0000000000 --- a/src/frontend/src/pages/CalendarPage/SummaryComponents/DesignReviewSummaryModalButtons.tsx +++ /dev/null @@ -1,77 +0,0 @@ -import { Box } from '@mui/system'; -import { DesignReview, TeamType } from 'shared'; -import { NERButton } from '../../../components/NERButton'; -import NERFailButton from '../../../components/NERFailButton'; -import NERSuccessButton from '../../../components/NERSuccessButton'; -import { DesignReviewCreateModal } from '../DesignReviewCreateModal'; -import { useState } from 'react'; - -interface DesignReviewSummaryModalButtonsProps { - designReview: DesignReview; - handleStageGateClick: () => void; - handleDelayClick: () => void; - teamTypes: TeamType[]; -} - -const DesignReviewSummaryModalButtons: React.FC = ({ - designReview, - handleDelayClick, - handleStageGateClick, - teamTypes -}) => { - const [isCreateModalOpen, setIsCreateModalOpen] = useState(false); - - return ( - - {isCreateModalOpen && ( - { - setIsCreateModalOpen(false); - }} - teamTypes={teamTypes} - defaultDate={new Date()} - defaultWbsNum={designReview.wbsNum} - /> - )} - - - Request Delay - - - Stage Gate - - - setIsCreateModalOpen(true)} - > - Schedule Another DR - - - ); -}; - -export default DesignReviewSummaryModalButtons; diff --git a/src/frontend/src/pages/CalendarPage/SummaryComponents/DesignReviewSummaryModalDetails.tsx b/src/frontend/src/pages/CalendarPage/SummaryComponents/DesignReviewSummaryModalDetails.tsx deleted file mode 100644 index 44e340ab20..0000000000 --- a/src/frontend/src/pages/CalendarPage/SummaryComponents/DesignReviewSummaryModalDetails.tsx +++ /dev/null @@ -1,144 +0,0 @@ -import { Box, Checkbox, FormControlLabel, Link, Typography } from '@mui/material'; -import { DesignReview, DesignReviewStatus, TeamType } from 'shared'; -import AccessTimeIcon from '@mui/icons-material/AccessTime'; -import LocationOnIcon from '@mui/icons-material/LocationOn'; -import DescriptionIcon from '@mui/icons-material/Description'; -import VideocamIcon from '@mui/icons-material/Videocam'; -import { DesignReviewPill } from './DesignReviewPill'; -import { meetingStartTimePipe } from '../../../utils/pipes'; -import { useState } from 'react'; -import StageGateWorkPackageModalContainer from '../../WorkPackageDetailPage/StageGateWorkPackageModalContainer/StageGateWorkPackageModalContainer'; -import { DesignReviewDelayModal } from './DesignReviewDelayModal'; -import DesignReviewSummaryModalButtons from './DesignReviewSummaryModalButtons'; -import NERModal from '../../../components/NERModal'; -import { useSetDesignReviewStatus } from '../../../hooks/design-reviews.hooks'; - -interface DesignReviewSummaryModalDetailsProps { - designReview: DesignReview; - teamTypes: TeamType[]; - markedStatus: DesignReviewStatus; - setMarkedStatus: (_: DesignReviewStatus) => void; -} - -const DesignReviewSummaryModalDetails: React.FC = ({ - designReview, - teamTypes, - markedStatus, - setMarkedStatus -}) => { - const [showStageGateModal, setShowStageGateModal] = useState(false); - const [showDelayModal, setShowDelayModal] = useState(false); - const [showMarkCompleteModal, setShowMarkCompleteModal] = useState(false); - const [showUnmarkCompleteModal, setShowUnmarkCompleteModal] = useState(false); - const { mutateAsync } = useSetDesignReviewStatus(designReview.designReviewId); - - const MarkCompleteModal: React.FC = () => { - return ( - setShowMarkCompleteModal(false)} - cancelText="No" - submitText="Yes" - onSubmit={async () => { - setShowMarkCompleteModal(false); - await mutateAsync({ status: DesignReviewStatus.DONE }); - setMarkedStatus(DesignReviewStatus.DONE); - }} - > - Are you sure you want to mark this design review as complete? - - ); - }; - - const UnmarkCompleteModal: React.FC = () => { - return ( - setShowUnmarkCompleteModal(false)} - cancelText="No" - submitText="Yes" - onSubmit={async () => { - setShowUnmarkCompleteModal(false); - await mutateAsync({ status: DesignReviewStatus.SCHEDULED }); - setMarkedStatus(DesignReviewStatus.SCHEDULED); - }} - > - - Are you sure you want to mark this design review as not complete? - - - ); - }; - - return ( - <> - - setShowStageGateModal(false)} - hideStatus - /> - setShowDelayModal(false)} designReview={designReview} /> - - } displayText={meetingStartTimePipe(designReview.meetingTimes)} /> - } - displayText={designReview.location ? designReview.location : 'Online'} - /> - - - - - - - - {designReview.docTemplateLink ? 'Question Document' : 'No Question Document'} - - - - - { - if (markedStatus === DesignReviewStatus.DONE) setShowUnmarkCompleteModal(true); - else setShowMarkCompleteModal(true); - }} - sx={{ - color: 'inherit', - '&.Mui-checked': { color: 'inherit' } - }} - /> - } - /> - - - - - - {designReview.zoomLink ? 'Zoom Link' : 'No Zoom'} - - - {markedStatus === DesignReviewStatus.DONE && ( - setShowStageGateModal(true)} - handleDelayClick={() => setShowDelayModal(true)} - teamTypes={teamTypes} - /> - )} - - - - - - - ); -}; - -export default DesignReviewSummaryModalDetails; diff --git a/src/frontend/src/pages/CalendarPage/UpcomingMeetingsCard.tsx b/src/frontend/src/pages/CalendarPage/UpcomingMeetingsCard.tsx new file mode 100644 index 0000000000..ca4896a6dc --- /dev/null +++ b/src/frontend/src/pages/CalendarPage/UpcomingMeetingsCard.tsx @@ -0,0 +1,79 @@ +import { Card, Box, Typography, Stack } from '@mui/material'; +import BuildOutlinedIcon from '@mui/icons-material/BuildOutlined'; +import RoomOutlinedIcon from '@mui/icons-material/RoomOutlined'; +import GroupsOutlinedIcon from '@mui/icons-material/GroupsOutlined'; +import { Calendar, EventInstance, EventType } from 'shared'; +import { datePipe } from '../../utils/pipes'; +import { formatTime } from '../../utils/datetime.utils'; + +interface UpcomingMeetingProp { + calendars: Calendar[]; + event: EventInstance; + eventTypes?: EventType[]; +} + +const UpcomingMeetingsCard: React.FC = ({ event, calendars = [], eventTypes = [] }) => { + const specificEventType = eventTypes?.find((eventType) => eventType.eventTypeId === event.eventTypeId); + const specificCalendar = calendars?.find((calendar) => + calendar.eventTypes.some((eventType) => eventType.eventTypeId === specificEventType?.eventTypeId) + ); + + // optional and required members are combined and sorted by first name + const members = Array.from( + new Map([...event.requiredMembers, ...event.optionalMembers].map((u) => [u.userId, u])).values() + ).sort((a, b) => a.firstName.localeCompare(b.firstName)); + + return ( + + + + {/* Event Title */} + + + {event.title} + + + {/* Event Time */} + + {datePipe(event.startTime)} + + + + + {/* Event Location */} + + {event.location ? event.location : event.zoomLink ? event.zoomLink : 'N/A'} + + + {/* Event Time */} + + {formatTime(new Date(event.startTime))} + + + + {/* Event Members */} + + + + + {members.length > 0 + ? members.map((u) => `${u.firstName} ${u.lastName}`).join(', ') + : event.teams.length > 0 + ? event.teams.map((t) => t.teamName).join(', ') + : (event.teamType?.name ?? 'N/A')} + + + + + ); +}; + +export default UpcomingMeetingsCard; diff --git a/src/frontend/src/pages/CalendarPage/YourEventsComponents/TableCellHuge.tsx b/src/frontend/src/pages/CalendarPage/YourEventsComponents/TableCellHuge.tsx new file mode 100644 index 0000000000..124d67a26e --- /dev/null +++ b/src/frontend/src/pages/CalendarPage/YourEventsComponents/TableCellHuge.tsx @@ -0,0 +1,35 @@ +import { TableCell, Typography } from '@mui/material'; + +interface TableCellHugeProps { + title: string; +} + +/** + * Build the Table Cell for the header of the table. + * @param title The title of the table cell + **/ +const TableCellHuge: React.FC = ({ title }) => { + return ( + + + {title} + + + ); +}; + +export default TableCellHuge; diff --git a/src/frontend/src/pages/CalendarPage/YourEventsComponents/WarningTooltip.tsx b/src/frontend/src/pages/CalendarPage/YourEventsComponents/WarningTooltip.tsx new file mode 100644 index 0000000000..35f6452dd2 --- /dev/null +++ b/src/frontend/src/pages/CalendarPage/YourEventsComponents/WarningTooltip.tsx @@ -0,0 +1,85 @@ +import { Button, Stack, Tooltip, Typography } from '@mui/material'; +import React from 'react'; +import WarningIcon from '@mui/icons-material/Warning'; +import ErrorOutlineIcon from '@mui/icons-material/ErrorOutline'; + +interface WarningTooltipProps { + warning: string; + buttonText: string; + onClick: () => void; +} + +const WarningTooltip: React.FC = ({ warning, buttonText, onClick }) => { + return ( + + + {warning} + + + } + > + + + ); +}; + +export default WarningTooltip; diff --git a/src/frontend/src/pages/GanttPage/ProjectGanttChart/GanttChartColorLegend.tsx b/src/frontend/src/pages/GanttPage/ProjectGanttChart/GanttChartColorLegend.tsx index 69244fd692..a462fda95a 100644 --- a/src/frontend/src/pages/GanttPage/ProjectGanttChart/GanttChartColorLegend.tsx +++ b/src/frontend/src/pages/GanttPage/ProjectGanttChart/GanttChartColorLegend.tsx @@ -4,15 +4,15 @@ */ import { Box, Card, Tooltip, Typography } from '@mui/material'; -import { DesignReviewStatus, TaskStatus, WbsElementStatus, WorkPackageStage } from 'shared'; +import { EventStatus, TaskStatus, WbsElementStatus, WorkPackageStage } from 'shared'; import { - ganttDesignReviewStatusColorPipe, + ganttDesignReviewEventStatusColorPipe, ganttTaskColorPipe, ganttWorkPackageStageColorPipe, GanttWorkPackageTextColor } from '../../../utils/gantt.utils'; import { - DesignReviewStatusTextPipe, + DesignReviewEventStatusTextPipe, TaskStatusTextPipe, WbsElementStatusTextPipe, WorkPackageStageTextPipe @@ -67,11 +67,11 @@ const DesignReviewToolTipPopUp = () => { py: 1 }} > - {[DesignReviewStatus.UNCONFIRMED, DesignReviewStatus.SCHEDULED].map((status) => { + {[EventStatus.UNCONFIRMED, EventStatus.SCHEDULED].map((status) => { return ( { }} > - {DesignReviewStatusTextPipe(status)} + {DesignReviewEventStatusTextPipe(status)} ); @@ -193,7 +193,7 @@ const GanttChartColorLegend = () => {
    { dateCreated: new Date(), teamTypes: [], changes: [], - designReviews: [], + events: [], deleted: false }; diff --git a/src/frontend/src/pages/HomePage/components/DesignReviewCard.tsx b/src/frontend/src/pages/HomePage/components/EventCard.tsx similarity index 59% rename from src/frontend/src/pages/HomePage/components/DesignReviewCard.tsx rename to src/frontend/src/pages/HomePage/components/EventCard.tsx index a0fbd0d3e4..f2bf7ef690 100644 --- a/src/frontend/src/pages/HomePage/components/DesignReviewCard.tsx +++ b/src/frontend/src/pages/HomePage/components/EventCard.tsx @@ -1,6 +1,6 @@ import { Box, Card, CardContent, Link, Stack, Typography, useTheme } from '@mui/material'; -import { AuthenticatedUser, DesignReview, meetingStartTimePipe } from 'shared'; -import { datePipe, projectWbsPipe } from '../../../utils/pipes'; +import { AuthenticatedUser, Event } from 'shared'; +import { datePipe, meetingStartTimePipeScheduleSlot, projectWbsPipe } from '../../../utils/pipes'; import { routes } from '../../../utils/routes'; import { Link as RouterLink } from 'react-router-dom'; import CalendarMonthIcon from '@mui/icons-material/CalendarMonth'; @@ -9,12 +9,12 @@ import { useHistory } from 'react-router-dom'; import { NERButton } from '../../../components/NERButton'; import { timezoneOffset } from '../../../utils/datetime.utils'; -interface DesignReviewProps { - designReview: DesignReview; +interface EventProps { + event: Event; user: AuthenticatedUser; } -const DesignReviewInfo = ({ icon, text, link }: { icon: React.ReactNode; text: string; link?: boolean }) => { +const EventInfo = ({ icon, text, link }: { icon: React.ReactNode; text: string; link?: boolean }) => { return ( {icon} @@ -33,9 +33,9 @@ const DesignReviewInfo = ({ icon, text, link }: { icon: React.ReactNode; text: s ); }; -const DisplayStatus: React.FC = ({ designReview, user }) => { +const DisplayStatus: React.FC = ({ event, user }) => { const history = useHistory(); - const confirmedMemberIds = designReview.confirmedMembers.map((user) => user.userId); + const confirmedMemberIds = event.confirmedMembers.map((user) => user.userId); return ( <> @@ -45,14 +45,14 @@ const DisplayStatus: React.FC = ({ designReview, user }) => { size="small" sx={{ color: 'white', padding: 1 }} onClick={() => { - history.push(`${routes.SETTINGS_PREFERENCES}?drId=${designReview.designReviewId}`); + history.push(`${routes.SETTINGS_PREFERENCES}?eventId=${event.eventId}`); }} component={RouterLink} > Confirm Availibility ) : ( - {designReview.status} + {event.status} )} ); @@ -67,9 +67,25 @@ const removeYear = (str: string): string => { return str.substring(0, str.length - 5); }; -const UpcomingDesignReviewsCard: React.FC = ({ designReview, user }) => { +const UpcomingEventCard: React.FC = ({ event, user }) => { const theme = useTheme(); - const timezoneAdjustedDate = timezoneOffset(designReview.dateScheduled); + const firstScheduledDate = event.initialDateScheduled || event.scheduledTimes[0]?.startTime; + const timezoneAdjustedDate = firstScheduledDate ? timezoneOffset(firstScheduledDate) : new Date(); + + const [firstWorkPackage] = event.workPackages; + + const wbsNumber = firstWorkPackage + ? { + carNumber: firstWorkPackage.wbsElement.carNumber, + projectNumber: firstWorkPackage.wbsElement.projectNumber, + workPackageNumber: firstWorkPackage.wbsElement.workPackageNumber + } + : { carNumber: 0, projectNumber: 0, workPackageNumber: 0 }; + + const eventName = firstWorkPackage?.wbsElement?.name + ? `${firstWorkPackage.wbsElement.carNumber}.${firstWorkPackage.wbsElement.projectNumber}.${firstWorkPackage.wbsElement.workPackageNumber} - ${firstWorkPackage.wbsElement.name}` + : event.title; + return ( = ({ designReview, - - {designReview.wbsName} + + {eventName} @@ -96,21 +112,17 @@ const UpcomingDesignReviewsCard: React.FC = ({ designReview, ', ' + removeYear(datePipe(timezoneAdjustedDate)) + ' @ ' + - meetingStartTimePipe(designReview.meetingTimes)} + meetingStartTimePipeScheduleSlot(event.scheduledTimes)} - {designReview.isInPerson && !!designReview.location && ( - } text={designReview.location} /> - )} - {designReview.isOnline && !!designReview.zoomLink && ( - } text={designReview.zoomLink} link /> - )} + {event.location && } text={event.location} />} + {event.zoomLink && } text={event.zoomLink} link />} - + ); }; -export default UpcomingDesignReviewsCard; +export default UpcomingEventCard; diff --git a/src/frontend/src/pages/HomePage/components/UpcomingDesignReviews.tsx b/src/frontend/src/pages/HomePage/components/UpcomingDesignReviews.tsx index 7d0d4c0565..ce2cbad04a 100644 --- a/src/frontend/src/pages/HomePage/components/UpcomingDesignReviews.tsx +++ b/src/frontend/src/pages/HomePage/components/UpcomingDesignReviews.tsx @@ -3,20 +3,20 @@ * See the LICENSE file in the repository root folder for details. */ -import DesignReviewCard from './DesignReviewCard'; -import { useAllDesignReviews } from '../../../hooks/design-reviews.hooks'; +import DesignReviewCard from './EventCard'; +import { useAllEvents } from '../../../hooks/calendar.hooks'; import ErrorPage from '../../ErrorPage'; -import { AuthenticatedUser, DesignReviewStatus, wbsPipe } from 'shared'; +import { AuthenticatedUser, EventStatus } from 'shared'; import LoadingIndicator from '../../../components/LoadingIndicator'; import ScrollablePageBlock from './ScrollablePageBlock'; import EmptyPageBlockDisplay from './EmptyPageBlockDisplay'; import { Error } from '@mui/icons-material'; -interface UpcomingDesignReviewProps { +interface UpcomingEventProps { user: AuthenticatedUser; } -const NoUpcomingDesignReviewsDisplay: React.FC = () => { +const NoUpcomingEventsDisplay: React.FC = () => { return ( } @@ -26,37 +26,41 @@ const NoUpcomingDesignReviewsDisplay: React.FC = () => { ); }; -const UpcomingDesignReviews: React.FC = ({ user }) => { - const { data: designReviews, isLoading, isError, error } = useAllDesignReviews(); +const UpcomingEvents: React.FC = ({ user }) => { + const { data: events, isLoading, isError, error } = useAllEvents(); - if (isLoading || !designReviews) return ; + if (isLoading || !events) return ; if (isError) return ; - const filteredDesignReviews = designReviews.filter((review) => { - const scheduledDate = review.dateScheduled; + const filteredEvents = events.filter((event) => { + // Get the first scheduled date + const scheduledDate = event.scheduledTimes[0]?.startTime; + if (!scheduledDate) return false; + const currentDate = new Date(); const inTwoWeeks = new Date(); inTwoWeeks.setDate(currentDate.getDate() + 14); + const memberUserIds = [ - ...review.requiredMembers.map((user) => user.userId), - ...review.optionalMembers.map((user) => user.userId) + ...event.requiredMembers.map((user) => user.userId), + ...event.optionalMembers.map((user) => user.userId) ]; - // added in case the person who created the design review forgets to add their name onto the required members - memberUserIds.concat(review.userCreated.userId); + + memberUserIds.push(event.userCreated.userId); return ( scheduledDate >= currentDate && scheduledDate <= inTwoWeeks && - review.status !== DesignReviewStatus.DONE && + event.status !== EventStatus.DONE && memberUserIds.includes(user.userId) ); }); const fullDisplay = ( - - {filteredDesignReviews.length === 0 ? ( - + + {filteredEvents.length === 0 ? ( + ) : ( - filteredDesignReviews.map((d) => ) + filteredEvents.map((event) => ) )} ); @@ -64,4 +68,4 @@ const UpcomingDesignReviews: React.FC = ({ user }) => return fullDisplay; }; -export default UpcomingDesignReviews; +export default UpcomingEvents; diff --git a/src/frontend/src/pages/ProjectTemplateForm/ProjectTemplateFormView.tsx b/src/frontend/src/pages/ProjectTemplateForm/ProjectTemplateFormView.tsx index f2043c5b33..9b3e609a7c 100644 --- a/src/frontend/src/pages/ProjectTemplateForm/ProjectTemplateFormView.tsx +++ b/src/frontend/src/pages/ProjectTemplateForm/ProjectTemplateFormView.tsx @@ -15,7 +15,7 @@ import React from 'react'; import ProjectTemplateWorkPackageSection from './ProjectTemplateWorkPackageSection'; import { generateUUID } from '../../utils/form'; import { AttachMoney } from '@mui/icons-material'; -import { useAllTeams } from '../../hooks/teams.hooks'; +import { useAllTeamPreviews, useAllTeams } from '../../hooks/teams.hooks'; import LoadingIndicator from '../../components/LoadingIndicator'; import ErrorPage from '../ErrorPage'; import { WorkPackageTemplateApiInputs } from 'shared'; @@ -59,7 +59,7 @@ const ProjectTemplateFormView: React.FC = ({ const history = useHistory(); - const { data: teams, isLoading: teamsLoading, isError: teamsIsError, error: teamsError } = useAllTeams(); + const { data: teams, isLoading: teamsLoading, isError: teamsIsError, error: teamsError } = useAllTeamPreviews(); const pageTitle = defaultValues ? 'Edit Project Template' : 'Create Project Template'; diff --git a/src/frontend/src/pages/SettingsPage/UserScheduleSettings/Availability/AvailabilityEditModal.tsx b/src/frontend/src/pages/SettingsPage/UserScheduleSettings/Availability/AvailabilityEditModal.tsx index bd0f39c29e..e4c270eb97 100644 --- a/src/frontend/src/pages/SettingsPage/UserScheduleSettings/Availability/AvailabilityEditModal.tsx +++ b/src/frontend/src/pages/SettingsPage/UserScheduleSettings/Availability/AvailabilityEditModal.tsx @@ -25,7 +25,6 @@ const AvailabilityEditModal: React.FC = ({ initialDate, canChangeDateRange = true }) => { - const existingMeetingData = new Map }>(); const onCancel = () => { setConfirmedAvailabilities(new Map()); onHide(); @@ -38,12 +37,11 @@ const AvailabilityEditModal: React.FC = ({ title={header} onSubmit={onSubmit} submitText="Save" - paperProps={{ maxWidth: '900px', maxHeight: '680px' }} + paperProps={{ maxWidth: '1200px', maxHeight: '680px' }} > ; setEditedAvailabilities: (val: Map) => void; - existingMeetingData: ExistingMeetingData; totalAvailabilities: Availability[]; initialDate: Date; canChangeDateRange?: boolean; @@ -20,28 +19,21 @@ const EditAvailability: React.FC = ({ editedAvailabilities, totalAvailabilities, setEditedAvailabilities, - existingMeetingData, initialDate, canChangeDateRange = true }) => { const [currentlyDisplayedAvailabilities, setCurrentlyDisplayedAvailabilities] = useState(() => { const availabilities = Array.from(editedAvailabilities.values()); if (availabilities.length === 0) { - const defaultAvailabilities: Availability[] = []; - for (let i = 0; i < 7; i++) { - const date = addDaysToDate(initialDate, i); - defaultAvailabilities.push({ - dateSet: date, - availability: [] - }); - } + // Load existing availabilities instead of creating empty ones + const existingForWeek = getMostRecentAvailabilities(totalAvailabilities, initialDate); - defaultAvailabilities.forEach((availability) => { + existingForWeek.forEach((availability) => { editedAvailabilities.set(availability.dateSet.getTime(), availability); }); setEditedAvailabilities(editedAvailabilities); - return defaultAvailabilities; + return existingForWeek; } return availabilities; }); @@ -50,9 +42,7 @@ const EditAvailability: React.FC = ({ const handleMouseDown = (event: any, availability: Availability, selectedTime: number) => { event.preventDefault(); - toggleTimeSlot(availability, selectedTime); - setIsDragging(true); }; @@ -119,62 +109,91 @@ const EditAvailability: React.FC = ({ setCurrentlyDisplayedAvailabilities(getMostRecentAvailabilities(Array.from(editedAvailabilities.values()), initialDate)); }; + const stickyLeft = { + position: 'sticky', + left: 0, + zIndex: 2, + bgcolor: 'background.paper' + }; + return ( - - - - Available times in green - - + + + Available times in green + Invert Availability - - - {currentlyDisplayedAvailabilities.map((availability) => ( - - {getDayOfWeek(availability.dateSet)}
    {datePipe(availability.dateSet)} - - } - /> - ))} - {enumToArray(REVIEW_TIMES).map((time, timeIndex) => ( - - - {currentlyDisplayedAvailabilities.map((availability, dayIndex) => { - const backgroundColor = availability.availability.includes(timeIndex) ? HeatmapColors[3] : HeatmapColors[0]; - return ( - handleMouseDown(e, availability, timeIndex)} - onMouseEnter={(e) => handleMouseEnter(e, availability, timeIndex)} - onMouseUp={handleMouseUp} - icon={existingMeetingData.get(dayIndex)?.iconMap.get(timeIndex)} - /> - ); - })} - - ))} +
    + + + + + + + {currentlyDisplayedAvailabilities.map((availability, idx) => ( + + + {getDayOfWeek(availability.dateSet)} +
    + {datePipe(availability.dateSet)} +
    +
    + ))} +
    +
    + + {enumToArray(REVIEW_TIMES).map((time, timeIndex) => ( + + + + {time} + + + {currentlyDisplayedAvailabilities.map((availability, dayIndex) => { + const isAvailable = availability.availability.includes(timeIndex); + return ( + + handleMouseDown(e, availability, timeIndex)} + onMouseEnter={(e) => handleMouseEnter(e, availability, timeIndex)} + onMouseUp={handleMouseUp} + /> + + ); + })} + + ))} + +
    +
    + {canChangeDateRange && ( - + - + )} -
    + ); }; diff --git a/src/frontend/src/pages/SettingsPage/UserScheduleSettings/Availability/SingleAvailabilityModal.tsx b/src/frontend/src/pages/SettingsPage/UserScheduleSettings/Availability/SingleAvailabilityModal.tsx index 8ae9b966c9..3426338706 100644 --- a/src/frontend/src/pages/SettingsPage/UserScheduleSettings/Availability/SingleAvailabilityModal.tsx +++ b/src/frontend/src/pages/SettingsPage/UserScheduleSettings/Availability/SingleAvailabilityModal.tsx @@ -7,14 +7,27 @@ interface SingleAvailabilityModalProps { header: string; availabilites: Availability[]; onHide: () => void; + initialDate?: Date; } -const SingleAvailabilityModal: React.FC = ({ open, onHide, header, availabilites }) => { - const existingMeetingData = new Map }>(); - +const SingleAvailabilityModal: React.FC = ({ + open, + onHide, + header, + availabilites, + initialDate +}) => { return ( - - + + ); }; diff --git a/src/frontend/src/pages/SettingsPage/UserScheduleSettings/Availability/SingleAvailabilityView.tsx b/src/frontend/src/pages/SettingsPage/UserScheduleSettings/Availability/SingleAvailabilityView.tsx index 6ef266f6e5..638a1820a5 100644 --- a/src/frontend/src/pages/SettingsPage/UserScheduleSettings/Availability/SingleAvailabilityView.tsx +++ b/src/frontend/src/pages/SettingsPage/UserScheduleSettings/Availability/SingleAvailabilityView.tsx @@ -1,60 +1,107 @@ -import { Box, Grid } from '@mui/material'; -import { HeatmapColors, enumToArray, REVIEW_TIMES, ExistingMeetingData } from '../../../../utils/design-review.utils'; -import TimeSlot from '../../../../components/TimeSlot'; +import { Box, Table, TableBody, TableCell, TableContainer, TableHead, TableRow, Typography } from '@mui/material'; import { Availability, getDayOfWeek, getMostRecentAvailabilities } from 'shared'; import { datePipe } from '../../../../utils/pipes'; -import { useState } from 'react'; +import { useState, useEffect } from 'react'; import NERArrows from '../../../../components/NERArrows'; +import { enumToArray, REVIEW_TIMES, getBackgroundColor } from '../../../../utils/design-review.utils'; +import EventTimeSlot from '../../../CalendarPage/Components/EventTimeSlot'; interface SingleAvailabilityViewProps { totalAvailability: Availability[]; - existingMeetingData: ExistingMeetingData; + initialDate?: Date; } -const SingleAvailabilityView: React.FC = ({ totalAvailability, existingMeetingData }) => { - const [startDate, setStartDate] = useState(new Date()); +const SingleAvailabilityView: React.FC = ({ totalAvailability, initialDate }) => { + const [startDate, setStartDate] = useState(initialDate || new Date()); + + useEffect(() => { + if (initialDate) { + setStartDate(initialDate); + } + }, [initialDate]); + const selectedTimes = getMostRecentAvailabilities(totalAvailability, startDate); const onArrowIncrease = () => { - setStartDate(new Date(startDate.setDate(startDate.getDate() + 7))); + const newDate = new Date(startDate); + newDate.setDate(newDate.getDate() + 7); + setStartDate(newDate); }; const onArrowDecrease = () => { - setStartDate(new Date(startDate.setDate(startDate.getDate() - 7))); + const newDate = new Date(startDate); + newDate.setDate(newDate.getDate() - 7); + setStartDate(newDate); }; + + const stickyLeft = { + position: 'sticky', + left: 0, + zIndex: 2, + bgcolor: 'background.paper' + }; + return ( - - - {selectedTimes.map((availability) => ( - - ))} - {enumToArray(REVIEW_TIMES).map((time, timeIndex) => ( - - - {selectedTimes.map((availability, dayIndex) => { - const backgroundColor = availability.availability.includes(timeIndex) ? HeatmapColors[3] : HeatmapColors[0]; - return ( - - ); - })} - - ))} - + + + + + + + {selectedTimes.map((availability, idx) => ( + + + {getDayOfWeek(availability.dateSet) + ' ' + datePipe(availability.dateSet)} + + + ))} + + + + {enumToArray(REVIEW_TIMES).map((time, timeIndex) => ( + + + + {time} + + + {selectedTimes.map((availability, dayIndex) => { + const isAvailable = availability.availability.includes(timeIndex); + return ( + + {}} + /> + + ); + })} + + ))} + +
    +
    + -
    + ); }; diff --git a/src/frontend/src/pages/SettingsPage/UserScheduleSettings/UserScheduleSettings.tsx b/src/frontend/src/pages/SettingsPage/UserScheduleSettings/UserScheduleSettings.tsx index cae0a3dad9..13470d017e 100644 --- a/src/frontend/src/pages/SettingsPage/UserScheduleSettings/UserScheduleSettings.tsx +++ b/src/frontend/src/pages/SettingsPage/UserScheduleSettings/UserScheduleSettings.tsx @@ -21,7 +21,7 @@ import { useUpdateUserScheduleSettings, useUserScheduleSettings } from '../../.. import LoadingIndicator from '../../../components/LoadingIndicator'; import ErrorPage from '../../ErrorPage'; import { useToast } from '../../../hooks/toasts.hooks'; -import { useSingleDesignReview } from '../../../hooks/design-reviews.hooks'; +import { useSingleEvent } from '../../../hooks/calendar.hooks'; import { useQuery } from '../../../hooks/utils.hooks'; import { deeplyCopy } from 'shared'; import { availabilityTransformer } from '../../../apis/transformers/users.transformers'; @@ -39,7 +39,7 @@ const UserScheduleSettings = ({ user }: { user: AuthenticatedUser }) => { const [edit, setEdit] = useState(false); const toast = useToast(); const query = useQuery(); - const designReviewId = query.get('drId'); + const eventId = query.get('eventId'); const { data, isLoading, isError, error } = useUserScheduleSettings(user.userId); const { @@ -49,16 +49,16 @@ const UserScheduleSettings = ({ user }: { user: AuthenticatedUser }) => { error: updateUserScheduleSettingsError } = useUpdateUserScheduleSettings(); const { - data: designReview, - isError: designReviewIsError, - error: designReviewError, - isLoading: designReviewIsLoading - } = useSingleDesignReview(designReviewId ?? undefined); + data: event, + isError: eventIsError, + error: eventError, + isLoading: eventIsLoading + } = useSingleEvent(eventId ?? undefined); - if (designReviewId && (!designReview || designReviewIsLoading)) return ; + if (eventId && (!event || eventIsLoading)) return ; if (!data || isLoading || updateUserScheduleSettingsIsLoading) return ; - if (designReviewId && designReviewIsError) return ; + if (eventId && eventIsError) return ; if (isError) return ; if (updateUserScheduleSettingsIsError) return ; @@ -121,7 +121,7 @@ const UserScheduleSettings = ({ user }: { user: AuthenticatedUser }) => { {!edit ? ( - + ) : ( { const [availabilityOpen, setAvailabilityOpen] = useState(false); const toast = useToast(); - const defaultOpen = designReview !== undefined; + const defaultOpen = event !== undefined; const [confirmAvailabilityOpen, setConfirmAvailabilityOpen] = useState(defaultOpen || false); const [confirmedAvailabilities, setConfirmedAvailabilities] = useState(new Map()); - const { mutateAsync } = useMarkUserConfirmed(designReview?.designReviewId || ''); - const confirmModalTitle = designReview - ? `Update your availability for the ${designReview?.wbsName} Design Review on the week of ${new Date( - designReview.dateScheduled.getTime() - designReview.dateScheduled.getTimezoneOffset() * -60000 - ).toLocaleDateString()}` - : ''; + const { mutateAsync } = useMarkUserConfirmed(event?.eventId || ''); + + // Get first scheduled date from event + let firstScheduledDate: Date | undefined; + if (event && event.scheduledTimes && event.scheduledTimes.length > 0 && event.scheduledTimes[0].startTime) { + firstScheduledDate = new Date(event.scheduledTimes[0].startTime); + } + + // Get work package names for the event title + const workPackageNames = event?.workPackages.map((wp) => wp.wbsElement.name).join(', ') || 'Event'; + + const confirmModalTitle = + event && firstScheduledDate + ? `Update your availability for the ${workPackageNames} Design Review on the week of ${new Date( + firstScheduledDate.getTime() - firstScheduledDate.getTimezoneOffset() * -60000 + ).toLocaleDateString()}` + : ''; const handleConfirm = async (payload: { availability: Availability[] }) => { setConfirmAvailabilityOpen(false); @@ -44,15 +55,17 @@ const UserScheduleSettingsView = ({ } }; + const firstDate = useMemo(() => { + const raw = event?.scheduledTimes?.[0]?.startTime; + return raw ? new Date(raw as any) : new Date(); + }, [event?.scheduledTimes]); + useEffect(() => { if (confirmedAvailabilities.size === 0 && scheduleSettings.availabilities.length > 0) { - const confirmed = getMostRecentAvailabilities( - scheduleSettings.availabilities, - designReview?.initialDate || new Date() - ); + const confirmed = getMostRecentAvailabilities(scheduleSettings.availabilities, firstDate); setConfirmedAvailabilities(new Map(confirmed.map((availability) => [availability.dateSet.getTime(), availability]))); } - }, [scheduleSettings.availabilities, designReview, confirmedAvailabilities]); + }, [confirmedAvailabilities.size, scheduleSettings.availabilities, firstDate]); return ( @@ -69,7 +82,7 @@ const UserScheduleSettingsView = ({ confirmedAvailabilities={confirmedAvailabilities} setConfirmedAvailabilities={setConfirmedAvailabilities} totalAvailabilities={scheduleSettings.availabilities} - initialDate={designReview?.initialDate || new Date()} + initialDate={firstScheduledDate || new Date()} onSubmit={() => handleConfirm({ availability: Array.from(confirmedAvailabilities.values()) })} canChangeDateRange={false} /> diff --git a/src/frontend/src/tests/hooks/DesignReviews.hooks.test.tsx b/src/frontend/src/tests/hooks/DesignReviews.hooks.test.tsx deleted file mode 100644 index 5217699119..0000000000 --- a/src/frontend/src/tests/hooks/DesignReviews.hooks.test.tsx +++ /dev/null @@ -1,35 +0,0 @@ -/* - * 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 { renderHook, waitFor } from '@testing-library/react'; -import { AxiosResponse } from 'axios'; -import { DesignReview } from 'shared'; -import wrapper from '../../app/AppContextQuery'; -import { mockPromiseAxiosResponse } from '../test-support/test-data/test-utils.stub'; -import { exampleAllDesignReviews, exampleDesignReview1 } from '../test-support/test-data/design-reviews.stub'; -import { getAllDesignReviews, getSingleDesignReview } from '../../apis/design-reviews.api'; -import { useAllDesignReviews, useSingleDesignReview } from '../../hooks/design-reviews.hooks'; - -vi.mock('../../apis/design-reviews.api'); - -describe('design review hooks', () => { - it('handles getting a list of design reviews', async () => { - const mockedGetAllDesignReviews = getAllDesignReviews as jest.Mock>>; - mockedGetAllDesignReviews.mockReturnValue(mockPromiseAxiosResponse(exampleAllDesignReviews)); - - const { result } = renderHook(() => useAllDesignReviews(), { wrapper }); - await waitFor(() => result.current.isSuccess); - expect(result.current.data).toEqual(exampleAllDesignReviews); - }); - - it('handles getting a single design review', async () => { - const mockedGetSingleDesignReview = getSingleDesignReview as jest.Mock>>; - mockedGetSingleDesignReview.mockReturnValue(mockPromiseAxiosResponse(exampleDesignReview1)); - - const { result } = renderHook(() => useSingleDesignReview('1'), { wrapper }); - await waitFor(() => result.current.isSuccess); - expect(result.current.data).toEqual(exampleDesignReview1); - }); -}); diff --git a/src/frontend/src/tests/test-support/test-data/design-reviews.stub.ts b/src/frontend/src/tests/test-support/test-data/design-reviews.stub.ts index f28717f9ea..0d9b092220 100644 --- a/src/frontend/src/tests/test-support/test-data/design-reviews.stub.ts +++ b/src/frontend/src/tests/test-support/test-data/design-reviews.stub.ts @@ -3,9 +3,8 @@ * See the LICENSE file in the repository root folder for details. */ -import { DesignReview, DesignReviewStatus, TeamType } from 'shared'; +import { ConflictStatus, Event, EventStatus, TeamType } from 'shared'; import { exampleAdminUser, exampleAppAdminUser } from './users.stub'; -import { exampleWbsProject1 } from './wbs-numbers.stub'; export const teamType1: TeamType = { teamTypeId: '1', @@ -18,44 +17,112 @@ export const teamType1: TeamType = { deletedById: undefined }; -export const exampleDesignReview1: DesignReview = { - designReviewId: '1', - dateScheduled: new Date('2024-03-25'), - meetingTimes: [0, 1, 2, 3], - dateCreated: new Date('2024-03-10'), +export const exampleDesignReviewEvent1: Event = { + eventId: '1', + title: 'Design Review - Impact Attenuator', + approved: ConflictStatus.APPROVED, userCreated: exampleAdminUser, - status: DesignReviewStatus.CONFIRMED, - teamType: teamType1, + dateCreated: new Date('2024-03-10'), + eventTypeId: 'design-review-event-type-id', + approvalRequiredFrom: undefined, + scheduledTimes: [ + { + scheduleSlotId: 'slot-1', + startTime: new Date('2024-03-25T10:00:00'), + endTime: new Date('2024-03-25T11:00:00'), + allDay: false + }, + { + scheduleSlotId: 'slot-2', + startTime: new Date('2024-03-25T11:00:00'), + endTime: new Date('2024-03-25T12:00:00'), + allDay: false + }, + { + scheduleSlotId: 'slot-3', + startTime: new Date('2024-03-25T12:00:00'), + endTime: new Date('2024-03-25T13:00:00'), + allDay: false + }, + { + scheduleSlotId: 'slot-4', + startTime: new Date('2024-03-25T13:00:00'), + endTime: new Date('2024-03-25T14:00:00'), + allDay: false + } + ], requiredMembers: [exampleAdminUser], optionalMembers: [], confirmedMembers: [exampleAdminUser], deniedMembers: [], - isOnline: true, - isInPerson: false, - attendees: [exampleAdminUser], - wbsName: '1', - wbsNum: exampleWbsProject1, - initialDate: new Date('2024-03-25') + teams: [], // Design reviews don't link to specific teams + location: undefined, // Online only + zoomLink: 'https://zoom.us/j/example123', + shops: [], + machinery: [], + workPackages: [ + { + workPackageId: 'wp-1', + wbsElement: { + name: 'Impact Attenuator', + carNumber: 0, + projectNumber: 0, + workPackageNumber: 0 + } + } + ], + documents: [], + questionDocumentLink: 'https://docs.google.com/document/d/example-questions', + description: undefined, + status: EventStatus.CONFIRMED }; -export const exampleDesignReview2: DesignReview = { - designReviewId: '2', - dateScheduled: new Date('2024-03-25'), - meetingTimes: [0, 4], - dateCreated: new Date('2024-03-10'), +export const exampleDesignReviewEvent2: Event = { + eventId: '2', + title: 'Design Review - Bodywork', + approved: ConflictStatus.APPROVED, userCreated: exampleAppAdminUser, - status: DesignReviewStatus.CONFIRMED, - teamType: teamType1, + dateCreated: new Date('2024-03-10'), + eventTypeId: 'design-review-event-type-id', + approvalRequiredFrom: undefined, + scheduledTimes: [ + { + scheduleSlotId: 'slot-5', + startTime: new Date('2024-03-25T10:00:00'), + endTime: new Date('2024-03-25T11:00:00'), + allDay: false + }, + { + scheduleSlotId: 'slot-6', + startTime: new Date('2024-03-25T14:00:00'), + endTime: new Date('2024-03-25T15:00:00'), + allDay: false + } + ], requiredMembers: [exampleAppAdminUser], optionalMembers: [], confirmedMembers: [exampleAppAdminUser], deniedMembers: [], - isOnline: false, - isInPerson: true, - attendees: [exampleAppAdminUser], - wbsName: '1', - wbsNum: exampleWbsProject1, - initialDate: new Date('2024-03-25') + teams: [], + location: 'Campus Center Room 101', // In person + zoomLink: undefined, + shops: [], + machinery: [], + workPackages: [ + { + workPackageId: 'wp-2', + wbsElement: { + name: 'Bodywork', + carNumber: 0, + projectNumber: 1, + workPackageNumber: 0 + } + } + ], + documents: [], + questionDocumentLink: 'https://docs.google.com/document/d/example-questions-2', + description: undefined, + status: EventStatus.CONFIRMED }; -export const exampleAllDesignReviews: DesignReview[] = [exampleDesignReview1, exampleDesignReview2]; +export const exampleAllDesignReviews: Event[] = [exampleDesignReviewEvent1, exampleDesignReviewEvent2]; diff --git a/src/frontend/src/tests/test-support/test-data/work-packages.stub.ts b/src/frontend/src/tests/test-support/test-data/work-packages.stub.ts index ba4334e634..25a104a2be 100644 --- a/src/frontend/src/tests/test-support/test-data/work-packages.stub.ts +++ b/src/frontend/src/tests/test-support/test-data/work-packages.stub.ts @@ -55,7 +55,7 @@ export const exampleResearchWorkPackage: WorkPackage = { stage: WorkPackageStage.Research, blocking: [], teamTypes: [], - designReviews: [] + events: [] }; export const exampleDesignWorkPackage: WorkPackage = { @@ -101,7 +101,7 @@ export const exampleDesignWorkPackage: WorkPackage = { stage: WorkPackageStage.Design, blocking: [], teamTypes: [], - designReviews: [] + events: [] }; export const exampleManufacturingWorkPackage: WorkPackage = { @@ -137,7 +137,7 @@ export const exampleManufacturingWorkPackage: WorkPackage = { stage: WorkPackageStage.Manufacturing, blocking: [], teamTypes: [], - designReviews: [] + events: [] }; export const exampleInstallWorkPackage: WorkPackage = { @@ -172,7 +172,7 @@ export const exampleInstallWorkPackage: WorkPackage = { stage: WorkPackageStage.Install, blocking: [], teamTypes: [], - designReviews: [], + events: [], deleted: false }; @@ -208,7 +208,7 @@ export const exampleWorkPackage5: WorkPackage = { projectName: 'project3', blocking: [], teamTypes: [], - designReviews: [] + events: [] }; export const exampleAllWorkPackages: WorkPackage[] = [ diff --git a/src/frontend/src/utils/calendar.utils.ts b/src/frontend/src/utils/calendar.utils.ts new file mode 100644 index 0000000000..d644b8809f --- /dev/null +++ b/src/frontend/src/utils/calendar.utils.ts @@ -0,0 +1,152 @@ +import { DayOfWeek, Event, EventInstance } from 'shared'; +import { EventFormValues } from '../pages/CalendarPage/Components/EventModal'; + +export const convertDayToInt = (day: DayOfWeek) => { + switch (day) { + case DayOfWeek.MONDAY: + return 1; + case DayOfWeek.TUESDAY: + return 2; + case DayOfWeek.WEDNESDAY: + return 3; + case DayOfWeek.THURSDAY: + return 4; + case DayOfWeek.FRIDAY: + return 5; + case DayOfWeek.SATURDAY: + return 6; + case DayOfWeek.SUNDAY: + return 0; + } +}; + +export const convertIntToDay = (num: number) => { + switch (num) { + case 1: + return DayOfWeek.MONDAY; + case 2: + return DayOfWeek.TUESDAY; + case 3: + return DayOfWeek.WEDNESDAY; + case 4: + return DayOfWeek.THURSDAY; + case 5: + return DayOfWeek.FRIDAY; + case 6: + return DayOfWeek.SATURDAY; + case 0: + return DayOfWeek.SUNDAY; + default: + return DayOfWeek.MONDAY; + } +}; + +export const convertDayToDayShorthand = (day: DayOfWeek) => { + switch (day) { + case DayOfWeek.MONDAY: + return 'M'; + case DayOfWeek.TUESDAY: + return 'T'; + case DayOfWeek.WEDNESDAY: + return 'W'; + case DayOfWeek.THURSDAY: + return 'Th'; + case DayOfWeek.FRIDAY: + return 'F'; + case DayOfWeek.SATURDAY: + return 'Sat'; + case DayOfWeek.SUNDAY: + return 'S'; + default: + return 'Undefined'; + } +}; + +// Get a list of dates for user viewing purposes (formatted to their timezone, with date and start/end time) +// After the recurring events refactor, each schedule slot contains the actual date/time +// Should be used when events need to be populated/displayed +export const getMeetingDates = (event: Event, startTimes: boolean = true) => { + const times: Date[] = []; + + event.scheduledTimes.forEach((schedule) => { + const specificTime = startTimes ? schedule.startTime : schedule.endTime; + + // With the new schema, startTime and endTime contain the full date/time + // Just return the dates directly + if (specificTime) { + times.push(new Date(specificTime)); + } else if (schedule.allDay && schedule.startTime) { + // For all-day events, use startTime for the date + times.push(new Date(schedule.startTime)); + } + }); + + return times; +}; + +// check when two events overlap, returning the start and end time for both events that overlap +export const getOverlapTime = (event1: Event, event2: Event) => { + const starts1 = getMeetingDates(event1, true); + const ends1 = getMeetingDates(event1, false); + const starts2 = getMeetingDates(event2, true); + const ends2 = getMeetingDates(event2, false); + + const overlaps: { event1Time: { start: Date; end: Date }; event2Time: { start: Date; end: Date } }[] = []; + + for (let i = 0; i < starts1.length; i++) { + const start1 = starts1[i]; + const end1 = ends1[i]; + + for (let j = 0; j < starts2.length; j++) { + const start2 = starts2[j]; + const end2 = ends2[j]; + + if (start1 < end2 && end1 > start2) { + overlaps.push({ + event1Time: { start: start1, end: end1 }, + event2Time: { start: start2, end: end2 } + }); + } + } + } + + return overlaps; +}; + +// converts an Event into Event Form Values +// Note: Because users can only edit a single instaces time, editModal is always populated with an event instance +// representing a single occurrence of the event. However, event edits will effect the entire series for all values +// except for the schedule slot (date/time), and users are prompted if they want the time effects to propogate to other schedule slots +export const convertEventToFormValues = (event: EventInstance): Partial => { + // For edit mode, use the actual scheduled date of this occurrence, not initialDateScheduled + const scheduleDate = event.startTime ? new Date(event.startTime) : undefined; + + 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, + startTime: event.startTime ? new Date(event.startTime) : undefined, + endTime: event.endTime ? new Date(event.endTime) : undefined, + allDay: event.allDay ?? false, + // Set recurrence to 0 since we've already expanded the schedule + recurrenceNumber: 0, + // No days since this is now a single occurrence + days: [], + selectedScheduleSlotId: event.scheduleSlotId + }; +}; diff --git a/src/frontend/src/utils/datetime.utils.ts b/src/frontend/src/utils/datetime.utils.ts index ea7c37442b..4d562c5ca1 100644 --- a/src/frontend/src/utils/datetime.utils.ts +++ b/src/frontend/src/utils/datetime.utils.ts @@ -63,3 +63,7 @@ export const dateMonthDayYear = (date: Date): string => { export const isPastEvent = (startDate: Date, endDate: Date) => { return startDate < endDate; }; + +export const formatTime = (date: Date) => { + return date.toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit' }); +}; diff --git a/src/frontend/src/utils/design-review.utils.ts b/src/frontend/src/utils/design-review.utils.ts index 2367926673..189bc0a42a 100644 --- a/src/frontend/src/utils/design-review.utils.ts +++ b/src/frontend/src/utils/design-review.utils.ts @@ -1,4 +1,4 @@ -import { DesignReview, DesignReviewStatus } from 'shared'; +import { Event, EventStatus } from 'shared'; export const enumToArray = (en: { [key: number]: string | number }) => { return Object.keys(en).filter((value: string) => isNaN(Number(value)) === true); @@ -89,36 +89,34 @@ export const getWeekDateRange = (selectedDate: Date) => { return [startDate, endDate]; }; -export const isConfirmed = (designReview: DesignReview): boolean => { +export const isConfirmed = (event: Event): boolean => { return ( - designReview.status === DesignReviewStatus.CONFIRMED || - designReview.status === DesignReviewStatus.SCHEDULED || - designReview.status === DesignReviewStatus.DONE + event.status === EventStatus.CONFIRMED || event.status === EventStatus.SCHEDULED || event.status === EventStatus.DONE ); }; -export const designReviewStatusPipe = (status: DesignReviewStatus) => { +export const eventStatusPipe = (status: EventStatus) => { switch (status) { - case DesignReviewStatus.CONFIRMED: + case EventStatus.CONFIRMED: return 'Ready to Schedule'; - case DesignReviewStatus.UNCONFIRMED: + case EventStatus.UNCONFIRMED: return 'Unconfirmed'; - case DesignReviewStatus.SCHEDULED: + case EventStatus.SCHEDULED: return 'Scheduled'; - case DesignReviewStatus.DONE: + case EventStatus.DONE: return 'Completed'; } }; -export const designReviewStatusColor = (status: DesignReviewStatus) => { +export const eventStatusColor = (status: EventStatus) => { switch (status) { - case DesignReviewStatus.CONFIRMED: + case EventStatus.CONFIRMED: return 'orange'; - case DesignReviewStatus.UNCONFIRMED: + case EventStatus.UNCONFIRMED: return 'grey'; - case DesignReviewStatus.SCHEDULED: + case EventStatus.SCHEDULED: return '#ef4345'; - case DesignReviewStatus.DONE: + case EventStatus.DONE: return 'green'; } }; diff --git a/src/frontend/src/utils/enum-pipes.ts b/src/frontend/src/utils/enum-pipes.ts index 47b22dce70..57926da60c 100644 --- a/src/frontend/src/utils/enum-pipes.ts +++ b/src/frontend/src/utils/enum-pipes.ts @@ -3,14 +3,7 @@ * See the LICENSE file in the repository root folder for details. */ import { yellow, green, blue, purple, grey, orange } from '@mui/material/colors'; -import { - ChangeRequestStatus, - ChangeRequestType, - DesignReviewStatus, - TaskStatus, - WbsElementStatus, - WorkPackageStage -} from 'shared'; +import { ChangeRequestStatus, ChangeRequestType, EventStatus, TaskStatus, WbsElementStatus, WorkPackageStage } from 'shared'; // maps stage to the desired color export const WorkPackageStageColorPipe: (stage: WorkPackageStage | undefined) => string = (stage) => { @@ -89,15 +82,15 @@ export const WbsElementStatusTextPipe: (status: WbsElementStatus) => string = (s } }; -export const DesignReviewStatusTextPipe: (status: DesignReviewStatus) => string = (status) => { +export const DesignReviewEventStatusTextPipe: (status: EventStatus) => string = (status) => { switch (status) { - case DesignReviewStatus.UNCONFIRMED: + case EventStatus.UNCONFIRMED: return 'Unconfirmed'; - case DesignReviewStatus.CONFIRMED: + case EventStatus.CONFIRMED: return 'Confirmed'; - case DesignReviewStatus.DONE: + case EventStatus.DONE: return 'Done'; - case DesignReviewStatus.SCHEDULED: + case EventStatus.SCHEDULED: return 'Scheduled'; } }; diff --git a/src/frontend/src/utils/form.ts b/src/frontend/src/utils/form.ts index ab5f13bee4..7f4bfe3daa 100644 --- a/src/frontend/src/utils/form.ts +++ b/src/frontend/src/utils/form.ts @@ -82,5 +82,9 @@ export enum FormStorageKey { CREATE_MILESTONE = 'CREATE_MILESTONE', EDIT_MILESTONE = 'EDIT_MILESTONE', CREATE_FAQ = 'CREATE_FAQ', - EDIT_FAQ = 'EDIT_FAQ' + EDIT_FAQ = 'EDIT_FAQ', + CREATE_MACHINERY = 'CREATE_MACHINERY', + EDIT_MACHINERY = 'EDIT_MACHINERY', + CREATE_EVENT_TYPE = 'CREATE_EVENT_TYPE', + EDIT_EVENT_TYPE = 'EDIT_EVENT_TYPE' } diff --git a/src/frontend/src/utils/gantt.utils.tsx b/src/frontend/src/utils/gantt.utils.tsx index 2a8c06c499..7b3d5465a1 100644 --- a/src/frontend/src/utils/gantt.utils.tsx +++ b/src/frontend/src/utils/gantt.utils.tsx @@ -5,8 +5,8 @@ import { addWeeksToDate, - DesignReviewPreview, - DesignReviewStatus, + EventPreview, + EventStatus, isWorkPackage, ProjectGantt, RetrospectiveProjectPreview, @@ -158,12 +158,12 @@ export const getProjectEndDate = (project: ProjectGantt): Date => { }, wpEnd); }; -export const transformDesignReviewToGanttEvent = (designReview: DesignReviewPreview): GanttEvent => { +export const transformDesignReviewEventToGanttEvent = (event: EventPreview): GanttEvent => { return { - date: designReview.dateScheduled, - color: ganttDesignReviewStatusColorPipe(designReview.status), - onClick: () => window.open(`${routes.CALENDAR}/${designReview.designReviewId}`, '_blank'), - name: designReview.wbsName + date: event.dateScheduled, + color: ganttDesignReviewEventStatusColorPipe(event.status), + onClick: () => window.open(`${routes.CALENDAR}/${event.eventId}`, '_blank'), + name: event.wbsName }; }; @@ -448,7 +448,7 @@ export const transformWorkPackageToGanttTask = ( start: workPackage.startDate, end: workPackage.endDate, - events: workPackage.designReviews.map(transformDesignReviewToGanttEvent), + events: workPackage.events.map(transformDesignReviewEventToGanttEvent), blocking: getBlockingGanttTasks(workPackage, allWorkPackages, transformWorkPackageToGanttTask), children: [], overlays: [], @@ -582,8 +582,8 @@ export const sortWbs = (a: { wbsNum: WbsNumber }, b: { wbsNum: WbsNumber }) => { return aWbsNum.workPackageNumber - bWbsNum.workPackageNumber; }; -export const ganttDesignReviewStatusColorPipe = (status: DesignReviewStatus) => { - return status !== DesignReviewStatus.UNCONFIRMED ? '#712f99' : '#876e96'; +export const ganttDesignReviewEventStatusColorPipe = (status: EventStatus) => { + return status !== EventStatus.UNCONFIRMED ? '#712f99' : '#876e96'; }; // Maps task status to the desired color for Gantt Chart diff --git a/src/frontend/src/utils/pipes.ts b/src/frontend/src/utils/pipes.ts index 22b5f95e7e..6032768f18 100644 --- a/src/frontend/src/utils/pipes.ts +++ b/src/frontend/src/utils/pipes.ts @@ -9,10 +9,11 @@ import { isProject, IndexCode, AccountCode, - DesignReview, WorkPackagePreview, WbsElementPreview, - UserPreview + UserPreview, + ScheduleSlot, + Event } from 'shared'; /** @@ -126,8 +127,18 @@ export const daysOrWeeksLeftOrLate = (daysLeft: number) => { return `${daysToDaysOrWeeksPipe(Math.abs(daysLeft))} ${daysLeft > 0 ? 'left' : 'late'}`; }; -export const designReviewNamePipe = (designReview: DesignReview) => { - return `${wbsPipe(designReview.wbsNum)} - ${designReview.wbsName}`; +export const eventNamePipe = (event: Event) => { + const [firstWorkPackage] = event.workPackages; + + if (firstWorkPackage) { + return `${wbsPipe({ + carNumber: firstWorkPackage.wbsElement.carNumber, + projectNumber: firstWorkPackage.wbsElement.projectNumber, + workPackageNumber: firstWorkPackage.wbsElement.workPackageNumber + })} - ${firstWorkPackage.wbsElement.name}`; + } + + return event.title; }; export const dateRangePipe = (startDate: Date, endDate: Date) => { @@ -175,11 +186,16 @@ export const displayEnum = (enumString: string) => { return enumString; }; -export const meetingStartTimePipe = (times: number[], isEndTime = false) => { - if (isEndTime && times[0] % 12 === 0) return '10pm'; - const time = (times[0] % 12) + 10; +export const meetingStartTimePipeScheduleSlot = (scheduledTimes: ScheduleSlot[]): string => { + if (scheduledTimes.length === 0) return ''; + + const firstTime = scheduledTimes[0].startTime; + if (!firstTime) return ''; - return time === 12 ? time + 'pm' : time < 12 ? time + 'am' : time - 12 + 'pm'; + const date = new Date(firstTime); + const hour = date.getHours(); + const displayHour = hour % 12 || 12; + return displayHour + (hour < 12 ? 'am' : 'pm'); }; // takes in a Date and returns it as a string in the form mm/dd/yy diff --git a/src/frontend/src/utils/routes.ts b/src/frontend/src/utils/routes.ts index f2317a1af0..a70439d003 100644 --- a/src/frontend/src/utils/routes.ts +++ b/src/frontend/src/utils/routes.ts @@ -66,7 +66,7 @@ const PROJECT_TEMPLATE_EDIT = PROJECT_TEMPLATES + '/edit'; /**************** Design Review Calendar ****************/ const CALENDAR = `/design-review-calendar`; -const DESIGN_REVIEW_BY_ID = CALENDAR + `/:id`; +const NEW_CALENDAR = `/calendar`; /**************** Organizations ****************/ const ORGANIZATIONS = `/organizations`; @@ -136,7 +136,7 @@ export const routes = { PROJECT_TEMPLATE_EDIT, CALENDAR, - DESIGN_REVIEW_BY_ID, + NEW_CALENDAR, ORGANIZATIONS, diff --git a/src/frontend/src/utils/urls.ts b/src/frontend/src/utils/urls.ts index e207dd37b1..d3d2af692e 100644 --- a/src/frontend/src/utils/urls.ts +++ b/src/frontend/src/utils/urls.ts @@ -113,6 +113,8 @@ const workPackagesDelete = (wbsNum: string) => `${workPackagesByWbsNum(wbsNum)}/ const workPackagesBlocking = (wbsNum: string) => `${workPackagesByWbsNum(wbsNum)}/blocking`; const workPackagesSlackUpcomingDeadlines = () => `${workPackages()}/slack-upcoming-deadlines`; const workPackagesMany = () => `${workPackages()}/get-many`; +const workPackagesAllPreview = (status?: string) => + `${API_URL}/work-packages/all-preview${status ? `?status=${status}` : ''}`; const homePageWorkPackages = (selection: WorkPackageSelection) => `${workPackages()}/home-page/${selection}`; /**************** Change Requests Endpoints ****************/ @@ -135,6 +137,7 @@ const changeRequestRequestReviewer = (id: string) => changeRequestsById(id) + '/ /**************** Teams Endpoints ****************/ const teams = () => `${API_URL}/teams`; +const teamPreviews = () => `${API_URL}/teams/previews/`; const teamsById = (id: string) => `${teams()}/${id}`; const teamsDelete = (id: string) => `${teamsById(id)}/delete`; const teamsSetMembers = (id: string) => `${teamsById(id)}/set-members`; @@ -336,6 +339,7 @@ const bomUnitById = (id: string) => `${bomGetAllUnits()}/${id}`; const bomDeleteUnit = (id: string) => `${bomUnitById(id)}/delete`; /************** Design Review Endpoints *******************************/ +/* const designReviews = () => `${API_URL}/design-reviews`; const designReviewsCreate = () => `${designReviews()}/create`; const designReviewsEdit = (designReviewId: string) => `${designReviews()}/${designReviewId}/edit`; @@ -343,6 +347,7 @@ const designReviewById = (id: string) => `${designReviews()}/${id}`; const designReviewDelete = (id: string) => `${designReviewById(id)}/delete`; const designReviewMarkUserConfirmed = (id: string) => `${designReviewById(id)}/confirm-schedule`; const designReviewSetStatus = (id: string) => `${designReviewById(id)}/set-status`; +*/ /******************* WBS Element Template Endpoints ********************/ @@ -441,6 +446,46 @@ const retrospectiveTimelines = (startDate?: Date, endDate?: Date) => (endDate ? `end=${encodeURIComponent(endDate.toISOString())}` : ''); const retrospectiveBudgets = () => `${API_URL}/retrospective/budgets`; +/**************** Calendar Endpoints ****************/ +const calendar = () => `${API_URL}/calendar`; +const calendarShops = () => `${calendar()}/shops`; +const calendarEvents = () => `${calendar()}/events`; +const calendarEventTypes = () => `${calendar()}/event-types`; +const calendarCreateShop = () => `${calendar()}/shop/create`; +const calendarFilterEvents = () => `${calendar()}/events/filter`; +const calendarMachinery = () => `${calendar()}/machinery`; +const calendarCreateMachinery = () => `${calendar()}/machinery/create`; +const calendarEditMachinery = (machineryId: string) => `${calendar()}/machinery/${machineryId}/edit`; +const calendarDeleteMachinery = (machineryId: string) => `${calendar()}/machinery/${machineryId}/delete`; +const calendarAddMachineryToShop = (machineryId: string) => `${calendar()}/machinery/${machineryId}/add-to-shop`; +const calendarEditShop = (shopId: string) => `${calendar()}/shop/${shopId}/edit`; +const calendarDeleteShop = (shopId: string) => `${calendar()}/shop/${shopId}/delete`; +const calendarDeleteCalendar = (calendarId: string) => `${calendar()}/${calendarId}/delete`; +const calendarCreateCalendar = () => `${calendar()}/create`; +const calendarEditCalendar = (calendarId: string) => `${calendar()}/${calendarId}/edit`; +const calendarCalendars = () => `${calendar()}/calendars`; +const calendarCreateEventType = () => `${calendar()}/event-type/create`; +const calendarEditEventType = (eventTypeId: string) => `${calendar()}/event-type/${eventTypeId}/edit`; +const calendarDeleteEventType = (eventTypeId: string) => `${calendar()}/event-type/${eventTypeId}/delete`; +const calendarEventMarkUserConfirmed = (id: string) => `${calendar()}/event/${id}/confirm-schedule`; +const calendarGetSingleEvent = (id: string) => `${calendar()}/event/${id}`; +const calendarGetSingleEventWithMembers = (id: string) => `${calendar()}/event-members/${id}`; +const calendarGetConflictingEvent = (id: string) => `${calendar()}/event/${id}/conflict`; +const calendarDeleteEvent = (id: string) => `${calendar()}/event/${id}/delete`; +const calendarEventSetStatus = (id: string) => `${calendar()}/event/${id}/set-status`; +const calendarApproveEvent = (id: string) => `${calendar()}/event/${id}/approve`; +const calendarDenyEvent = (id: string) => `${calendar()}/event/${id}/deny`; +const calendarCreateEvent = () => `${calendar()}/event/create`; +const calendarEditEvent = (eventId: string) => `${calendar()}/event/${eventId}/edit`; +const calendarEditScheduleSlot = (eventId: string, scheduleSlotId: string) => + `${calendar()}/event/${eventId}/schedule-slot/${scheduleSlotId}/edit`; +const calendarPreviewScheduleSlotRecurringEdits = (eventId: string, scheduleSlotId: string) => + `${calendar()}/event/${eventId}/schedule-slot/${scheduleSlotId}/preview-recurring-edits`; +const calendarDeleteScheduleSlot = (eventId: string, scheduleSlotId: string) => + `${calendar()}/event/${eventId}/schedule-slot/${scheduleSlotId}/delete`; +const calendarUploadDocument = (eventId: string) => `${calendar()}/event/${eventId}/upload-document`; +const calendarPDFById = (fileId: string) => `${calendar()}/document/${fileId}`; + /**************** Other Endpoints ****************/ const version = () => `https://api.github.com/repos/Northeastern-Electric-Racing/FinishLine/releases/latest`; @@ -534,6 +579,7 @@ export const apiUrls = { workPackagesBlocking, workPackagesSlackUpcomingDeadlines, workPackagesMany, + workPackagesAllPreview, homePageWorkPackages, changeRequests, @@ -552,6 +598,7 @@ export const apiUrls = { approvedChangeRequests, teams, + teamPreviews, teamsById, teamsDelete, teamsSetMembers, @@ -659,7 +706,7 @@ export const apiUrls = { bomCreateUnit, bomUnitById, bomDeleteUnit, - + /* designReviews, designReviewsCreate, designReviewById, @@ -667,7 +714,7 @@ export const apiUrls = { designReviewMarkUserConfirmed, designReviewDelete, designReviewSetStatus, - +*/ workPackageTemplates, workPackageTemplatesById, workPackageTemplatesEdit, @@ -748,5 +795,40 @@ export const apiUrls = { retrospectiveTimelines, retrospectiveBudgets, + calendarShops, + calendarCreateShop, + calendarFilterEvents, + calendarMachinery, + calendarCreateMachinery, + calendarEditMachinery, + calendarDeleteMachinery, + calendarAddMachineryToShop, + calendarEditShop, + calendarEventMarkUserConfirmed, + calendarGetSingleEvent, + calendarGetSingleEventWithMembers, + calendarGetConflictingEvent, + calendarEvents, + calendarEventTypes, + calendarDeleteEvent, + calendarEventSetStatus, + calendarDeleteShop, + calendarDeleteCalendar, + calendarCreateCalendar, + calendarEditCalendar, + calendarCalendars, + calendarCreateEventType, + calendarEditEventType, + calendarCreateEvent, + calendarUploadDocument, + calendarPDFById, + calendarDeleteEventType, + calendarApproveEvent, + calendarDenyEvent, + calendarEditEvent, + calendarEditScheduleSlot, + calendarPreviewScheduleSlotRecurringEdits, + calendarDeleteScheduleSlot, + version }; diff --git a/src/shared/index.ts b/src/shared/index.ts index d58823112f..8fc3312f37 100644 --- a/src/shared/index.ts +++ b/src/shared/index.ts @@ -19,6 +19,7 @@ export * from './src/types/pop-up-types.js'; export * from './src/types/announcements.types.js'; export * from './src/types/part-review.types.js'; export * from './src/types/finance-types.js'; +export * from './src/types/calendar-types.js'; export * from './src/validate-wbs.js'; export * from './src/date-utils.js'; diff --git a/src/shared/src/types/calendar-types.ts b/src/shared/src/types/calendar-types.ts new file mode 100644 index 0000000000..938a72beff --- /dev/null +++ b/src/shared/src/types/calendar-types.ts @@ -0,0 +1,258 @@ +import { User, UserWithScheduleSettings } from './user-types.js'; + +export interface EventDocumentCreateArgs { + googleFileId: string; + name: string; +} + +export interface EventDocumentUploadArgs extends EventDocumentCreateArgs { + file?: File; +} + +export interface Document { + documentId: string; + googleFileId: string; + name: string; +} + +export interface ShopPreview { + shopId: string; + name: string; +} + +export interface MachineryPreview { + machineryId: string; + name: string; +} + +export interface TeamCalendarPreview { + teamId: string; + teamName: string; +} + +export interface TeamWithMembers { + teamId: string; + teamName: string; + members: User[]; + leads: User[]; + head: User; +} + +export interface TeamTypeCalendarPreview { + teamTypeId: string; + name: string; +} + +export interface TeamTypeWithMembersCalendarPreview { + teamTypeId: string; + name: string; + teams: { + members: User[]; + leads: User[]; + head: User; + }[]; +} + +export interface WorkPackageCalendarPreview { + workPackageId: string; + wbsElement: { + name: string; + carNumber: number; + projectNumber: number; + workPackageNumber: number; + }; +} + +export enum EventStatus { + UNCONFIRMED = 'UNCONFIRMED', + CONFIRMED = 'CONFIRMED', + SCHEDULED = 'SCHEDULED', + DONE = 'DONE' +} + +export enum DayOfWeek { + MONDAY = 'MONDAY', + TUESDAY = 'TUESDAY', + WEDNESDAY = 'WEDNESDAY', + THURSDAY = 'THURSDAY', + FRIDAY = 'FRIDAY', + SATURDAY = 'SATURDAY', + SUNDAY = 'SUNDAY' +} + +export enum ConflictStatus { + PENDING = 'PENDING', + APPROVED = 'APPROVED', + DENIED = 'DENIED', + NO_CONFLICT = 'NO_CONFLICT' +} + +export interface Calendar { + calendarId: string; + name: string; + description: string; + color: string; + userCreated: User; + dateCreated: Date; + eventTypes: EventType[]; +} + +export interface ScheduleSlot { + scheduleSlotId: string; + startTime: Date; + endTime: Date; + allDay: boolean; +} + +export interface ScheduleSlotCreateArgs { + startTime: Date; + endTime: Date; + allDay: boolean; +} + +export interface FilterArgs { + memberIds?: string[]; + teamIds?: string[]; + calendarIds?: string[]; + eventTypeIds?: string[]; + eventIds?: string[]; + statuses?: ConflictStatus[]; + approvalIds?: string[]; + startPeriod: Date; + endPeriod: Date; +} + +export interface EventType { + eventTypeId: string; + name: string; + userCreated: User; + dateCreated: Date; + calendarIds: string[]; + requiredMembers: boolean; + optionalMembers: boolean; + teams: boolean; + teamType: boolean; + location: boolean; + zoomLink: boolean; + shop: boolean; + machinery: boolean; + workPackage: boolean; + questionDocument: boolean; + documents: boolean; + description: boolean; + onlyHeadsOrAboveForEventCreation: boolean; + requiresConfirmation: boolean; + sendSlackNotifications: boolean; +} + +export interface EventTypeCreateArgs { + name: string; + calendarIds: string[]; + requiredMembers: boolean; + optionalMembers: boolean; + teams: boolean; + teamType: boolean; + location: boolean; + zoomLink: boolean; + shop: boolean; + machinery: boolean; + workPackage: boolean; + questionDocument: boolean; + documents: boolean; + description: boolean; + onlyHeadsOrAbove: boolean; + requiresConfirmation: boolean; + sendSlackNotifications: boolean; +} + +export interface Shop { + shopId: string; + name: string; + description: string; + userCreated: User; + dateCreated: Date; +} + +export interface ShopMachinery { + shopMachineryId: string; + shop: Shop; + quantity: number; + description?: string; +} + +export interface Machinery { + machineryId: string; + name: string; + userCreated: User; + dateCreated: Date; + shops: ShopMachinery[]; +} + +export interface Event { + eventId: string; + title: string; + approved: ConflictStatus; + userCreated: UserWithScheduleSettings; + dateCreated: Date; + eventTypeId: string; + approvalRequiredFrom?: User; + scheduledTimes: ScheduleSlot[]; + requiredMembers: User[]; + optionalMembers: User[]; + confirmedMembers: UserWithScheduleSettings[]; + deniedMembers: User[]; + teams: TeamCalendarPreview[]; + teamType?: TeamTypeCalendarPreview; + location?: string; + zoomLink?: string; + shops: ShopPreview[]; + machinery: MachineryPreview[]; + workPackages: WorkPackageCalendarPreview[]; + documents: Document[]; + questionDocumentLink?: string; + description?: string; + status: EventStatus; + initialDateScheduled?: Date; +} + +export type EventInstance = Omit & + ScheduleSlot & { + recurring: boolean; + totalScheduledSlots: number; + }; + +export type EventPreview = { + eventId: string; + title: string; + dateScheduled: Date; + status: EventStatus; + userCreated: User; + wbsName: string; +}; + +export interface EventWithMembers { + eventId: string; + title: string; + approved: ConflictStatus; + userCreated: UserWithScheduleSettings; + dateCreated: Date; + eventTypeId: string; + approvalRequiredFrom?: User; + scheduledTimes: ScheduleSlot[]; + requiredMembers: User[]; + optionalMembers: User[]; + confirmedMembers: UserWithScheduleSettings[]; + deniedMembers: User[]; + teams: TeamWithMembers[]; + teamType?: TeamTypeWithMembersCalendarPreview; + location?: string; + zoomLink?: string; + shops: ShopPreview[]; + machinery: MachineryPreview[]; + workPackages: WorkPackageCalendarPreview[]; + documents: Document[]; + questionDocumentLink?: string; + description?: string; + status: EventStatus; + initialDateScheduled?: Date; +} diff --git a/src/shared/src/types/design-review-types.ts b/src/shared/src/types/design-review-types.ts index e74af30db2..a3301bb5c4 100644 --- a/src/shared/src/types/design-review-types.ts +++ b/src/shared/src/types/design-review-types.ts @@ -1,5 +1,6 @@ -import { WbsNumber } from './project-types.js'; -import { User, UserWithScheduleSettings } from './user-types.js'; +/* +import { WbsNumber } from './project-types'; +import { User, UserWithScheduleSettings } from './user-types'; export interface DesignReview { designReviewId: string; @@ -38,6 +39,7 @@ export enum DesignReviewStatus { SCHEDULED = 'SCHEDULED', DONE = 'DONE' } + */ export interface TeamType { teamTypeId: string; diff --git a/src/shared/src/types/project-types.ts b/src/shared/src/types/project-types.ts index 54100c37df..d4761a2eb4 100644 --- a/src/shared/src/types/project-types.ts +++ b/src/shared/src/types/project-types.ts @@ -3,8 +3,9 @@ * See the LICENSE file in the repository root folder for details. */ +import { EventPreview } from './calendar-types.js'; import { ImplementedChange } from './change-request-types.js'; -import { DesignReviewPreview, TeamType } from './design-review-types.js'; +import { TeamType } from './design-review-types.js'; import { Task } from './task-types.js'; import { TeamPreview } from './team-types.js'; import { User, UserPreview } from './user-types.js'; @@ -105,7 +106,7 @@ export interface WorkPackage extends WbsElement { stage?: WorkPackageStage; teamTypes: TeamType[]; projectId: string; - designReviews: DesignReviewPreview[]; + events: EventPreview[]; } export interface WorkPackagePreview extends WbsElementPreview { diff --git a/src/shared/src/types/team-types.ts b/src/shared/src/types/team-types.ts index 88eb31190e..d589f162de 100644 --- a/src/shared/src/types/team-types.ts +++ b/src/shared/src/types/team-types.ts @@ -7,18 +7,22 @@ import { TeamType } from './design-review-types.js'; import { ProjectGantt } from './project-types.js'; import { User } from './user-types.js'; -export interface Team { +export interface TeamBase { teamId: string; teamName: string; - head: User; slackId: string; description: string; + dateArchived?: Date; + teamType?: TeamType; +} + +export interface TeamPreview extends TeamBase { members: User[]; - projects: ProjectGantt[]; + head: User; leads: User[]; userArchived?: User; - dateArchived?: Date; - teamType?: TeamType; } -export type TeamPreview = Pick; +export interface Team extends TeamPreview { + projects: ProjectGantt[]; +} diff --git a/src/shared/src/utils.ts b/src/shared/src/utils.ts index a754e51e01..8c3d48185a 100644 --- a/src/shared/src/utils.ts +++ b/src/shared/src/utils.ts @@ -25,10 +25,10 @@ export const isSubset = (elements: string[], suppliedArray: string[]): boolean = return elements.every((element) => suppliedArray.includes(element)); }; -export const meetingStartTimePipe = (times: number[]) => { - const time = (times[0] % 12) + 10; - - return time <= 12 ? time + 'am' : time - 12 + 'pm'; +export const meetingStartTimePipeNumbers = (hours: number[]) => { + const [hour] = hours; + const displayHour = hour % 12 || 12; // Convert 0 to 12 for midnight, 13-23 to 1-11 + return displayHour + (hour < 12 ? 'am' : 'pm'); }; export const MAX_FILE_SIZE = 25 * 1024 * 1024; // 25MB diff --git a/yarn.lock b/yarn.lock index d1e55cea99..813be6d6e3 100644 --- a/yarn.lock +++ b/yarn.lock @@ -79,251 +79,251 @@ __metadata: linkType: hard "@aws-sdk/client-ses@npm:^3.731.1": - version: 3.969.0 - resolution: "@aws-sdk/client-ses@npm:3.969.0" + version: 3.970.0 + resolution: "@aws-sdk/client-ses@npm:3.970.0" dependencies: "@aws-crypto/sha256-browser": 5.2.0 "@aws-crypto/sha256-js": 5.2.0 - "@aws-sdk/core": 3.969.0 - "@aws-sdk/credential-provider-node": 3.969.0 + "@aws-sdk/core": 3.970.0 + "@aws-sdk/credential-provider-node": 3.970.0 "@aws-sdk/middleware-host-header": 3.969.0 "@aws-sdk/middleware-logger": 3.969.0 "@aws-sdk/middleware-recursion-detection": 3.969.0 - "@aws-sdk/middleware-user-agent": 3.969.0 + "@aws-sdk/middleware-user-agent": 3.970.0 "@aws-sdk/region-config-resolver": 3.969.0 "@aws-sdk/types": 3.969.0 - "@aws-sdk/util-endpoints": 3.969.0 + "@aws-sdk/util-endpoints": 3.970.0 "@aws-sdk/util-user-agent-browser": 3.969.0 - "@aws-sdk/util-user-agent-node": 3.969.0 + "@aws-sdk/util-user-agent-node": 3.970.0 "@smithy/config-resolver": ^4.4.6 - "@smithy/core": ^3.20.5 + "@smithy/core": ^3.20.6 "@smithy/fetch-http-handler": ^5.3.9 "@smithy/hash-node": ^4.2.8 "@smithy/invalid-dependency": ^4.2.8 "@smithy/middleware-content-length": ^4.2.8 - "@smithy/middleware-endpoint": ^4.4.6 - "@smithy/middleware-retry": ^4.4.22 + "@smithy/middleware-endpoint": ^4.4.7 + "@smithy/middleware-retry": ^4.4.23 "@smithy/middleware-serde": ^4.2.9 "@smithy/middleware-stack": ^4.2.8 "@smithy/node-config-provider": ^4.3.8 "@smithy/node-http-handler": ^4.4.8 "@smithy/protocol-http": ^5.3.8 - "@smithy/smithy-client": ^4.10.7 + "@smithy/smithy-client": ^4.10.8 "@smithy/types": ^4.12.0 "@smithy/url-parser": ^4.2.8 "@smithy/util-base64": ^4.3.0 "@smithy/util-body-length-browser": ^4.2.0 "@smithy/util-body-length-node": ^4.2.1 - "@smithy/util-defaults-mode-browser": ^4.3.21 - "@smithy/util-defaults-mode-node": ^4.2.24 + "@smithy/util-defaults-mode-browser": ^4.3.22 + "@smithy/util-defaults-mode-node": ^4.2.25 "@smithy/util-endpoints": ^3.2.8 "@smithy/util-middleware": ^4.2.8 "@smithy/util-retry": ^4.2.8 "@smithy/util-utf8": ^4.2.0 "@smithy/util-waiter": ^4.2.8 tslib: ^2.6.2 - checksum: e96effc66ef270845eeb34c36e0e606e3f284b3d3c6caa981e13ab671d2f4e765e5b612115d3f7491007817c65a9019178892e029d74f72c4775ee9fdb7fcd80 + checksum: 45bb99212a3d04c3b696c0a8773a8880a6f7f2d17bb1abb6f4f6c39696805c12bfbe76a5351e0ce4c161c2b877385ae4c2585d79245e9273566570c0a9d77d47 languageName: node linkType: hard -"@aws-sdk/client-sso@npm:3.969.0": - version: 3.969.0 - resolution: "@aws-sdk/client-sso@npm:3.969.0" +"@aws-sdk/client-sso@npm:3.970.0": + version: 3.970.0 + resolution: "@aws-sdk/client-sso@npm:3.970.0" dependencies: "@aws-crypto/sha256-browser": 5.2.0 "@aws-crypto/sha256-js": 5.2.0 - "@aws-sdk/core": 3.969.0 + "@aws-sdk/core": 3.970.0 "@aws-sdk/middleware-host-header": 3.969.0 "@aws-sdk/middleware-logger": 3.969.0 "@aws-sdk/middleware-recursion-detection": 3.969.0 - "@aws-sdk/middleware-user-agent": 3.969.0 + "@aws-sdk/middleware-user-agent": 3.970.0 "@aws-sdk/region-config-resolver": 3.969.0 "@aws-sdk/types": 3.969.0 - "@aws-sdk/util-endpoints": 3.969.0 + "@aws-sdk/util-endpoints": 3.970.0 "@aws-sdk/util-user-agent-browser": 3.969.0 - "@aws-sdk/util-user-agent-node": 3.969.0 + "@aws-sdk/util-user-agent-node": 3.970.0 "@smithy/config-resolver": ^4.4.6 - "@smithy/core": ^3.20.5 + "@smithy/core": ^3.20.6 "@smithy/fetch-http-handler": ^5.3.9 "@smithy/hash-node": ^4.2.8 "@smithy/invalid-dependency": ^4.2.8 "@smithy/middleware-content-length": ^4.2.8 - "@smithy/middleware-endpoint": ^4.4.6 - "@smithy/middleware-retry": ^4.4.22 + "@smithy/middleware-endpoint": ^4.4.7 + "@smithy/middleware-retry": ^4.4.23 "@smithy/middleware-serde": ^4.2.9 "@smithy/middleware-stack": ^4.2.8 "@smithy/node-config-provider": ^4.3.8 "@smithy/node-http-handler": ^4.4.8 "@smithy/protocol-http": ^5.3.8 - "@smithy/smithy-client": ^4.10.7 + "@smithy/smithy-client": ^4.10.8 "@smithy/types": ^4.12.0 "@smithy/url-parser": ^4.2.8 "@smithy/util-base64": ^4.3.0 "@smithy/util-body-length-browser": ^4.2.0 "@smithy/util-body-length-node": ^4.2.1 - "@smithy/util-defaults-mode-browser": ^4.3.21 - "@smithy/util-defaults-mode-node": ^4.2.24 + "@smithy/util-defaults-mode-browser": ^4.3.22 + "@smithy/util-defaults-mode-node": ^4.2.25 "@smithy/util-endpoints": ^3.2.8 "@smithy/util-middleware": ^4.2.8 "@smithy/util-retry": ^4.2.8 "@smithy/util-utf8": ^4.2.0 tslib: ^2.6.2 - checksum: 46d86d52a326c1ff97ae466f55c204ce9bd3fdc1940ad2f4abc7b9909bf9d4ff2bd1335d6e56180ba0c99eade4f51154bd03b49125a8e47c6cadbf21e697afa2 + checksum: ccde46038d52db63464973f18945d8702a5639c6c30b567b5168ef2cfd63e804c7ace9d5cfd3ad287135283fc8744d2fed6510c2d1bd91ce8d8b3080c2fa0c1a languageName: node linkType: hard -"@aws-sdk/core@npm:3.969.0": - version: 3.969.0 - resolution: "@aws-sdk/core@npm:3.969.0" +"@aws-sdk/core@npm:3.970.0": + version: 3.970.0 + resolution: "@aws-sdk/core@npm:3.970.0" dependencies: "@aws-sdk/types": 3.969.0 "@aws-sdk/xml-builder": 3.969.0 - "@smithy/core": ^3.20.5 + "@smithy/core": ^3.20.6 "@smithy/node-config-provider": ^4.3.8 "@smithy/property-provider": ^4.2.8 "@smithy/protocol-http": ^5.3.8 "@smithy/signature-v4": ^5.3.8 - "@smithy/smithy-client": ^4.10.7 + "@smithy/smithy-client": ^4.10.8 "@smithy/types": ^4.12.0 "@smithy/util-base64": ^4.3.0 "@smithy/util-middleware": ^4.2.8 "@smithy/util-utf8": ^4.2.0 tslib: ^2.6.2 - checksum: 94682a2c72d89bc4450a1895e3a3fb7af5e5ae318dd722f35917463ffafa58539c91e5f7df485c867c5fcb6d0bf1465bc726f795e0cae652fb0b660d0709d266 + checksum: 488cebfcba46cc358180cd3e6775abc04748bcb7ac560bf646923b3f03a702f345baaee72995f8d2bce576194659e26e71230b86199f1949a729174d06dba29d languageName: node linkType: hard -"@aws-sdk/credential-provider-env@npm:3.969.0": - version: 3.969.0 - resolution: "@aws-sdk/credential-provider-env@npm:3.969.0" +"@aws-sdk/credential-provider-env@npm:3.970.0": + version: 3.970.0 + resolution: "@aws-sdk/credential-provider-env@npm:3.970.0" dependencies: - "@aws-sdk/core": 3.969.0 + "@aws-sdk/core": 3.970.0 "@aws-sdk/types": 3.969.0 "@smithy/property-provider": ^4.2.8 "@smithy/types": ^4.12.0 tslib: ^2.6.2 - checksum: 7f4c572b60351b4d2bb8b753c0a1abbacecb81bdac857a097d86e8725cd6d58381629a7f45a8d1d7503f974df496b5d5f0f562f368454166a5478d3d5dd5eac0 + checksum: 9a09ff769173e7e072b562c1c6d4b4229c60b340460e761aaef4e853fa5e2abdd2d8e218e270073bd692393e86b7d20a79fe94bf906acb9c3087f14da819eb69 languageName: node linkType: hard -"@aws-sdk/credential-provider-http@npm:3.969.0": - version: 3.969.0 - resolution: "@aws-sdk/credential-provider-http@npm:3.969.0" +"@aws-sdk/credential-provider-http@npm:3.970.0": + version: 3.970.0 + resolution: "@aws-sdk/credential-provider-http@npm:3.970.0" dependencies: - "@aws-sdk/core": 3.969.0 + "@aws-sdk/core": 3.970.0 "@aws-sdk/types": 3.969.0 "@smithy/fetch-http-handler": ^5.3.9 "@smithy/node-http-handler": ^4.4.8 "@smithy/property-provider": ^4.2.8 "@smithy/protocol-http": ^5.3.8 - "@smithy/smithy-client": ^4.10.7 + "@smithy/smithy-client": ^4.10.8 "@smithy/types": ^4.12.0 "@smithy/util-stream": ^4.5.10 tslib: ^2.6.2 - checksum: 99e80847506a78268ee86597315418b218a3d083f62a8b49bc2c54575db4c21a17b26add6c42e16a681821eb3f7875fc619470a5ba4e06f31feb7c94a4635806 + checksum: 2e19384aa425e281701e13f2eb3d9663ba87cd1a748dbf83b4451d07acfc6f5580fbaf64a27f185f3e11fda8d1852e8e0c8815877f935edd40bc1af2f13acb9e languageName: node linkType: hard -"@aws-sdk/credential-provider-ini@npm:3.969.0": - version: 3.969.0 - resolution: "@aws-sdk/credential-provider-ini@npm:3.969.0" - dependencies: - "@aws-sdk/core": 3.969.0 - "@aws-sdk/credential-provider-env": 3.969.0 - "@aws-sdk/credential-provider-http": 3.969.0 - "@aws-sdk/credential-provider-login": 3.969.0 - "@aws-sdk/credential-provider-process": 3.969.0 - "@aws-sdk/credential-provider-sso": 3.969.0 - "@aws-sdk/credential-provider-web-identity": 3.969.0 - "@aws-sdk/nested-clients": 3.969.0 +"@aws-sdk/credential-provider-ini@npm:3.970.0": + version: 3.970.0 + resolution: "@aws-sdk/credential-provider-ini@npm:3.970.0" + dependencies: + "@aws-sdk/core": 3.970.0 + "@aws-sdk/credential-provider-env": 3.970.0 + "@aws-sdk/credential-provider-http": 3.970.0 + "@aws-sdk/credential-provider-login": 3.970.0 + "@aws-sdk/credential-provider-process": 3.970.0 + "@aws-sdk/credential-provider-sso": 3.970.0 + "@aws-sdk/credential-provider-web-identity": 3.970.0 + "@aws-sdk/nested-clients": 3.970.0 "@aws-sdk/types": 3.969.0 "@smithy/credential-provider-imds": ^4.2.8 "@smithy/property-provider": ^4.2.8 "@smithy/shared-ini-file-loader": ^4.4.3 "@smithy/types": ^4.12.0 tslib: ^2.6.2 - checksum: b487707aad2ca40311089d64f108fa87c6e3c896ac4b8fc4fb4badd54e6430ab747d67a63760ff73231bb48dc06527b2e55fed57d4bea99cb89f1ee94a8d1b78 + checksum: 5bcd15fa7daa5778884eddcb2ddb54bc7a42d4e1d54dae311b6ba8f1095dd1c51e3db191f9bade0adf070d63d12b8679db15e06bf8ab423882c6a4c1f9a7ec8f languageName: node linkType: hard -"@aws-sdk/credential-provider-login@npm:3.969.0": - version: 3.969.0 - resolution: "@aws-sdk/credential-provider-login@npm:3.969.0" +"@aws-sdk/credential-provider-login@npm:3.970.0": + version: 3.970.0 + resolution: "@aws-sdk/credential-provider-login@npm:3.970.0" dependencies: - "@aws-sdk/core": 3.969.0 - "@aws-sdk/nested-clients": 3.969.0 + "@aws-sdk/core": 3.970.0 + "@aws-sdk/nested-clients": 3.970.0 "@aws-sdk/types": 3.969.0 "@smithy/property-provider": ^4.2.8 "@smithy/protocol-http": ^5.3.8 "@smithy/shared-ini-file-loader": ^4.4.3 "@smithy/types": ^4.12.0 tslib: ^2.6.2 - checksum: 1c5ce1f69b928d08b192d06f7e5f2efe7427e7f75e25d26e8a672fdbf432547853b1cb287754b4abd9a59db63b4988e46695ca5296e3a0eae1ead244924897da + checksum: ea7824ee3d0598b1e1ad5231ab3352cf74bc7a04a15c4ed5924847d0b804566afd3dfee9afd8ba1ffef1fea2e1c07da892e0b70728118d424b09965ea4d26c30 languageName: node linkType: hard -"@aws-sdk/credential-provider-node@npm:3.969.0": - version: 3.969.0 - resolution: "@aws-sdk/credential-provider-node@npm:3.969.0" - dependencies: - "@aws-sdk/credential-provider-env": 3.969.0 - "@aws-sdk/credential-provider-http": 3.969.0 - "@aws-sdk/credential-provider-ini": 3.969.0 - "@aws-sdk/credential-provider-process": 3.969.0 - "@aws-sdk/credential-provider-sso": 3.969.0 - "@aws-sdk/credential-provider-web-identity": 3.969.0 +"@aws-sdk/credential-provider-node@npm:3.970.0": + version: 3.970.0 + resolution: "@aws-sdk/credential-provider-node@npm:3.970.0" + dependencies: + "@aws-sdk/credential-provider-env": 3.970.0 + "@aws-sdk/credential-provider-http": 3.970.0 + "@aws-sdk/credential-provider-ini": 3.970.0 + "@aws-sdk/credential-provider-process": 3.970.0 + "@aws-sdk/credential-provider-sso": 3.970.0 + "@aws-sdk/credential-provider-web-identity": 3.970.0 "@aws-sdk/types": 3.969.0 "@smithy/credential-provider-imds": ^4.2.8 "@smithy/property-provider": ^4.2.8 "@smithy/shared-ini-file-loader": ^4.4.3 "@smithy/types": ^4.12.0 tslib: ^2.6.2 - checksum: 83585cb3c38a3fda7b8f90c15cd981d0bd15a72c0adf473b9b639630a4fa7e1e272004f99afc7df58c1325146e3aaba3db678a09e4f1eebfe8603d9650e43224 + checksum: c3fda0e63bc59e595ac3d1045e31ffa804be889cadd9e55a8d464f19f8ac533e73d305acddad0ecbb3bd1137d0d625cc7380a8cca4bd6ab2d5fe6c69bfb2964c languageName: node linkType: hard -"@aws-sdk/credential-provider-process@npm:3.969.0": - version: 3.969.0 - resolution: "@aws-sdk/credential-provider-process@npm:3.969.0" +"@aws-sdk/credential-provider-process@npm:3.970.0": + version: 3.970.0 + resolution: "@aws-sdk/credential-provider-process@npm:3.970.0" dependencies: - "@aws-sdk/core": 3.969.0 + "@aws-sdk/core": 3.970.0 "@aws-sdk/types": 3.969.0 "@smithy/property-provider": ^4.2.8 "@smithy/shared-ini-file-loader": ^4.4.3 "@smithy/types": ^4.12.0 tslib: ^2.6.2 - checksum: d8795f271d008cc139fb8bf8ec536f90b3cec9528fd078cb03bab093af154b7158ebe3f43a86a7b6214937bb44678f7d76a8b2f429a2c172a3ccf602748cee64 + checksum: 9d512edce1baa673cdf9001328296f9b8dc764316a07cb5534923b0f115916bc1e1cbe979f5c68ccf671056b27d5f32090efdcfad595d84de9678c50538c0a39 languageName: node linkType: hard -"@aws-sdk/credential-provider-sso@npm:3.969.0": - version: 3.969.0 - resolution: "@aws-sdk/credential-provider-sso@npm:3.969.0" +"@aws-sdk/credential-provider-sso@npm:3.970.0": + version: 3.970.0 + resolution: "@aws-sdk/credential-provider-sso@npm:3.970.0" dependencies: - "@aws-sdk/client-sso": 3.969.0 - "@aws-sdk/core": 3.969.0 - "@aws-sdk/token-providers": 3.969.0 + "@aws-sdk/client-sso": 3.970.0 + "@aws-sdk/core": 3.970.0 + "@aws-sdk/token-providers": 3.970.0 "@aws-sdk/types": 3.969.0 "@smithy/property-provider": ^4.2.8 "@smithy/shared-ini-file-loader": ^4.4.3 "@smithy/types": ^4.12.0 tslib: ^2.6.2 - checksum: b6be5d87a0e6cae79dea0e61ac1ebca01c2d85f891c8e0256bf39fea982c57e67074233190d3abeb03a0ff677d444bb0c80b64efd9ef41e33fc3437450245046 + checksum: afe96c44fa68c1ddcb3416c7ef0161203f027a0e3c7568407041ef4b4d6d6af64c4da2f2df53dbdb35778d55f2013b761a57489aec9522d36c7c7c315671e993 languageName: node linkType: hard -"@aws-sdk/credential-provider-web-identity@npm:3.969.0": - version: 3.969.0 - resolution: "@aws-sdk/credential-provider-web-identity@npm:3.969.0" +"@aws-sdk/credential-provider-web-identity@npm:3.970.0": + version: 3.970.0 + resolution: "@aws-sdk/credential-provider-web-identity@npm:3.970.0" dependencies: - "@aws-sdk/core": 3.969.0 - "@aws-sdk/nested-clients": 3.969.0 + "@aws-sdk/core": 3.970.0 + "@aws-sdk/nested-clients": 3.970.0 "@aws-sdk/types": 3.969.0 "@smithy/property-provider": ^4.2.8 "@smithy/shared-ini-file-loader": ^4.4.3 "@smithy/types": ^4.12.0 tslib: ^2.6.2 - checksum: f9b1dae637bf94b67aa7845008ee1825eea793cdb68bc92429a2594044ea8c71d9cb7e2a5a0f0344a35349e865e44f597a87b5d801e25f5001a293dd6005ab04 + checksum: b161d7143266ab4fe8da0d776a3afa7ed56f2e67c6e38bc4f8f5e3e75afa86f83b5ef4281b563316498ca3e35c374710047e2c2fbd5b785c1da58795b6a44ce8 languageName: node linkType: hard @@ -363,64 +363,64 @@ __metadata: languageName: node linkType: hard -"@aws-sdk/middleware-user-agent@npm:3.969.0": - version: 3.969.0 - resolution: "@aws-sdk/middleware-user-agent@npm:3.969.0" +"@aws-sdk/middleware-user-agent@npm:3.970.0": + version: 3.970.0 + resolution: "@aws-sdk/middleware-user-agent@npm:3.970.0" dependencies: - "@aws-sdk/core": 3.969.0 + "@aws-sdk/core": 3.970.0 "@aws-sdk/types": 3.969.0 - "@aws-sdk/util-endpoints": 3.969.0 - "@smithy/core": ^3.20.5 + "@aws-sdk/util-endpoints": 3.970.0 + "@smithy/core": ^3.20.6 "@smithy/protocol-http": ^5.3.8 "@smithy/types": ^4.12.0 tslib: ^2.6.2 - checksum: 6f393e2e2ef948eb4f9e0193d9c664fd629cd228ca30a5f2439d119f23c49b8211b90cb7eaddd7f723a3e53c04572544d538716fdd8e92bf9287d5e086a09347 + checksum: 95debd5ad3ee103f2dca89defcb64f877ee44789c99cf61bb0e4cbcae8974596b46495077effec287187c249c90202bbfb689c98b4d2d790d4119a18dff45062 languageName: node linkType: hard -"@aws-sdk/nested-clients@npm:3.969.0": - version: 3.969.0 - resolution: "@aws-sdk/nested-clients@npm:3.969.0" +"@aws-sdk/nested-clients@npm:3.970.0": + version: 3.970.0 + resolution: "@aws-sdk/nested-clients@npm:3.970.0" dependencies: "@aws-crypto/sha256-browser": 5.2.0 "@aws-crypto/sha256-js": 5.2.0 - "@aws-sdk/core": 3.969.0 + "@aws-sdk/core": 3.970.0 "@aws-sdk/middleware-host-header": 3.969.0 "@aws-sdk/middleware-logger": 3.969.0 "@aws-sdk/middleware-recursion-detection": 3.969.0 - "@aws-sdk/middleware-user-agent": 3.969.0 + "@aws-sdk/middleware-user-agent": 3.970.0 "@aws-sdk/region-config-resolver": 3.969.0 "@aws-sdk/types": 3.969.0 - "@aws-sdk/util-endpoints": 3.969.0 + "@aws-sdk/util-endpoints": 3.970.0 "@aws-sdk/util-user-agent-browser": 3.969.0 - "@aws-sdk/util-user-agent-node": 3.969.0 + "@aws-sdk/util-user-agent-node": 3.970.0 "@smithy/config-resolver": ^4.4.6 - "@smithy/core": ^3.20.5 + "@smithy/core": ^3.20.6 "@smithy/fetch-http-handler": ^5.3.9 "@smithy/hash-node": ^4.2.8 "@smithy/invalid-dependency": ^4.2.8 "@smithy/middleware-content-length": ^4.2.8 - "@smithy/middleware-endpoint": ^4.4.6 - "@smithy/middleware-retry": ^4.4.22 + "@smithy/middleware-endpoint": ^4.4.7 + "@smithy/middleware-retry": ^4.4.23 "@smithy/middleware-serde": ^4.2.9 "@smithy/middleware-stack": ^4.2.8 "@smithy/node-config-provider": ^4.3.8 "@smithy/node-http-handler": ^4.4.8 "@smithy/protocol-http": ^5.3.8 - "@smithy/smithy-client": ^4.10.7 + "@smithy/smithy-client": ^4.10.8 "@smithy/types": ^4.12.0 "@smithy/url-parser": ^4.2.8 "@smithy/util-base64": ^4.3.0 "@smithy/util-body-length-browser": ^4.2.0 "@smithy/util-body-length-node": ^4.2.1 - "@smithy/util-defaults-mode-browser": ^4.3.21 - "@smithy/util-defaults-mode-node": ^4.2.24 + "@smithy/util-defaults-mode-browser": ^4.3.22 + "@smithy/util-defaults-mode-node": ^4.2.25 "@smithy/util-endpoints": ^3.2.8 "@smithy/util-middleware": ^4.2.8 "@smithy/util-retry": ^4.2.8 "@smithy/util-utf8": ^4.2.0 tslib: ^2.6.2 - checksum: 1ed1614292be8c3b18e46ef049d9f1264216c3178a2e418360b0e014e126b17c7afb0460827eafb3453f2b0e81438a245c37933392f5f14240de349e272e6592 + checksum: 902ddb2b606d13c6dc29ad3c9001dc29320c520589ce3998f87f2a100d2d1fcd97536a4059e3a8a1273a2ec714f182d748df79a5946cd2f81f83bfcb46fe6977 languageName: node linkType: hard @@ -437,18 +437,18 @@ __metadata: languageName: node linkType: hard -"@aws-sdk/token-providers@npm:3.969.0": - version: 3.969.0 - resolution: "@aws-sdk/token-providers@npm:3.969.0" +"@aws-sdk/token-providers@npm:3.970.0": + version: 3.970.0 + resolution: "@aws-sdk/token-providers@npm:3.970.0" dependencies: - "@aws-sdk/core": 3.969.0 - "@aws-sdk/nested-clients": 3.969.0 + "@aws-sdk/core": 3.970.0 + "@aws-sdk/nested-clients": 3.970.0 "@aws-sdk/types": 3.969.0 "@smithy/property-provider": ^4.2.8 "@smithy/shared-ini-file-loader": ^4.4.3 "@smithy/types": ^4.12.0 tslib: ^2.6.2 - checksum: 38ec6288c045d00e840e036ebc55cd549fc56fc4ed18c37f75bebb8c6ecf660ed8915e56074ba2d4c333c772a9c39d8bd15496c429873753ebd4105cd0460c09 + checksum: cba18f898ffd8baccd911ff4e478f1297a98c8ab58a5653519d467085eaf0c53bb4b38a1e299ae9ccb5b9b13d04caa6d8df7c6515e38f3fc93623f81ee79fafa languageName: node linkType: hard @@ -462,16 +462,16 @@ __metadata: languageName: node linkType: hard -"@aws-sdk/util-endpoints@npm:3.969.0": - version: 3.969.0 - resolution: "@aws-sdk/util-endpoints@npm:3.969.0" +"@aws-sdk/util-endpoints@npm:3.970.0": + version: 3.970.0 + resolution: "@aws-sdk/util-endpoints@npm:3.970.0" dependencies: "@aws-sdk/types": 3.969.0 "@smithy/types": ^4.12.0 "@smithy/url-parser": ^4.2.8 "@smithy/util-endpoints": ^3.2.8 tslib: ^2.6.2 - checksum: ca03191cea0bf8760e138dea0a05a883f11b5835422d3df74cef7a0a8e54f3ee245ceac6cd254500164e36884ea767885b1d7fed63bf903028a5f3cdbc580cc0 + checksum: 37fbc5e76b597b653b1deaa6fbba84450cf1faf9ef878610ad7260783593ff14e081d50306f559574ee056ab3c85c493c097d93c2b9c15ce2bf30d0ed04e16a4 languageName: node linkType: hard @@ -496,11 +496,11 @@ __metadata: languageName: node linkType: hard -"@aws-sdk/util-user-agent-node@npm:3.969.0": - version: 3.969.0 - resolution: "@aws-sdk/util-user-agent-node@npm:3.969.0" +"@aws-sdk/util-user-agent-node@npm:3.970.0": + version: 3.970.0 + resolution: "@aws-sdk/util-user-agent-node@npm:3.970.0" dependencies: - "@aws-sdk/middleware-user-agent": 3.969.0 + "@aws-sdk/middleware-user-agent": 3.970.0 "@aws-sdk/types": 3.969.0 "@smithy/node-config-provider": ^4.3.8 "@smithy/types": ^4.12.0 @@ -510,7 +510,7 @@ __metadata: peerDependenciesMeta: aws-crt: optional: true - checksum: 1764dd8e2be8c23e552be105c1d41810f8057a48dc7d908434ebca840cb938256a41582aaa1906361b31ee3ecf15ecfb8b6e93a9fb75e51145fe5cbaa06b8eaf + checksum: fef0ec2e6b07b419b0efd786004f3a668754a44995baca34971d4037cabbb47357f49f2d194d2d1adc4642d503c12237dc0aa89653397668fe93a47334b92fb9 languageName: node linkType: hard @@ -4916,9 +4916,9 @@ __metadata: languageName: node linkType: hard -"@smithy/core@npm:^3.20.5": - version: 3.20.5 - resolution: "@smithy/core@npm:3.20.5" +"@smithy/core@npm:^3.20.6": + version: 3.20.6 + resolution: "@smithy/core@npm:3.20.6" dependencies: "@smithy/middleware-serde": ^4.2.9 "@smithy/protocol-http": ^5.3.8 @@ -4930,7 +4930,7 @@ __metadata: "@smithy/util-utf8": ^4.2.0 "@smithy/uuid": ^1.1.0 tslib: ^2.6.2 - checksum: 8d0343f98383fffbb6e0147205af5d3620f483e00ad423f8731f84d4cd60b73d09f3521a18d4369c7e30227f49dc7773b406cfffe7d8268540866eb83b89ea93 + checksum: c280fd797faa3da0461fded5ced6ccd1feb6ce9693ee96ce34df593253c786e2deb8b19e920ebc61106bc463c9ff031e8c99bef198b07cb883f1ed4606b8cad0 languageName: node linkType: hard @@ -5011,11 +5011,11 @@ __metadata: languageName: node linkType: hard -"@smithy/middleware-endpoint@npm:^4.4.6": - version: 4.4.6 - resolution: "@smithy/middleware-endpoint@npm:4.4.6" +"@smithy/middleware-endpoint@npm:^4.4.7": + version: 4.4.7 + resolution: "@smithy/middleware-endpoint@npm:4.4.7" dependencies: - "@smithy/core": ^3.20.5 + "@smithy/core": ^3.20.6 "@smithy/middleware-serde": ^4.2.9 "@smithy/node-config-provider": ^4.3.8 "@smithy/shared-ini-file-loader": ^4.4.3 @@ -5023,24 +5023,24 @@ __metadata: "@smithy/url-parser": ^4.2.8 "@smithy/util-middleware": ^4.2.8 tslib: ^2.6.2 - checksum: 404182c0640ed2f0e950da07712d108ee88ccee785b97715587786ffe04ed46fd573506842396aa41de69b20174b4c4dcbe8658c2898261fa4dd837f53861104 + checksum: e2dcdca39837bd03a825abfab46279ad3141a8b264e3d375da7e6e2da945b6a4e7d4a2a80d90e271c191c13d0f1d6307634ac89ff31a68f0a2722866299ea9c5 languageName: node linkType: hard -"@smithy/middleware-retry@npm:^4.4.22": - version: 4.4.22 - resolution: "@smithy/middleware-retry@npm:4.4.22" +"@smithy/middleware-retry@npm:^4.4.23": + version: 4.4.23 + resolution: "@smithy/middleware-retry@npm:4.4.23" dependencies: "@smithy/node-config-provider": ^4.3.8 "@smithy/protocol-http": ^5.3.8 "@smithy/service-error-classification": ^4.2.8 - "@smithy/smithy-client": ^4.10.7 + "@smithy/smithy-client": ^4.10.8 "@smithy/types": ^4.12.0 "@smithy/util-middleware": ^4.2.8 "@smithy/util-retry": ^4.2.8 "@smithy/uuid": ^1.1.0 tslib: ^2.6.2 - checksum: 8fe2b8beff19dc3f2321fa47d83cef845c691a008a786e2c770bd1479b2753cf6424f36d7564c4e4e7ce5cd0415f8e5fded50710b7ce8390f533f884e1c42ed3 + checksum: d32212067eaa0f07bdcd40e25ec825a743163947fb7c100b836edd28ef862ccb904a7ee5a0ed39871fac66c94919e4777012b7014c32b96c48bc7b72b7c4bae8 languageName: node linkType: hard @@ -5166,18 +5166,18 @@ __metadata: languageName: node linkType: hard -"@smithy/smithy-client@npm:^4.10.7": - version: 4.10.7 - resolution: "@smithy/smithy-client@npm:4.10.7" +"@smithy/smithy-client@npm:^4.10.8": + version: 4.10.8 + resolution: "@smithy/smithy-client@npm:4.10.8" dependencies: - "@smithy/core": ^3.20.5 - "@smithy/middleware-endpoint": ^4.4.6 + "@smithy/core": ^3.20.6 + "@smithy/middleware-endpoint": ^4.4.7 "@smithy/middleware-stack": ^4.2.8 "@smithy/protocol-http": ^5.3.8 "@smithy/types": ^4.12.0 "@smithy/util-stream": ^4.5.10 tslib: ^2.6.2 - checksum: cde3a8f63db56f0edbee629d050e61c55b937dce6bb971c91d642336e6526c48448dc5186907c3ce59f0999144aff2fee4f76181397d16683f6b51661178a26c + checksum: 665ba27bd0bf1c5fc85150959968e7be334d75293eb6d55c9dc8367246c734b903e46a30105ec91597e31a964210449fb8d9f2f53d853e8420895f35eb9e8f30 languageName: node linkType: hard @@ -5259,30 +5259,30 @@ __metadata: languageName: node linkType: hard -"@smithy/util-defaults-mode-browser@npm:^4.3.21": - version: 4.3.21 - resolution: "@smithy/util-defaults-mode-browser@npm:4.3.21" +"@smithy/util-defaults-mode-browser@npm:^4.3.22": + version: 4.3.22 + resolution: "@smithy/util-defaults-mode-browser@npm:4.3.22" dependencies: "@smithy/property-provider": ^4.2.8 - "@smithy/smithy-client": ^4.10.7 + "@smithy/smithy-client": ^4.10.8 "@smithy/types": ^4.12.0 tslib: ^2.6.2 - checksum: 89e8158d571fd5070feb2eb0c2200120fb5fe672b5f92f4995237cd5067a5fffcc038cf6c535dd8fd24dd638a30a0e277530cb2a7453a881fb1c47b96d258a13 + checksum: 2665e963431c505ffa14ea883f24c3336a6739b213524ff97268756a95d62f6ffb4e8f6c56254d0588a2a50e3f668716060d0aae1209f6fdb3bf0004eb942bb1 languageName: node linkType: hard -"@smithy/util-defaults-mode-node@npm:^4.2.24": - version: 4.2.24 - resolution: "@smithy/util-defaults-mode-node@npm:4.2.24" +"@smithy/util-defaults-mode-node@npm:^4.2.25": + version: 4.2.25 + resolution: "@smithy/util-defaults-mode-node@npm:4.2.25" dependencies: "@smithy/config-resolver": ^4.4.6 "@smithy/credential-provider-imds": ^4.2.8 "@smithy/node-config-provider": ^4.3.8 "@smithy/property-provider": ^4.2.8 - "@smithy/smithy-client": ^4.10.7 + "@smithy/smithy-client": ^4.10.8 "@smithy/types": ^4.12.0 tslib: ^2.6.2 - checksum: d86287d71e09bddc2fc8645e36d74f433b979d913cd6cee7dd1c2a637b731a781352336bd687187f3cccd9c98b5974a74f11ca3b409ae4c3fbac2a8fed30c249 + checksum: dc7f3fb2c6fa4bd4f63ad0b14b7db3c734595339536e3c1f8b02b27fa57656289f68089e8f0a7e8d103f1cbb6b3d07beaf614ba49143543680f222e5bd0ea346 languageName: node linkType: hard @@ -6184,11 +6184,11 @@ __metadata: linkType: hard "@types/node@npm:*, @types/node@npm:>=18.0.0": - version: 25.0.8 - resolution: "@types/node@npm:25.0.8" + version: 25.0.9 + resolution: "@types/node@npm:25.0.9" dependencies: undici-types: ~7.16.0 - checksum: 26348643a121bd07048dad55b60fcda5ba2e6661708ce88b8ec13a29521d0d4bd5f374d11be2d3cec9ae306b98a245b2ae226b9166c997362b788c0b032ff333 + checksum: 0dd245ed1823d32851007da980319af17f9794b0fe4b6b46093cee185e4c3ea033162238d2dc053b4474c1eeb534d47d2679a92907b5e7a27dac0938cb250c7a languageName: node linkType: hard @@ -6214,11 +6214,11 @@ __metadata: linkType: hard "@types/node@npm:^20.0.0": - version: 20.19.29 - resolution: "@types/node@npm:20.19.29" + version: 20.19.30 + resolution: "@types/node@npm:20.19.30" dependencies: undici-types: ~6.21.0 - checksum: 326bbcb9dbc32b7bb0ff04f9b20a9d6bbc8cfcbdcffb122997d79b8945b2f17c644090aae3c68084c448d502a8c8d87b9cb367722974ea881e78356622e2b765 + checksum: 32e80a104b8fcdcacc52a98f0a197a33fd264325501f9af1653cbe9c07fdb6d11130cb0111c65fd353112e6a5af1e5f106714411b1b252d06f0f58760153a1e3 languageName: node linkType: hard @@ -20462,8 +20462,8 @@ __metadata: linkType: hard "terser@npm:^5.0.0, terser@npm:^5.10.0, terser@npm:^5.31.1": - version: 5.44.1 - resolution: "terser@npm:5.44.1" + version: 5.46.0 + resolution: "terser@npm:5.46.0" dependencies: "@jridgewell/source-map": ^0.3.3 acorn: ^8.15.0 @@ -20471,7 +20471,7 @@ __metadata: source-map-support: ~0.5.20 bin: terser: bin/terser - checksum: 1113c5711bb53127f9886e3c906fde8a93a665b532db9c7e36ff7bf287e032ed48ea0e5a3a1a27f6a27c3c0f934e47e7590fcd15c76b7b7bd44ad751b8a9ede4 + checksum: 39d28f3723e84e80ddb4576a441adb12a6d365258fb9262e25f8b6d1e4514954e81f711008ee2ad9927f00b860a5bcbd4c1db7a6873d0f712bdcc667fb7b7557 languageName: node linkType: hard