diff --git a/src/backend/src/controllers/users.controllers.ts b/src/backend/src/controllers/users.controllers.ts index 8076e225d7..bf965c8d8e 100644 --- a/src/backend/src/controllers/users.controllers.ts +++ b/src/backend/src/controllers/users.controllers.ts @@ -191,4 +191,29 @@ export default class UsersController { next(error); } } + + static async getUserUnreadNotifications(req: Request, res: Response, next: NextFunction) { + try { + const { userId } = req.params; + const { organization } = req; + + const unreadNotifications = await UsersService.getUserUnreadNotifications(userId, organization); + res.status(200).json(unreadNotifications); + } catch (error: unknown) { + next(error); + } + } + + static async removeUserNotification(req: Request, res: Response, next: NextFunction) { + try { + const { userId } = req.params; + const { notificationId } = req.body; + const { organization } = req; + + const unreadNotifications = await UsersService.removeUserNotification(userId, notificationId, organization); + res.status(200).json(unreadNotifications); + } catch (error: unknown) { + next(error); + } + } } diff --git a/src/backend/src/prisma/migrations/20240910005616_add_logo_image_featured_project/migration.sql b/src/backend/src/prisma/migrations/20240910005616_add_logo_image_featured_project/migration.sql deleted file mode 100644 index 2a3f594835..0000000000 --- a/src/backend/src/prisma/migrations/20240910005616_add_logo_image_featured_project/migration.sql +++ /dev/null @@ -1,8 +0,0 @@ --- AlterTable -ALTER TABLE "Organization" ADD COLUMN "logoImageId" TEXT; - --- AlterTable -ALTER TABLE "Project" ADD COLUMN "organizationId" TEXT; - --- AddForeignKey -ALTER TABLE "Project" ADD CONSTRAINT "Project_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "Organization"("organizationId") ON DELETE SET NULL ON UPDATE CASCADE; diff --git a/src/backend/src/prisma/migrations/20241211195435_announcements_and_notifications/migration.sql b/src/backend/src/prisma/migrations/20241218031222_home_page_updates/migration.sql similarity index 84% rename from src/backend/src/prisma/migrations/20241211195435_announcements_and_notifications/migration.sql rename to src/backend/src/prisma/migrations/20241218031222_home_page_updates/migration.sql index c57afabd25..c7975f2e21 100644 --- a/src/backend/src/prisma/migrations/20241211195435_announcements_and_notifications/migration.sql +++ b/src/backend/src/prisma/migrations/20241218031222_home_page_updates/migration.sql @@ -1,3 +1,9 @@ +-- AlterTable +ALTER TABLE "Organization" ADD COLUMN "logoImageId" TEXT; + +-- AlterTable +ALTER TABLE "Project" ADD COLUMN "organizationId" TEXT; + -- CreateTable CREATE TABLE "Announcement" ( "announcementId" TEXT NOT NULL, @@ -13,6 +19,7 @@ CREATE TABLE "Notification" ( "notificationId" TEXT NOT NULL, "text" TEXT NOT NULL, "iconName" TEXT NOT NULL, + "eventLink" TEXT, CONSTRAINT "Notification_pkey" PRIMARY KEY ("notificationId") ); @@ -41,6 +48,9 @@ CREATE UNIQUE INDEX "_userNotifications_AB_unique" ON "_userNotifications"("A", -- CreateIndex CREATE INDEX "_userNotifications_B_index" ON "_userNotifications"("B"); +-- AddForeignKey +ALTER TABLE "Project" ADD CONSTRAINT "Project_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "Organization"("organizationId") ON DELETE SET NULL ON UPDATE CASCADE; + -- AddForeignKey ALTER TABLE "Announcement" ADD CONSTRAINT "Announcement_userCreatedId_fkey" FOREIGN KEY ("userCreatedId") REFERENCES "User"("userId") ON DELETE RESTRICT ON UPDATE CASCADE; diff --git a/src/backend/src/prisma/schema.prisma b/src/backend/src/prisma/schema.prisma index c06ca3d45f..e0157c3353 100644 --- a/src/backend/src/prisma/schema.prisma +++ b/src/backend/src/prisma/schema.prisma @@ -941,8 +941,9 @@ model Announcement { } model Notification { - notificationId String @id @default(uuid()) + notificationId String @id @default(uuid()) text String iconName String - users User[] @relation("userNotifications") + users User[] @relation("userNotifications") + eventLink String? } diff --git a/src/backend/src/prisma/seed.ts b/src/backend/src/prisma/seed.ts index a30e502baf..fdb5ffefdb 100644 --- a/src/backend/src/prisma/seed.ts +++ b/src/backend/src/prisma/seed.ts @@ -33,6 +33,7 @@ import { writeFileSync } from 'fs'; import WorkPackageTemplatesService from '../services/work-package-template.services'; import RecruitmentServices from '../services/recruitment.services'; import OrganizationsService from '../services/organizations.services'; +import NotificationsService from '../services/notifications.services'; const prisma = new PrismaClient(); diff --git a/src/backend/src/routes/users.routes.ts b/src/backend/src/routes/users.routes.ts index 2f95201f6f..622a6fb01c 100644 --- a/src/backend/src/routes/users.routes.ts +++ b/src/backend/src/routes/users.routes.ts @@ -54,5 +54,11 @@ userRouter.post( validateInputs, UsersController.getManyUserTasks ); +userRouter.get('/:userId/notifications', UsersController.getUserUnreadNotifications); +userRouter.post( + '/:userId/notifications/remove', + nonEmptyString(body('notificationId')), + UsersController.removeUserNotification +); export default userRouter; diff --git a/src/backend/src/services/change-requests.services.ts b/src/backend/src/services/change-requests.services.ts index 89cbaaa370..85e0dc5579 100644 --- a/src/backend/src/services/change-requests.services.ts +++ b/src/backend/src/services/change-requests.services.ts @@ -46,6 +46,7 @@ import { import { ChangeRequestQueryArgs, getChangeRequestQueryArgs } from '../prisma-query-args/change-requests.query-args'; import proposedSolutionTransformer from '../transformers/proposed-solutions.transformer'; import { getProposedSolutionQueryArgs } from '../prisma-query-args/proposed-solutions.query-args'; +import { sendHomeCrRequestReviewNotification, sendHomeCrReviewedNotification } from '../utils/notifications.utils'; export default class ChangeRequestsService { /** @@ -150,6 +151,8 @@ export default class ChangeRequestsService { // send a notification to the submitter that their change request has been reviewed await sendCRSubmitterReviewedNotification(updated); + await sendHomeCrReviewedNotification(foundCR, updated.submitter, accepted, organization.organizationId); + // send a reply to a CR's notifications of its updated status await sendSlackCRStatusToThread(updated.notificationSlackThreads, foundCR.crId, foundCR.identifier, accepted); @@ -1078,5 +1081,7 @@ export default class ChangeRequestsService { // send slack message to CR reviewers await sendSlackRequestedReviewNotification(newReviewers, changeRequestTransformer(foundCR)); + + await sendHomeCrRequestReviewNotification(foundCR, newReviewers, organization.organizationId); } } diff --git a/src/backend/src/services/design-reviews.services.ts b/src/backend/src/services/design-reviews.services.ts index 8f7511b73c..644903fa43 100644 --- a/src/backend/src/services/design-reviews.services.ts +++ b/src/backend/src/services/design-reviews.services.ts @@ -39,6 +39,7 @@ import { getWorkPackageQueryArgs } from '../prisma-query-args/work-packages.quer import { UserWithSettings } from '../utils/auth.utils'; import { getUserScheduleSettingsQueryArgs } from '../prisma-query-args/user.query-args'; import { createCalendarEvent, deleteCalendarEvent, updateCalendarEvent } from '../utils/google-integration.utils'; +import { sendHomeDrNotification } from '../utils/notifications.utils'; export default class DesignReviewsService { /** @@ -205,6 +206,8 @@ export default class DesignReviewsService { } } + await sendHomeDrNotification(designReview, members, submitter, wbsElement.name, organization.organizationId); + const project = wbsElement.workPackage?.project; const teams = project?.teams; if (teams && teams.length > 0) { diff --git a/src/backend/src/services/notifications.services.ts b/src/backend/src/services/notifications.services.ts index 483e6ed9d6..e0617301f5 100644 --- a/src/backend/src/services/notifications.services.ts +++ b/src/backend/src/services/notifications.services.ts @@ -202,13 +202,21 @@ export default class NotificationsService { * @param iconName icon that appears in the notification * @param userIds ids of users to send the notification to * @param organizationId + * @param eventLink link the notification will go to when clicked * @returns the created notification */ - static async sendNotifcationToUsers(text: string, iconName: string, userIds: string[], organizationId: string) { + static async sendNotifcationToUsers( + text: string, + iconName: string, + userIds: string[], + organizationId: string, + eventLink?: string + ) { const createdNotification = await prisma.notification.create({ data: { text, - iconName + iconName, + eventLink }, ...getNotificationQueryArgs(organizationId) }); diff --git a/src/backend/src/services/users.services.ts b/src/backend/src/services/users.services.ts index d786c04137..1358ca5f6c 100644 --- a/src/backend/src/services/users.services.ts +++ b/src/backend/src/services/users.services.ts @@ -38,6 +38,8 @@ import { getAuthUserQueryArgs } from '../prisma-query-args/auth-user.query-args' import authenticatedUserTransformer from '../transformers/auth-user.transformer'; import { getTaskQueryArgs } from '../prisma-query-args/tasks.query-args'; import taskTransformer from '../transformers/tasks.transformer'; +import { getNotificationQueryArgs } from '../prisma-query-args/notifications.query-args'; +import notificationTransformer from '../transformers/notifications.transformer'; export default class UsersService { /** @@ -566,4 +568,49 @@ export default class UsersService { const resolvedTasks = await Promise.all(tasksPromises); return resolvedTasks.flat(); } + + /** + * Gets all of a user's unread notifications + * @param userId id of user to get unread notifications from + * @param organization the user's orgainzation + * @returns the unread notifications of the user + */ + static async getUserUnreadNotifications(userId: string, organization: Organization) { + const requestedUser = await prisma.user.findUnique({ + where: { userId }, + include: { unreadNotifications: getNotificationQueryArgs(organization.organizationId) } + }); + if (!requestedUser) throw new NotFoundException('User', userId); + + return requestedUser.unreadNotifications.map(notificationTransformer); + } + + /** + * Removes a notification from the user's unread notifications + * @param userId id of the user to remove notification from + * @param notificationId id of the notification to remove + * @param organization the user's organization + * @returns the user's updated unread notifications + */ + static async removeUserNotification(userId: string, notificationId: string, organization: Organization) { + const requestedUser = await prisma.user.findUnique({ + where: { userId } + }); + + if (!requestedUser) throw new NotFoundException('User', userId); + + const updatedUser = await prisma.user.update({ + where: { userId }, + data: { + unreadNotifications: { + disconnect: { + notificationId + } + } + }, + include: { unreadNotifications: getNotificationQueryArgs(organization.organizationId) } + }); + + return updatedUser.unreadNotifications.map(notificationTransformer); + } } diff --git a/src/backend/src/transformers/notifications.transformer.ts b/src/backend/src/transformers/notifications.transformer.ts index 32666b151a..45dd25dee9 100644 --- a/src/backend/src/transformers/notifications.transformer.ts +++ b/src/backend/src/transformers/notifications.transformer.ts @@ -6,7 +6,8 @@ const notificationTransformer = (notification: Prisma.NotificationGetPayload { endOfDay.setDate(startOfDay.getDate() + 1); return endOfDay; }; + +/** + * Sends a finishline notification 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 + */ +export const sendHomeDrNotification = async ( + designReview: Design_Review, + members: User[], + submitter: User, + workPackageName: string, + organizationId: string +) => { + const designReviewLink = `/settings/preferences?drId=${designReview.designReviewId}`; + + const msg = `Design Review for ${workPackageName} is being scheduled by ${submitter.firstName} ${submitter.lastName}`; + await NotificationsService.sendNotifcationToUsers( + msg, + 'calendar_month', + members.map((member) => member.userId), + organizationId, + designReviewLink + ); +}; + +/** + * Sends a finishline notification that a change request was reviewed + * @param changeRequest cr that was requested review + * @param submitter the user who submitted the cr + * @param accepted true if the cr changes were accepted, false if denied + * @param organizationId id of the organization of the cr + */ +export const sendHomeCrReviewedNotification = async ( + changeRequest: Change_Request, + submitter: User, + accepted: boolean, + organizationId: string +) => { + const isProd = process.env.NODE_ENV === 'production'; + + const changeRequestLink = isProd + ? `https://finishlinebyner.com/change-requests/${changeRequest.crId}` + : `http://localhost:3000/change-requests/${changeRequest.crId}`; + await NotificationsService.sendNotifcationToUsers( + `CR #${changeRequest.identifier} has been ${accepted ? 'approved!' : 'denied.'}`, + accepted ? 'check_circle' : 'cancel', + [submitter.userId], + organizationId, + changeRequestLink + ); +}; + +/** + * Sends a finishline notification to all requested reviewers of a change request + * @param changeRequest cr that was requested review + * @param reviewers user's reviewing the cr + * @param organizationId id of the organization of the cr + */ +export const sendHomeCrRequestReviewNotification = async ( + changeRequest: Change_Request, + reviewers: User[], + organizationId: string +) => { + const changeRequestLink = `/change-requests/${changeRequest.crId}`; + await NotificationsService.sendNotifcationToUsers( + `Your review has been requested on CR #${changeRequest.identifier}`, + 'edit_note', + reviewers.map((reviewer) => reviewer.userId), + organizationId, + changeRequestLink + ); +}; diff --git a/src/backend/tests/unmocked/users.test.ts b/src/backend/tests/unmocked/users.test.ts index c13a0c857f..512a651b90 100644 --- a/src/backend/tests/unmocked/users.test.ts +++ b/src/backend/tests/unmocked/users.test.ts @@ -3,6 +3,7 @@ import { createTestOrganization, createTestTask, createTestUser, resetUsers } fr import { batmanAppAdmin } from '../test-data/users.test-data'; import UsersService from '../../src/services/users.services'; import { NotFoundException } from '../../src/utils/errors.utils'; +import NotificationsService from '../../src/services/notifications.services'; describe('User Tests', () => { let orgId: string; @@ -48,4 +49,57 @@ describe('User Tests', () => { expect(userTasks).toStrictEqual([batmanTask, batmanTask]); }); }); + + describe('Get Notifications', () => { + it('fails on invalid user id', async () => { + await expect(async () => await UsersService.getUserUnreadNotifications('1', organization)).rejects.toThrow( + new NotFoundException('User', '1') + ); + }); + + it('Succeeds and gets user notifications', async () => { + const testBatman = await createTestUser(batmanAppAdmin, orgId); + await NotificationsService.sendNotifcationToUsers('test1', 'test1', [testBatman.userId], orgId); + await NotificationsService.sendNotifcationToUsers('test2', 'test2', [testBatman.userId], orgId); + + const notifications = await UsersService.getUserUnreadNotifications(testBatman.userId, organization); + + expect(notifications).toHaveLength(2); + expect(notifications[0].text).toBe('test1'); + expect(notifications[1].text).toBe('test2'); + }); + }); + + describe('Remove Notifications', () => { + it('Fails with invalid user', async () => { + const testBatman = await createTestUser(batmanAppAdmin, orgId); + await NotificationsService.sendNotifcationToUsers('test1', 'test1', [testBatman.userId], orgId); + const notifications = await UsersService.getUserUnreadNotifications(testBatman.userId, organization); + + await expect( + async () => await UsersService.removeUserNotification('1', notifications[0].notificationId, organization) + ).rejects.toThrow(new NotFoundException('User', '1')); + }); + + it('Succeeds and gets user notifications', async () => { + const testBatman = await createTestUser(batmanAppAdmin, orgId); + await NotificationsService.sendNotifcationToUsers('test1', 'test1', [testBatman.userId], orgId); + await NotificationsService.sendNotifcationToUsers('test2', 'test2', [testBatman.userId], orgId); + + const notifications = await UsersService.getUserUnreadNotifications(testBatman.userId, organization); + + expect(notifications).toHaveLength(2); + expect(notifications[0].text).toBe('test1'); + expect(notifications[1].text).toBe('test2'); + + const updatedNotifications = await UsersService.removeUserNotification( + testBatman.userId, + notifications[0].notificationId, + organization + ); + + expect(updatedNotifications).toHaveLength(1); + expect(updatedNotifications[0].text).toBe('test2'); + }); + }); }); diff --git a/src/frontend/src/apis/users.api.ts b/src/frontend/src/apis/users.api.ts index afa5ea00f6..5a91bff5fd 100644 --- a/src/frontend/src/apis/users.api.ts +++ b/src/frontend/src/apis/users.api.ts @@ -5,6 +5,7 @@ import axios from '../utils/axios'; import { + Notification, Project, SetUserScheduleSettingsPayload, Task, @@ -159,3 +160,19 @@ export const getManyUserTasks = (userIds: string[]) => { } ); }; + +/* + * Gets all unread notifications of the user with the given id + */ +export const getNotifications = (id: string) => { + return axios.get(apiUrls.userNotifications(id), { + transformResponse: (data) => JSON.parse(data) + }); +}; + +/* + * Removes a notification from the user with the given id + */ +export const removeNotification = (userId: string, notificationId: string) => { + return axios.post(apiUrls.userRemoveNotifications(userId), { notificationId }); +}; diff --git a/src/frontend/src/components/NotificationAlert.tsx b/src/frontend/src/components/NotificationAlert.tsx new file mode 100644 index 0000000000..581d849ef0 --- /dev/null +++ b/src/frontend/src/components/NotificationAlert.tsx @@ -0,0 +1,57 @@ +import { Box } from '@mui/material'; +import React, { useEffect, useState } from 'react'; +import { Notification, User } from 'shared'; +import NotificationCard from './NotificationCard'; +import { useRemoveUserNotification, useUserNotifications } from '../hooks/users.hooks'; +import { useHistory } from 'react-router-dom'; + +interface NotificationAlertProps { + user: User; +} + +const NotificationAlert: React.FC = ({ user }) => { + const { data: notifications, isLoading: notificationsIsLoading } = useUserNotifications(user.userId); + const { mutateAsync: removeNotification, isLoading: removeIsLoading } = useRemoveUserNotification(user.userId); + const [currentNotification, setCurrentNotification] = useState(); + const history = useHistory(); + + useEffect(() => { + if (notifications && notifications.length > 0) { + setCurrentNotification(notifications[0]); + } + }, [notifications]); + + const removeNotificationWrapper = async (notification: Notification) => { + setCurrentNotification(undefined); + await removeNotification(notification); + }; + + const onClick = async (notification: Notification) => { + if (!!notification.eventLink) { + await removeNotificationWrapper(notification); + history.push(notification.eventLink); + } + }; + + return ( + + {!removeIsLoading && !notificationsIsLoading && currentNotification && ( + + )} + + ); +}; + +export default NotificationAlert; diff --git a/src/frontend/src/components/NotificationCard.tsx b/src/frontend/src/components/NotificationCard.tsx new file mode 100644 index 0000000000..1e4cfb4c02 --- /dev/null +++ b/src/frontend/src/components/NotificationCard.tsx @@ -0,0 +1,73 @@ +import { Box, Card, Icon, IconButton, Typography, useTheme } from '@mui/material'; +import React from 'react'; +import { Notification } from 'shared'; +import CloseIcon from '@mui/icons-material/Close'; + +interface NotificationCardProps { + notification: Notification; + removeNotification: (notificationId: Notification) => Promise; + onClick: (notificationId: Notification) => Promise; +} + +const NotificationCard: React.FC = ({ notification, removeNotification, onClick }) => { + const theme = useTheme(); + return ( + + + await onClick(notification)} + sx={{ + display: 'flex', + gap: 1, + cursor: !!notification.eventLink ? 'pointer' : 'default' + }} + > + + + {notification.iconName} + + + {notification.text} + + removeNotification(notification)}> + + + + + ); +}; + +export default NotificationCard; diff --git a/src/frontend/src/hooks/users.hooks.ts b/src/frontend/src/hooks/users.hooks.ts index 96b659c1f1..32279217f9 100644 --- a/src/frontend/src/hooks/users.hooks.ts +++ b/src/frontend/src/hooks/users.hooks.ts @@ -19,7 +19,9 @@ import { getUserScheduleSettings, updateUserScheduleSettings, getUserTasks, - getManyUserTasks + getManyUserTasks, + getNotifications, + removeNotification } from '../apis/users.api'; import { User, @@ -31,7 +33,8 @@ import { UserScheduleSettings, UserWithScheduleSettings, SetUserScheduleSettingsPayload, - Task + Task, + Notification } from 'shared'; import { useAuth } from './auth.hooks'; import { useContext } from 'react'; @@ -260,3 +263,36 @@ export const useManyUserTasks = (userIds: string[]) => { return data; }); }; + +/** + * Curstom react hook to get all unread notifications from a user + * @param userId id of user to get unread notifications from + * @returns + */ +export const useUserNotifications = (userId: string) => { + return useQuery(['users', userId, 'notifications'], async () => { + const { data } = await getNotifications(userId); + return data; + }); +}; + +/** + * Curstom react hook to remove a notification from a user's unread notifications + * @param userId id of user to get unread notifications from + * @returns + */ +export const useRemoveUserNotification = (userId: string) => { + const queryClient = useQueryClient(); + return useMutation( + ['users', userId, 'notifications', 'remove'], + async (notification: Notification) => { + const { data } = await removeNotification(userId, notification.notificationId); + return data; + }, + { + onSuccess: () => { + queryClient.invalidateQueries(['users', userId, 'notifications']); + } + } + ); +}; diff --git a/src/frontend/src/pages/HomePage/AdminHomePage.tsx b/src/frontend/src/pages/HomePage/AdminHomePage.tsx index edee46bbab..b83e32a6ab 100644 --- a/src/frontend/src/pages/HomePage/AdminHomePage.tsx +++ b/src/frontend/src/pages/HomePage/AdminHomePage.tsx @@ -41,10 +41,10 @@ const AdminHomePage = ({ user }: AdminHomePageProps) => { - + - + diff --git a/src/frontend/src/pages/HomePage/Home.tsx b/src/frontend/src/pages/HomePage/Home.tsx index 961430d92e..76db11f05a 100644 --- a/src/frontend/src/pages/HomePage/Home.tsx +++ b/src/frontend/src/pages/HomePage/Home.tsx @@ -11,20 +11,26 @@ import { useState } from 'react'; import MemberHomePage from './MemberHomePage'; import LeadHomePage from './LeadHomePage'; import AdminHomePage from './AdminHomePage'; +import NotificationAlert from '../../components/NotificationAlert'; const Home = () => { const user = useCurrentUser(); const [onMemberHomePage, setOnMemberHomePage] = useState(false); - return isGuest(user.role) && !onMemberHomePage ? ( - - ) : isMember(user.role) ? ( - - ) : isLead(user.role) ? ( - - ) : isAdmin(user.role) ? ( - - ) : ( - + return ( + <> + {!onMemberHomePage && } + {isGuest(user.role) && !onMemberHomePage ? ( + + ) : isMember(user.role) ? ( + + ) : isLead(user.role) ? ( + + ) : isAdmin(user.role) ? ( + + ) : ( + + )} + ); }; diff --git a/src/frontend/src/pages/HomePage/LeadHomePage.tsx b/src/frontend/src/pages/HomePage/LeadHomePage.tsx index e927f9adf9..5466cdc285 100644 --- a/src/frontend/src/pages/HomePage/LeadHomePage.tsx +++ b/src/frontend/src/pages/HomePage/LeadHomePage.tsx @@ -11,6 +11,7 @@ import PageLayout, { PAGE_GRID_HEIGHT } from '../../components/PageLayout'; import { AuthenticatedUser } from 'shared'; import ChangeRequestsToReview from './components/ChangeRequestsToReview'; import MyTeamsOverdueTasks from './components/MyTeamsOverdueTasks'; +import UpcomingDesignReviews from './components/UpcomingDesignReviews'; interface LeadHomePageProps { user: AuthenticatedUser; @@ -39,10 +40,13 @@ const LeadHomePage = ({ user }: LeadHomePageProps) => { - + + + + diff --git a/src/frontend/src/pages/HomePage/components/ChangeRequestsToReview.tsx b/src/frontend/src/pages/HomePage/components/ChangeRequestsToReview.tsx index e7161db966..148ed6c822 100644 --- a/src/frontend/src/pages/HomePage/components/ChangeRequestsToReview.tsx +++ b/src/frontend/src/pages/HomePage/components/ChangeRequestsToReview.tsx @@ -19,7 +19,7 @@ const NoChangeRequestsToReview: React.FC = () => { } heading={`You're all caught up!`} - message={'You have no unreviewed changre requests!'} + message={'You have no unreviewed change requests!'} /> ); }; diff --git a/src/frontend/src/pages/HomePage/components/DesignReviewCard.tsx b/src/frontend/src/pages/HomePage/components/DesignReviewCard.tsx new file mode 100644 index 0000000000..7ecf0dac6a --- /dev/null +++ b/src/frontend/src/pages/HomePage/components/DesignReviewCard.tsx @@ -0,0 +1,117 @@ +import { Box, Card, CardContent, Link, Stack, Typography, useTheme } from '@mui/material'; +import { DesignReview, User } from 'shared'; +import { datePipe, projectWbsPipe } from '../../../utils/pipes'; +import { routes } from '../../../utils/routes'; +import { Link as RouterLink } from 'react-router-dom'; +import CalendarMonthIcon from '@mui/icons-material/CalendarMonth'; +import { LocationOnOutlined, Computer } from '@mui/icons-material'; +import { useHistory } from 'react-router-dom'; +import { NERButton } from '../../../components/NERButton'; +import { meetingStartTimePipe } from '../../../../../backend/src/utils/design-reviews.utils'; +import { timezoneOffset } from '../../../utils/datetime.utils'; + +interface DesignReviewProps { + designReview: DesignReview; + user: User; +} + +const DesignReviewInfo = ({ icon, text, link }: { icon: React.ReactNode; text: string; link?: boolean }) => { + return ( + + {icon} + {link ? ( + + + {text} + + + ) : ( + + {text} + + )} + + ); +}; + +const DisplayStatus: React.FC = ({ designReview, user }) => { + const history = useHistory(); + const confirmedMemberIds = designReview.confirmedMembers.map((user) => user.userId); + + return ( + <> + {!confirmedMemberIds.includes(user.userId) ? ( + { + history.push(`${routes.SETTINGS_PREFERENCES}?drId=${designReview.designReviewId}`); + }} + component={RouterLink} + > + Confirm Availibility + + ) : ( + {designReview.status} + )} + + ); +}; + +const getWeekday = (date: Date): string => { + const weekdays: string[] = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday']; + return weekdays[date.getDay()]; +}; + +const removeYear = (str: string): string => { + return str.substring(0, str.length - 5); +}; + +const UpcomingDesignReviewsCard: React.FC = ({ designReview, user }) => { + const theme = useTheme(); + const timezoneAdjustedDate = timezoneOffset(designReview.dateScheduled); + return ( + + + + + + + {designReview.wbsName} + + + + {} + + {getWeekday(timezoneAdjustedDate) + + ', ' + + removeYear(datePipe(timezoneAdjustedDate)) + + ' @ ' + + meetingStartTimePipe(designReview.meetingTimes)} + + + {designReview.isInPerson && !!designReview.location && ( + } text={designReview.location} /> + )} + {designReview.isOnline && !!designReview.zoomLink && ( + } text={designReview.zoomLink} link /> + )} + + + + + + ); +}; + +export default UpcomingDesignReviewsCard; diff --git a/src/frontend/src/pages/HomePage/components/OverdueWorkPackageCard.tsx b/src/frontend/src/pages/HomePage/components/OverdueWorkPackageCard.tsx new file mode 100644 index 0000000000..deb86badb5 --- /dev/null +++ b/src/frontend/src/pages/HomePage/components/OverdueWorkPackageCard.tsx @@ -0,0 +1,145 @@ +import { Construction, Work, CalendarMonth } from '@mui/icons-material'; +import { + Box, + Card, + CardContent, + Chip, + CircularProgress, + CircularProgressProps, + Link, + Stack, + Typography, + useTheme +} from '@mui/material'; +import { wbsPipe, WorkPackage } from 'shared'; +import { datePipe, fullNamePipe, projectWbsPipe } from '../../../utils/pipes'; +import { routes } from '../../../utils/routes'; +import { Link as RouterLink } from 'react-router-dom'; +import { useGetManyWorkPackages } from '../../../hooks/work-packages.hooks'; +import { daysOverdue } from '../../../utils/datetime.utils'; +import LoadingIndicator from '../../../components/LoadingIndicator'; + +export const CircularProgressWithLabel = (props: CircularProgressProps & { value: number }) => { + return ( + + +
+ {`${Math.round(props.value)}%`} +
+
+ ); +}; + +const OverdueWorkPackageCard = ({ wp }: { wp: WorkPackage }) => { + const theme = useTheme(); + const { data: blockedByWps, isLoading } = useGetManyWorkPackages(wp.blockedBy); + const numDaysOverdue = daysOverdue(new Date(wp.endDate)); + if (isLoading || !blockedByWps) return ; + return ( + + + + + + + {projectWbsPipe(wp.wbsNum)} - {wp.projectName} + + + + + {wbsPipe(wp.wbsNum)} - {wp.name} + + + + Blocked By: + +
    + {blockedByWps.length === 0 ? ( +
  • + + No Blockers + +
  • + ) : ( + blockedByWps.map((wp) => ( +
  • + + + {projectWbsPipe(wp.wbsNum)} - {wp.projectName} + + +
  • + )) + )} +
+
+ + + } label={fullNamePipe(wp.lead)} size={'small'} /> + } label={fullNamePipe(wp.manager)} size={'small'} /> + } label={datePipe(new Date(wp.endDate))} size={'small'} /> + + + + + {numDaysOverdue} + + + + Days + + + Overdue + + + + +
+
+
+ ); +}; + +export default OverdueWorkPackageCard; diff --git a/src/frontend/src/pages/HomePage/components/OverdueWorkPackageView.tsx b/src/frontend/src/pages/HomePage/components/OverdueWorkPackageView.tsx index b3621c742c..d0d42294b1 100644 --- a/src/frontend/src/pages/HomePage/components/OverdueWorkPackageView.tsx +++ b/src/frontend/src/pages/HomePage/components/OverdueWorkPackageView.tsx @@ -1,8 +1,8 @@ -import { Box, Card, CardContent, Stack, Typography, useTheme } from '@mui/material'; -import WorkPackageCard from './WorkPackageCard'; +import { Box, Card, CardContent, Typography, useTheme } from '@mui/material'; import { WorkPackage } from 'shared'; import EmptyPageBlockDisplay from './EmptyPageBlockDisplay'; import CheckCircleOutlineOutlinedIcon from '@mui/icons-material/CheckCircleOutlineOutlined'; +import OverdueWorkPackageCard from './OverdueWorkPackageCard'; interface OverdueWorkPackagesViewProps { workPackages: WorkPackage[]; @@ -41,7 +41,7 @@ const OverdueWorkPackagesView: React.FC = ({ workP }} > - Overdue Work Packages + All Overdue Work Packages = ({ workP - - {isEmpty ? : workPackages.map((wp) => )} - + {isEmpty ? : workPackages.map((wp) => )} + diff --git a/src/frontend/src/pages/HomePage/components/ScrollablePageBlock.tsx b/src/frontend/src/pages/HomePage/components/ScrollablePageBlock.tsx index 25045b6d39..54b11ce434 100644 --- a/src/frontend/src/pages/HomePage/components/ScrollablePageBlock.tsx +++ b/src/frontend/src/pages/HomePage/components/ScrollablePageBlock.tsx @@ -34,23 +34,26 @@ const ScrollablePageBlock: React.FC = ({ children, tit sx={{ mt: 2, display: 'flex', - flexDirection: horizontal ? 'row' : 'column', + flexDirection: 'row', + flexWrap: 'wrap', gap: 2, height: '100%', overflowX: horizontal ? 'auto' : 'hidden', overflowY: horizontal ? 'hidden' : 'auto', '&::-webkit-scrollbar': { - height: '20px' + width: '20px' }, '&::-webkit-scrollbar-track': { backgroundColor: 'transparent' }, '&::-webkit-scrollbar-thumb': { - backgroundColor: theme.palette.error.dark, + backgroundColor: theme.palette.primary.main, borderRadius: '20px', border: '6px solid transparent', backgroundClip: 'content-box' - } + }, + scrollbarWidth: 'auto', + scrollbarColor: `${theme.palette.primary.main} transparent` }} > {children} diff --git a/src/frontend/src/pages/HomePage/components/TeamTaskCard.tsx b/src/frontend/src/pages/HomePage/components/TeamTaskCard.tsx index 76b80e93c3..dfd1485e64 100644 --- a/src/frontend/src/pages/HomePage/components/TeamTaskCard.tsx +++ b/src/frontend/src/pages/HomePage/components/TeamTaskCard.tsx @@ -28,7 +28,7 @@ const TeamTaskCard: React.FC = ({ task, taskNumber }) => { { + return ( + } + heading={'No Upcoming Design Reviews'} + message={'There are no Upcoming Design Reviews to Display'} + /> + ); +}; + +const UpcomingDesignReviews: React.FC = ({ user }) => { + const { data: designReviews, isLoading, isError, error } = useAllDesignReviews(); + + if (isLoading || !designReviews) return ; + if (isError) return ; + + const filteredDesignReviews = designReviews.filter((review) => { + const scheduledDate = review.dateScheduled; + 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) + ]; + // added in case the person who created the design review forgets to add their name onto the required members + memberUserIds.concat(review.userCreated.userId); + return ( + scheduledDate >= currentDate && + scheduledDate <= inTwoWeeks && + review.status !== DesignReviewStatus.DONE && + memberUserIds.includes(user.userId) + ); + }); + + const fullDisplay = ( + + {filteredDesignReviews.length === 0 ? ( + + ) : ( + filteredDesignReviews.map((d) => ) + )} + + ); + + return fullDisplay; +}; + +export default UpcomingDesignReviews; diff --git a/src/frontend/src/utils/datetime.utils.ts b/src/frontend/src/utils/datetime.utils.ts index e728bb5cbf..1b23c43118 100644 --- a/src/frontend/src/utils/datetime.utils.ts +++ b/src/frontend/src/utils/datetime.utils.ts @@ -34,3 +34,8 @@ export const formatDate = (date: Date) => { export const daysOverdue = (deadline: Date) => { return Math.round((new Date().getTime() - deadline.getTime()) / (1000 * 60 * 60 * 24)); }; + +export const timezoneOffset = (date: Date) => { + const timestamp = new Date(date).getTime() - new Date(date).getTimezoneOffset() * -60000; + return new Date(timestamp); +}; diff --git a/src/frontend/src/utils/urls.ts b/src/frontend/src/utils/urls.ts index ae65afe744..f034f3f221 100644 --- a/src/frontend/src/utils/urls.ts +++ b/src/frontend/src/utils/urls.ts @@ -26,6 +26,8 @@ const userScheduleSettings = (id: string) => `${usersById(id)}/schedule-settings const userScheduleSettingsSet = () => `${users()}/schedule-settings/set`; const userTasks = (id: string) => `${usersById(id)}/tasks`; const manyUserTasks = () => `${users()}/tasks/get-many`; +const userNotifications = (id: string) => `${usersById(id)}/notifications`; +const userRemoveNotifications = (id: string) => `${usersById(id)}/notifications/remove`; /**************** Projects Endpoints ****************/ const projects = () => `${API_URL}/projects`; @@ -218,6 +220,8 @@ export const apiUrls = { userScheduleSettingsSet, userTasks, manyUserTasks, + userNotifications, + userRemoveNotifications, projects, allProjects, diff --git a/src/shared/src/types/notifications.types.ts b/src/shared/src/types/notifications.types.ts index e4419ef2ed..abd16fcd21 100644 --- a/src/shared/src/types/notifications.types.ts +++ b/src/shared/src/types/notifications.types.ts @@ -2,4 +2,5 @@ export interface Notification { notificationId: string; text: string; iconName: string; + eventLink?: string; }