diff --git a/src/backend/index.ts b/src/backend/index.ts index 7f7a4b760b..08630b9fa6 100644 --- a/src/backend/index.ts +++ b/src/backend/index.ts @@ -14,6 +14,7 @@ import reimbursementRequestsRouter from './src/routes/reimbursement-requests.rou import notificationsRouter from './src/routes/notifications.routes'; import designReviewsRouter from './src/routes/design-reviews.routes'; import workPackageTemplatesRouter from './src/routes/work-package-templates.routes'; +import carsRouter from './src/routes/cars.routes'; const app = express(); const port = process.env.PORT || 3001; @@ -58,6 +59,7 @@ app.use('/reimbursement-requests', reimbursementRequestsRouter); app.use('/design-reviews', designReviewsRouter); app.use('/notifications', notificationsRouter); app.use('/templates', workPackageTemplatesRouter); +app.use('/cars', carsRouter); app.use('/', (_req, res) => { res.json('Welcome to FinishLine'); }); diff --git a/src/backend/src/controllers/cars.controllers.ts b/src/backend/src/controllers/cars.controllers.ts new file mode 100644 index 0000000000..c4007df98b --- /dev/null +++ b/src/backend/src/controllers/cars.controllers.ts @@ -0,0 +1,31 @@ +import { NextFunction, Request, Response } from 'express'; +import CarsService from '../services/car.services'; +import { getCurrentUser } from '../utils/auth.utils'; +import { getOrganizationId } from '../utils/utils'; + +export default class CarsController { + static async getAllCars(req: Request, res: Response, next: NextFunction) { + try { + const organizationId = getOrganizationId(req.headers); + await getCurrentUser(res); + const cars = await CarsService.getAllCars(organizationId); + + res.status(200).json(cars); + } catch (error: unknown) { + next(error); + } + } + + static async createCar(req: Request, res: Response, next: NextFunction) { + try { + const organizationId = getOrganizationId(req.headers); + const user = await getCurrentUser(res); + const { name } = req.body; + const car = await CarsService.createCar(organizationId, user, name); + + res.status(201).json(car); + } catch (error: unknown) { + next(error); + } + } +} diff --git a/src/backend/src/controllers/projects.controllers.ts b/src/backend/src/controllers/projects.controllers.ts index 0b17a73d75..c1d8a328d3 100644 --- a/src/backend/src/controllers/projects.controllers.ts +++ b/src/backend/src/controllers/projects.controllers.ts @@ -428,13 +428,12 @@ export default class ProjectsController { static async editLinkType(req: Request, res: Response, next: NextFunction) { try { - const { linkId } = req.params; - const { iconName, required, linkTypeName } = req.body; + const { linkTypeName } = req.params; + const { iconName, required } = req.body; const submitter = await getCurrentUser(res); const organizationId = getOrganizationId(req.headers); const linkTypeUpdated = await ProjectsService.editLinkType( - linkId, linkTypeName, iconName, required, diff --git a/src/backend/src/controllers/users.controllers.ts b/src/backend/src/controllers/users.controllers.ts index 768187f061..d376dab46d 100644 --- a/src/backend/src/controllers/users.controllers.ts +++ b/src/backend/src/controllers/users.controllers.ts @@ -7,7 +7,7 @@ import { getOrganizationId } from '../utils/utils'; export default class UsersController { static async getAllUsers(req: Request, res: Response, next: NextFunction) { try { - const { organizationId } = req.headers as { organizationId?: string }; + const organizationId = getOrganizationId(req.headers); const users = await UsersService.getAllUsers(organizationId); diff --git a/src/backend/src/prisma-query-args/cars.query-args.ts b/src/backend/src/prisma-query-args/cars.query-args.ts new file mode 100644 index 0000000000..3b2369a0ef --- /dev/null +++ b/src/backend/src/prisma-query-args/cars.query-args.ts @@ -0,0 +1,27 @@ +import { Prisma } from '@prisma/client'; +import { getAssemblyQueryArgs, getMaterialQueryArgs } from './bom.query-args'; +import { getDescriptionBulletQueryArgs } from './description-bullets.query-args'; +import { getLinkQueryArgs } from './links.query-args'; +import { getUserQueryArgs } from './user.query-args'; + +export type CarQueryArgs = ReturnType; + +export const getCarQueryArgs = (organizationId: string) => + Prisma.validator()({ + include: { + wbsElement: { + include: { + lead: getUserQueryArgs(organizationId), + descriptionBullets: getDescriptionBulletQueryArgs(organizationId), + manager: getUserQueryArgs(organizationId), + links: getLinkQueryArgs(organizationId), + changes: { + where: { changeRequest: { dateDeleted: null } }, + include: { implementer: getUserQueryArgs(organizationId) } + }, + materials: getMaterialQueryArgs(organizationId), + assemblies: getAssemblyQueryArgs(organizationId) + } + } + } + }); diff --git a/src/backend/src/prisma-query-args/change-requests.query-args.ts b/src/backend/src/prisma-query-args/change-requests.query-args.ts index 01d1dc9d67..e6b90eb91d 100644 --- a/src/backend/src/prisma-query-args/change-requests.query-args.ts +++ b/src/backend/src/prisma-query-args/change-requests.query-args.ts @@ -17,8 +17,8 @@ export const getChangeRequestQueryArgs = (organizationId: string) => teams: true } }, - descriptionBullets: true, - links: true + descriptionBullets: { where: { dateDeleted: null } }, + links: { where: { dateDeleted: null } } } }, reviewer: getUserQueryArgs(organizationId), diff --git a/src/backend/src/prisma-query-args/projects.query-args.ts b/src/backend/src/prisma-query-args/projects.query-args.ts index 75baa1673d..9c433154bc 100644 --- a/src/backend/src/prisma-query-args/projects.query-args.ts +++ b/src/backend/src/prisma-query-args/projects.query-args.ts @@ -15,9 +15,9 @@ export const getProjectQueryArgs = (organizationId: string) => include: { lead: getUserQueryArgs(organizationId), manager: getUserQueryArgs(organizationId), - descriptionBullets: getDescriptionBulletQueryArgs(organizationId), + descriptionBullets: { where: { dateDeleted: null }, ...getDescriptionBulletQueryArgs(organizationId) }, tasks: { where: { dateDeleted: null }, ...getTaskQueryArgs(organizationId) }, - links: getLinkQueryArgs(organizationId), + links: { where: { dateDeleted: null }, ...getLinkQueryArgs(organizationId) }, changes: { where: { changeRequest: { dateDeleted: null } }, include: { implementer: getUserQueryArgs(organizationId) } @@ -43,15 +43,21 @@ export const getProjectQueryArgs = (organizationId: string) => wbsElement: { include: { lead: getUserQueryArgs(organizationId), - descriptionBullets: getDescriptionBulletQueryArgs(organizationId), + descriptionBullets: { where: { dateDeleted: null }, ...getDescriptionBulletQueryArgs(organizationId) }, manager: getUserQueryArgs(organizationId), - links: getLinkQueryArgs(organizationId), + links: { where: { dateDeleted: null }, ...getLinkQueryArgs(organizationId) }, changes: { where: { changeRequest: { dateDeleted: null } }, include: { implementer: getUserQueryArgs(organizationId) } }, - materials: getMaterialQueryArgs(organizationId), - assemblies: getAssemblyQueryArgs(organizationId) + materials: { + where: { dateDeleted: null }, + ...getMaterialQueryArgs(organizationId) + }, + assemblies: { + where: { dateDeleted: null }, + ...getAssemblyQueryArgs(organizationId) + } } }, blockedBy: { where: { dateDeleted: null } } diff --git a/src/backend/src/prisma-query-args/scope-change-requests.query-args.ts b/src/backend/src/prisma-query-args/scope-change-requests.query-args.ts index ced3012b77..30341acbce 100644 --- a/src/backend/src/prisma-query-args/scope-change-requests.query-args.ts +++ b/src/backend/src/prisma-query-args/scope-change-requests.query-args.ts @@ -7,7 +7,7 @@ import { getTeamQueryArgs } from './teams.query-args'; export type ProjectProposedChangesQueryArgs = ReturnType; -export type WorkPackageProposedChangesQueryArgs = typeof workPackageProposedChangesQueryArgs; +export type WorkPackageProposedChangesQueryArgs = ReturnType; export type WbsProposedChangeQueryArgs = ReturnType; @@ -17,6 +17,7 @@ const getProjectProposedChangesQueryArgs = (organizationId: string) => Prisma.validator()({ include: { teams: getTeamQueryArgs(organizationId), + workPackageProposedChanges: getWorkPackageProposedChangesQueryArgs(organizationId), car: { include: { wbsElement: true @@ -25,17 +26,26 @@ const getProjectProposedChangesQueryArgs = (organizationId: string) => } }); -export const workPackageProposedChangesQueryArgs = Prisma.validator()({ - include: { - blockedBy: true - } -}); +export const getWorkPackageProposedChangesQueryArgs = (organizationId: string) => + Prisma.validator()({ + include: { + blockedBy: true, + wbsProposedChanges: { + include: { + links: getLinkQueryArgs(organizationId), + lead: getUserQueryArgs(organizationId), + manager: getUserQueryArgs(organizationId), + proposedDescriptionBulletChanges: getDescriptionBulletQueryArgs(organizationId) + } + } + } + }); export const getWbsProposedChangeQueryArgs = (organizationId: string) => Prisma.validator()({ include: { projectProposedChanges: getProjectProposedChangesQueryArgs(organizationId), - workPackageProposedChanges: workPackageProposedChangesQueryArgs, + workPackageProposedChanges: getWorkPackageProposedChangesQueryArgs(organizationId), links: getLinkQueryArgs(organizationId), lead: getUserQueryArgs(organizationId), manager: getUserQueryArgs(organizationId), diff --git a/src/backend/src/prisma/migrations/20240527165314_add_work_package_proposed_solution_to_project_proposed_solution/migration.sql b/src/backend/src/prisma/migrations/20240527165314_add_work_package_proposed_solution_to_project_proposed_solution/migration.sql new file mode 100644 index 0000000000..94c9ab325e --- /dev/null +++ b/src/backend/src/prisma/migrations/20240527165314_add_work_package_proposed_solution_to_project_proposed_solution/migration.sql @@ -0,0 +1,5 @@ +-- AlterTable +ALTER TABLE "Work_Package_Proposed_Changes" ADD COLUMN "projectProposedChangesId" TEXT; + +-- AddForeignKey +ALTER TABLE "Work_Package_Proposed_Changes" ADD CONSTRAINT "Work_Package_Proposed_Changes_projectProposedChangesId_fkey" FOREIGN KEY ("projectProposedChangesId") REFERENCES "Project_Proposed_Changes"("projectProposedChangesId") ON DELETE SET NULL ON UPDATE CASCADE; diff --git a/src/backend/src/prisma/schema.prisma b/src/backend/src/prisma/schema.prisma index 1693588303..6a3d9bac8c 100644 --- a/src/backend/src/prisma/schema.prisma +++ b/src/backend/src/prisma/schema.prisma @@ -780,18 +780,22 @@ model Project_Proposed_Changes { teams Team[] @relation(name: "proposedProjectTeams") wbsProposedChanges Wbs_Proposed_Changes @relation(name: "projectProposedChanges", fields: [wbsProposedChangesId], references: [wbsProposedChangesId]) wbsProposedChangesId String @unique - carId String? - car Car? @relation(fields: [carId], references: [carId]) + + workPackageProposedChanges Work_Package_Proposed_Changes[] + carId String? + car Car? @relation(fields: [carId], references: [carId]) } model Work_Package_Proposed_Changes { - workPackageProposedChangesId String @id @default(uuid()) - startDate DateTime @db.Date + workPackageProposedChangesId String @id @default(uuid()) + startDate DateTime @db.Date duration Int - blockedBy WBS_Element[] @relation(name: "proposedBlockedBy") + blockedBy WBS_Element[] @relation(name: "proposedBlockedBy") stage Work_Package_Stage? - wbsProposedChanges Wbs_Proposed_Changes @relation(name: "wpProposedChanges", fields: [wbsProposedChangesId], references: [wbsProposedChangesId]) - wbsProposedChangesId String @unique + wbsProposedChanges Wbs_Proposed_Changes @relation(name: "wpProposedChanges", fields: [wbsProposedChangesId], references: [wbsProposedChangesId]) + wbsProposedChangesId String @unique + projectProposedChanges Project_Proposed_Changes? @relation(fields: [projectProposedChangesId], references: [projectProposedChangesId]) + projectProposedChangesId String? } model Work_Package_Template { diff --git a/src/backend/src/prisma/seed.ts b/src/backend/src/prisma/seed.ts index 0d6138ed44..35ebdd57dc 100644 --- a/src/backend/src/prisma/seed.ts +++ b/src/backend/src/prisma/seed.ts @@ -39,11 +39,6 @@ const performSeed: () => Promise = async () => { include: { userSettings: true, userSecureSettings: true } }); - const regina = await prisma.user.create({ - data: dbSeedAllUsers.regina, - include: { userSettings: true, userSecureSettings: true } - }); - const ner = await prisma.organization.create({ data: { name: 'NER', @@ -180,6 +175,7 @@ const performSeed: () => Promise = async () => { const norbury = await createUser(dbSeedAllUsers.norbury, RoleEnum.LEADERSHIP, organizationId); const carr = await createUser(dbSeedAllUsers.carr, RoleEnum.LEADERSHIP, organizationId); const trang = await createUser(dbSeedAllUsers.trang, RoleEnum.LEADERSHIP, organizationId); + const regina = await createUser(dbSeedAllUsers.regina, RoleEnum.LEADERSHIP, organizationId); await UsersService.updateUserRole(cyborg.userId, thomasEmrax, 'APP_ADMIN', organizationId); @@ -403,11 +399,11 @@ const performSeed: () => Promise = async () => { ); /** Link Types */ - const confluenceLinkType = await ProjectsService.createLinkType(batman, 'Confluence', 'doc', true, organizationId); + const confluenceLinkType = await ProjectsService.createLinkType(batman, 'Confluence', 'description', true, organizationId); - const bomLinkType = await ProjectsService.createLinkType(batman, 'Bill of Materials', 'doc', true, organizationId); + const bomLinkType = await ProjectsService.createLinkType(batman, 'Bill of Materials', 'bar_chart', true, organizationId); - await ProjectsService.createLinkType(batman, 'Google Drive', 'doc', true, organizationId); + await ProjectsService.createLinkType(batman, 'Google Drive', 'folder', true, organizationId); /** * Projects diff --git a/src/backend/src/routes/cars.routes.ts b/src/backend/src/routes/cars.routes.ts new file mode 100644 index 0000000000..a0e4e21c6d --- /dev/null +++ b/src/backend/src/routes/cars.routes.ts @@ -0,0 +1,10 @@ +import express from 'express'; +import CarsController from '../controllers/cars.controllers'; + +const carsRouter = express.Router(); + +carsRouter.get('/', CarsController.getAllCars); + +carsRouter.post('/create', CarsController.createCar); + +export default carsRouter; diff --git a/src/backend/src/routes/change-requests.routes.ts b/src/backend/src/routes/change-requests.routes.ts index c7a3699fc9..d278b5fd87 100644 --- a/src/backend/src/routes/change-requests.routes.ts +++ b/src/backend/src/routes/change-requests.routes.ts @@ -73,7 +73,7 @@ changeRequestsRouter.post( body('proposedSolutions.*.timelineImpact').isInt(), body('proposedSolutions.*.budgetImpact').isInt(), ...projectProposedChangesValidators, - ...workPackageProposedChangesValidators, + ...workPackageProposedChangesValidators('workPackageProposedChanges'), validateInputs, ChangeRequestsController.createStandardChangeRequest ); diff --git a/src/backend/src/services/car.services.ts b/src/backend/src/services/car.services.ts new file mode 100644 index 0000000000..355db06604 --- /dev/null +++ b/src/backend/src/services/car.services.ts @@ -0,0 +1,66 @@ +import { User } from '@prisma/client'; +import { isAdmin } from 'shared'; +import { getCarQueryArgs } from '../prisma-query-args/cars.query-args'; +import prisma from '../prisma/prisma'; +import { carTransformer } from '../transformers/cars.transformer'; +import { AccessDeniedAdminOnlyException, NotFoundException } from '../utils/errors.utils'; +import { userHasPermission } from '../utils/users.utils'; + +export default class CarsService { + static async getAllCars(organizationId: string) { + const cars = await prisma.car.findMany({ + where: { + wbsElement: { + organizationId + } + }, + ...getCarQueryArgs(organizationId) + }); + + return cars.map(carTransformer); + } + + static async createCar(organizationId: string, user: User, name: string) { + if (!(await userHasPermission(user.userId, organizationId, isAdmin))) + throw new AccessDeniedAdminOnlyException('create a car'); + + const organization = await prisma.organization.findUnique({ + where: { + organizationId + } + }); + + if (!organization) { + throw new NotFoundException('Organization', organizationId); + } + + const numExistingCars = await prisma.car.count({ + where: { + wbsElement: { + organizationId + } + } + }); + + const car = await prisma.car.create({ + data: { + wbsElement: { + create: { + name, + carNumber: numExistingCars, + projectNumber: 0, + workPackageNumber: 0, + organization: { + connect: { + organizationId + } + } + } + } + }, + ...getCarQueryArgs(organizationId) + }); + + return carTransformer(car); + } +} diff --git a/src/backend/src/services/change-requests.services.ts b/src/backend/src/services/change-requests.services.ts index c97e6595db..ad0ef66361 100644 --- a/src/backend/src/services/change-requests.services.ts +++ b/src/backend/src/services/change-requests.services.ts @@ -32,7 +32,7 @@ import { reviewProposedSolution, sendCRSubmitterReviewedNotification } from '../utils/change-requests.utils'; -import { CR_Type, WBS_Element_Status, User, Scope_CR_Why_Type, Project, Work_Package, Prisma } from '@prisma/client'; +import { CR_Type, WBS_Element_Status, User, Scope_CR_Why_Type, Prisma, Work_Package_Stage } from '@prisma/client'; import { getUserFullName, getUsersWithSettings, userHasPermission } from '../utils/users.utils'; import { throwIfUncheckedDescriptionBullets } from '../utils/description-bullets.utils'; import { buildChangeDetail } from '../utils/changes.utils'; @@ -43,7 +43,6 @@ import { sendSlackRequestedReviewNotification } from '../utils/slack.utils'; import { ChangeRequestQueryArgs, getChangeRequestQueryArgs } from '../prisma-query-args/change-requests.query-args'; -import { validateBlockedBys } from '../utils/work-packages.utils'; import proposedSolutionTransformer from '../transformers/proposed-solutions.transformer'; import { getProposedSolutionQueryArgs } from '../prisma-query-args/proposed-solutions.query-args'; @@ -176,9 +175,6 @@ export default class ChangeRequestsService { // reviews a proposed solution applying certain changes based on the content of the proposed solution await reviewProposedSolution(psId, foundCR, reviewer, organizationId); } else if (foundCR.scopeChangeRequest?.wbsProposedChanges && !psId) { - // we don't want to have merge conflictS on the wbs element thus we check if there are unreviewed or open CRs on the wbs element - await validateNoUnreviewedOpenCRs(foundCR.wbsElementId); - const associatedProject = foundCR.wbsElement.project; const associatedWorkPackage = foundCR.wbsElement.workPackage; const { wbsProposedChanges } = foundCR.scopeChangeRequest; @@ -199,16 +195,8 @@ export default class ChangeRequestsService { create: { name: foundCR.wbsElement.name, status: foundCR.wbsElement.status, - lead: { - connect: { - userId: foundCR.wbsElement.leadId ?? undefined - } - }, - manager: { - connect: { - userId: foundCR.wbsElement.managerId ?? undefined - } - }, + leadId: foundCR.wbsElement.leadId, + managerId: foundCR.wbsElement.managerId, links: { connect: foundCR.wbsElement.links.map((link) => ({ linkId: link.linkId @@ -258,8 +246,8 @@ export default class ChangeRequestsService { await applyWorkPackageProposedChanges( wbsProposedChanges, workPackageProposedChanges, - associatedProject as Project, - associatedWorkPackage as Work_Package, + associatedProject?.wbsElementId ?? null, + associatedWorkPackage, reviewer, foundCR.crId, organizationId @@ -268,7 +256,7 @@ export default class ChangeRequestsService { await applyProjectProposedChanges( wbsProposedChanges, projectProposedChanges, - associatedProject as Project, + associatedProject, reviewer, foundCR.crId, foundCR.wbsElement.carNumber, @@ -444,6 +432,8 @@ export default class ChangeRequestsService { if (!wbsElement) throw new NotFoundException('WBS Element', wbsPipe({ carNumber, projectNumber, workPackageNumber })); if (wbsElement.dateDeleted) throw new DeletedException('WBS Element', wbsPipe({ carNumber, projectNumber, workPackageNumber })); + // we don't want to have merge conflictS on the wbs element thus we check if there are unreviewed or open CRs on the wbs element + await validateNoUnreviewedOpenCRs(wbsElement.wbsElementId); const { changeRequests } = wbsElement; const nonDeletedChangeRequests = changeRequests.filter((changeRequest) => !changeRequest.dateDeleted); @@ -545,9 +535,10 @@ export default class ChangeRequestsService { }); if (!wbsElement) throw new NotFoundException('WBS Element', `${carNumber}.${projectNumber}.${workPackageNumber}`); - if (wbsElement.dateDeleted) throw new DeletedException('WBS Element', wbsPipe({ carNumber, projectNumber, workPackageNumber })); + // we don't want to have merge conflictS on the wbs element thus we check if there are unreviewed or open CRs on the wbs element + await validateNoUnreviewedOpenCRs(wbsElement.wbsElementId); if (wbsElement.workPackage) { throwIfUncheckedDescriptionBullets(wbsElement.descriptionBullets); @@ -670,6 +661,11 @@ export default class ChangeRequestsService { if (!wbsElement) throw new NotFoundException('WBS Element', `${carNumber}.${projectNumber}.${workPackageNumber}`); if (wbsElement.dateDeleted) throw new DeletedException('WBS Element', wbsPipe({ carNumber, projectNumber, workPackageNumber })); + if (wbsElement.organizationId !== organizationId) throw new InvalidOrganizationException('WBS Element'); + // we don't want to have merge conflictS on the wbs element thus we check if there are unreviewed or open CRs on the wbs element + if (projectNumber !== 0) { + await validateNoUnreviewedOpenCRs(wbsElement.wbsElementId); + } const numChangeRequests = await prisma.change_Request.count({ where: { organizationId } @@ -710,12 +706,25 @@ export default class ChangeRequestsService { if (projectProposedChanges && workPackageProposedChanges) { throw new HttpException(400, "Change Request can't be on both a project and a work package"); } else if (projectProposedChanges) { - const { name, leadId, managerId, links, budget, summary, teamIds, descriptionBullets, carNumber } = - projectProposedChanges; + const { + name, + leadId, + managerId, + links, + budget, + summary, + teamIds, + descriptionBullets, + workPackageProposedChanges, + carNumber + } = projectProposedChanges; const validationResult = await validateProposedChangesFields( + projectProposedChanges, links, descriptionBullets, + [], + workPackageProposedChanges, organizationId, carNumber, leadId, @@ -729,7 +738,7 @@ export default class ChangeRequestsService { } } - await prisma.wbs_Proposed_Changes.create({ + const changes = await prisma.wbs_Proposed_Changes.create({ data: { scopeChangeRequest: { connect: { @@ -738,8 +747,6 @@ export default class ChangeRequestsService { }, name, status: WBS_Element_Status.ACTIVE, - lead: { connect: { userId: leadId } }, - manager: { connect: { userId: managerId } }, links: { create: validationResult.links.map((linkInfo) => ({ url: linkInfo.url, @@ -758,6 +765,45 @@ export default class ChangeRequestsService { budget, summary, teams: { connect: teamIds.map((teamId) => ({ teamId })) }, + workPackageProposedChanges: { + create: validationResult.workPackageProposedChanges.map((workPackage) => ({ + wbsProposedChanges: { + create: { + name: workPackage.originalElement.name, + status: WBS_Element_Status.INACTIVE, + proposedDescriptionBulletChanges: { + create: workPackage.descriptionBullets.map((bullet) => ({ + detail: bullet.detail, + descriptionBulletTypeId: bullet.descriptionBulletType.id + })) + } + } + }, + duration: workPackage.originalElement.duration, + startDate: new Date(workPackage.originalElement.startDate), + stage: (workPackage.originalElement.stage as Work_Package_Stage) ?? undefined, + blockedBy: { + connect: workPackage.validatedBlockedBys.map((wbsElement) => ({ + wbsNumber: { + ...wbsElement, + organizationId + } + })) + } + })) + } + } + } + } + }); + + await prisma.wbs_Proposed_Changes.update({ + where: { wbsProposedChangesId: changes.wbsProposedChangesId }, + data: { + leadId, + managerId, + projectProposedChanges: { + update: { carId: validationResult.carId } } @@ -768,23 +814,22 @@ export default class ChangeRequestsService { workPackageProposedChanges; const validationResult = await validateProposedChangesFields( + workPackageProposedChanges, [], descriptionBullets, + blockedBy, + [], organizationId, undefined, leadId, managerId ); - await validateBlockedBys(blockedBy, organizationId); - - await prisma.wbs_Proposed_Changes.create({ + const changes = await prisma.wbs_Proposed_Changes.create({ data: { scopeChangeRequest: { connect: { scopeCrId: createdCR.scopeChangeRequest!.scopeCrId } }, name, status: WBS_Element_Status.INACTIVE, - lead: { connect: { userId: leadId } }, - manager: { connect: { userId: managerId } }, proposedDescriptionBulletChanges: { create: validationResult.descriptionBullets.map((bullet) => ({ detail: bullet.detail, @@ -797,7 +842,7 @@ export default class ChangeRequestsService { startDate: new Date(startDate), stage, blockedBy: { - connect: blockedBy.map((wbsNumber) => ({ + connect: validationResult.validatedBlockedBys.map((wbsNumber) => ({ wbsNumber: { carNumber: wbsNumber.carNumber, projectNumber: wbsNumber.projectNumber, @@ -810,6 +855,14 @@ export default class ChangeRequestsService { } } }); + + await prisma.wbs_Proposed_Changes.update({ + where: { wbsProposedChangesId: changes.wbsProposedChangesId }, + data: { + leadId, + managerId + } + }); } const proposedSolutionPromises = proposedSolutions.map(async (proposedSolution) => { @@ -846,7 +899,9 @@ export default class ChangeRequestsService { ...getChangeRequestQueryArgs(organizationId) }); - return changeRequestTransformer(finishedCR!) as StandardChangeRequest; + if (!finishedCR) throw new NotFoundException('Change Request', createdCR.crId); + + return changeRequestTransformer(finishedCR) as StandardChangeRequest; } /** diff --git a/src/backend/src/services/projects.services.ts b/src/backend/src/services/projects.services.ts index 288e167cb7..71ae7f585e 100644 --- a/src/backend/src/services/projects.services.ts +++ b/src/backend/src/services/projects.services.ts @@ -519,7 +519,6 @@ export default class ProjectsService { /** * Updates the linkType's name, iconName, or required. - * @param linkId the id of the linkType being editted * @param linkName the name of the linkType being editted * @param iconName the new iconName * @param required the new required status @@ -528,7 +527,6 @@ export default class ProjectsService { * @returns the updated linkType */ static async editLinkType( - linkId: string, linkName: string, iconName: string, required: boolean, @@ -540,15 +538,19 @@ export default class ProjectsService { // check if the linkType we are trying to update exists const linkType = await prisma.link_Type.findUnique({ - where: { id: linkId } + where: { + uniqueLinkType: { + name: linkName, + organizationId + } + } }); if (!linkType) throw new NotFoundException('Link Type', linkName); - if (linkType.organizationId !== organizationId) throw new InvalidOrganizationException('Link Type'); // update the LinkType const linkTypeUpdated = await prisma.link_Type.update({ - where: { id: linkId }, + where: { id: linkType.id }, data: { name: linkName, iconName, diff --git a/src/backend/src/services/reimbursement-requests.services.ts b/src/backend/src/services/reimbursement-requests.services.ts index 18f35a7eed..1bb7033df1 100644 --- a/src/backend/src/services/reimbursement-requests.services.ts +++ b/src/backend/src/services/reimbursement-requests.services.ts @@ -582,6 +582,18 @@ export default class ReimbursementRequestService { if (!(await userHasPermission(submitter.userId, organizationId, isAdmin))) throw new AccessDeniedAdminOnlyException('create Account Codes'); + const existingAccount = await prisma.account_Code.findUnique({ + where: { uniqueExpenseType: { name, organizationId } } + }); + + if (existingAccount && existingAccount.dateDeleted) { + await prisma.account_Code.update({ + where: { accountCodeId: existingAccount.accountCodeId }, + data: { dateDeleted: null } + }); + return existingAccount; + } else if (existingAccount) throw new HttpException(400, 'This Account Code already exists'); + const expense = await prisma.account_Code.create({ data: { name, diff --git a/src/backend/src/services/work-packages.services.ts b/src/backend/src/services/work-packages.services.ts index 66fe6771b3..a48bb5eafd 100644 --- a/src/backend/src/services/work-packages.services.ts +++ b/src/backend/src/services/work-packages.services.ts @@ -4,7 +4,7 @@ import { getDay, isAdmin, isGuest, - isProject, + isWorkPackage, WbsElementStatus, WbsNumber, wbsPipe, @@ -85,8 +85,8 @@ export default class WorkPackagesService { * @throws if the work package with the desired WBS number is not found, is deleted or is not part of the given organization */ static async getSingleWorkPackage(parsedWbs: WbsNumber, organizationId: string): Promise { - if (isProject(parsedWbs)) { - throw new HttpException(404, 'WBS Number ' + wbsPipe(parsedWbs) + ' is a project WBS#, not a Work Package WBS#'); + if (!isWorkPackage(parsedWbs)) { + throw new HttpException(404, 'WBS Number ' + wbsPipe(parsedWbs) + ' is a not a work package WBS#'); } const wp = await prisma.work_Package.findFirst({ @@ -123,10 +123,10 @@ export default class WorkPackagesService { */ static async getManyWorkPackages(wbsNums: WbsNumber[], organizationId: string): Promise { wbsNums.forEach((wbsNum) => { - if (isProject(wbsNum)) { + if (!isWorkPackage(wbsNum)) { throw new HttpException( 404, - `WBS Number ${wbsNum.carNumber}.${wbsNum.projectNumber}.${wbsNum.workPackageNumber} is a project WBS#, not a Work Package WBS#` + `WBS Number ${wbsNum.carNumber}.${wbsNum.projectNumber}.${wbsNum.workPackageNumber} is not a Work Package WBS#` ); } }); @@ -162,16 +162,19 @@ export default class WorkPackagesService { duration: number, blockedBy: WbsNumber[], descriptionBullets: DescriptionBulletPreview[], - organizationId: string + organizationId: string, + wbsElemId?: string ): Promise> { if (await userHasPermission(user.userId, organizationId, isGuest)) throw new AccessDeniedGuestException('create work packages'); const changeRequest = await validateChangeRequestAccepted(crId); + if (!wbsElemId) wbsElemId = changeRequest.wbsElementId; + const wbsElem = await prisma.wBS_Element.findUnique({ where: { - wbsElementId: changeRequest.wbsElementId + wbsElementId: wbsElemId }, include: { project: { @@ -183,7 +186,6 @@ export default class WorkPackagesService { }); if (!wbsElem) throw new NotFoundException('WBS Element', changeRequest.wbsElementId); - const blockedByElements: WBS_Element[] = await validateBlockedBys(blockedBy, organizationId); // get the corresponding project so we can find the next wbs number @@ -495,8 +497,8 @@ export default class WorkPackagesService { static async getBlockingWorkPackages(wbsNum: WbsNumber, organizationId: string): Promise { const { carNumber, projectNumber, workPackageNumber } = wbsNum; - // is a project so just return empty array until we implement blocking projects - if (isProject(wbsNum)) return []; + // is a project or car so just return empty array until we implement blocking projects/cars + if (!isWorkPackage(wbsNum)) return []; const wbsElement = await prisma.wBS_Element.findUnique({ where: { diff --git a/src/backend/src/transformers/cars.transformer.ts b/src/backend/src/transformers/cars.transformer.ts new file mode 100644 index 0000000000..1471d0147e --- /dev/null +++ b/src/backend/src/transformers/cars.transformer.ts @@ -0,0 +1,26 @@ +import { Prisma } from '@prisma/client'; +import { Car, WbsElementStatus } from 'shared'; +import { CarQueryArgs } from '../prisma-query-args/cars.query-args'; +import { descBulletConverter } from '../utils/description-bullets.utils'; +import { wbsNumOf } from '../utils/utils'; +import { linkTransformer } from './links.transformer'; +import { assemblyTransformer, materialTransformer } from './material.transformer'; +import { userTransformer } from './user.transformer'; + +export const carTransformer = (car: Prisma.CarGetPayload): Car => { + return { + wbsElementId: car.wbsElementId, + id: car.carId, + wbsNum: wbsNumOf(car.wbsElement), + dateCreated: car.wbsElement.dateCreated, + name: car.wbsElement.name, + links: car.wbsElement.links.map(linkTransformer), + status: car.wbsElement.status as WbsElementStatus, + lead: car.wbsElement.lead ? userTransformer(car.wbsElement.lead) : undefined, + manager: car.wbsElement.manager ? userTransformer(car.wbsElement.manager) : undefined, + descriptionBullets: car.wbsElement.descriptionBullets.map(descBulletConverter), + materials: car.wbsElement.materials.map(materialTransformer), + assemblies: car.wbsElement.assemblies.map(assemblyTransformer), + changes: [] + }; +}; diff --git a/src/backend/src/transformers/change-requests.transformer.ts b/src/backend/src/transformers/change-requests.transformer.ts index 3f65d7d362..6cefbfd9ff 100644 --- a/src/backend/src/transformers/change-requests.transformer.ts +++ b/src/backend/src/transformers/change-requests.transformer.ts @@ -17,7 +17,10 @@ import { userTransformer } from './user.transformer'; import { descBulletConverter } from '../utils/description-bullets.utils'; import { linkTransformer } from './links.transformer'; import teamTransformer from './teams.transformer'; -import { WbsProposedChangeQueryArgs } from '../prisma-query-args/scope-change-requests.query-args'; +import { + WbsProposedChangeQueryArgs, + WorkPackageProposedChangesQueryArgs +} from '../prisma-query-args/scope-change-requests.query-args'; import { HttpException } from '../utils/errors.utils'; import { ChangeRequestQueryArgs } from '../prisma-query-args/change-requests.query-args'; @@ -38,27 +41,30 @@ const projectProposedChangesTransformer = ( budget: projectProposedChanges.budget, descriptionBullets: wbsProposedChanges.proposedDescriptionBulletChanges.map(descBulletConverter), teams: projectProposedChanges.teams.map(teamTransformer), - carNumber: projectProposedChanges.car?.wbsElement.carNumber ?? undefined + carNumber: projectProposedChanges.car?.wbsElement.carNumber ?? undefined, + workPackageProposedChanges: projectProposedChanges.workPackageProposedChanges.map(workPackageProposedChangesTransformer) }; }; const workPackageProposedChangesTransformer = ( - wbsProposedChanges: Prisma.Wbs_Proposed_ChangesGetPayload + workPackageProposedChanges: Prisma.Work_Package_Proposed_ChangesGetPayload ): WorkPackageProposedChanges => { - const { workPackageProposedChanges } = wbsProposedChanges; - if (!workPackageProposedChanges) throw new HttpException(404, 'Work Package Proposed Changes not found'); - return { - id: wbsProposedChanges.wbsProposedChangesId, - name: wbsProposedChanges.name, - status: wbsProposedChanges.status as WbsElementStatus, - links: wbsProposedChanges.links.map(linkTransformer), - lead: wbsProposedChanges.lead ? userTransformer(wbsProposedChanges.lead) : undefined, - manager: wbsProposedChanges.manager ? userTransformer(wbsProposedChanges.manager) : undefined, + id: workPackageProposedChanges.wbsProposedChangesId, + name: workPackageProposedChanges.wbsProposedChanges.name, + status: workPackageProposedChanges.wbsProposedChanges.status as WbsElementStatus, + links: workPackageProposedChanges.wbsProposedChanges.links.map(linkTransformer), + lead: workPackageProposedChanges.wbsProposedChanges.lead + ? userTransformer(workPackageProposedChanges.wbsProposedChanges.lead) + : undefined, + manager: workPackageProposedChanges.wbsProposedChanges.manager + ? userTransformer(workPackageProposedChanges.wbsProposedChanges.manager) + : undefined, startDate: workPackageProposedChanges.startDate, duration: workPackageProposedChanges.duration, blockedBy: workPackageProposedChanges.blockedBy.map(wbsNumOf), - descriptionBullets: wbsProposedChanges.proposedDescriptionBulletChanges.map(descBulletConverter), + descriptionBullets: + workPackageProposedChanges.wbsProposedChanges.proposedDescriptionBulletChanges.map(descBulletConverter), stage: (workPackageProposedChanges.stage as WorkPackageStage) || undefined }; }; @@ -93,10 +99,10 @@ const changeRequestTransformer = ( status, // scope cr fields projectProposedChanges: changeRequest.scopeChangeRequest?.wbsProposedChanges?.projectProposedChanges - ? projectProposedChangesTransformer(changeRequest.scopeChangeRequest?.wbsProposedChanges) + ? projectProposedChangesTransformer(changeRequest.scopeChangeRequest.wbsProposedChanges) : undefined, workPackageProposedChanges: changeRequest.scopeChangeRequest?.wbsProposedChanges?.workPackageProposedChanges - ? workPackageProposedChangesTransformer(changeRequest.scopeChangeRequest?.wbsProposedChanges) + ? workPackageProposedChangesTransformer(changeRequest.scopeChangeRequest.wbsProposedChanges.workPackageProposedChanges) : undefined, what: changeRequest.scopeChangeRequest?.what ?? undefined, why: changeRequest.scopeChangeRequest?.why.map((why) => ({ @@ -110,10 +116,10 @@ const changeRequestTransformer = ( ? changeRequest.scopeChangeRequest?.proposedSolutions.map(proposedSolutionTransformer) ?? [] : undefined, originalProjectData: changeRequest.scopeChangeRequest?.wbsOriginalData?.projectProposedChanges - ? projectProposedChangesTransformer(changeRequest.scopeChangeRequest?.wbsOriginalData) + ? projectProposedChangesTransformer(changeRequest.scopeChangeRequest.wbsOriginalData) : undefined, originalWorkPackageData: changeRequest.scopeChangeRequest?.wbsOriginalData?.workPackageProposedChanges - ? workPackageProposedChangesTransformer(changeRequest.scopeChangeRequest?.wbsOriginalData) + ? workPackageProposedChangesTransformer(changeRequest.scopeChangeRequest.wbsOriginalData.workPackageProposedChanges) : undefined, // activation cr fields lead: changeRequest.activationChangeRequest?.lead diff --git a/src/backend/src/utils/auth.utils.ts b/src/backend/src/utils/auth.utils.ts index a698527c48..31989c576a 100644 --- a/src/backend/src/utils/auth.utils.ts +++ b/src/backend/src/utils/auth.utils.ts @@ -101,7 +101,6 @@ const notificationEndpointAuth = (req: Request, res: Response, next: NextFunctio */ export const getCurrentUser = async (res: Response): Promise => { const { userId } = res.locals; - console.log(userId); const user = await prisma.user.findUnique({ where: { userId } }); if (!user) throw new NotFoundException('User', userId); diff --git a/src/backend/src/utils/change-requests.utils.ts b/src/backend/src/utils/change-requests.utils.ts index 73ccb3ab3f..36c0829b8b 100644 --- a/src/backend/src/utils/change-requests.utils.ts +++ b/src/backend/src/utils/change-requests.utils.ts @@ -8,9 +8,18 @@ import { Link_Type, Description_Bullet_Type, Project, - Work_Package + Work_Package, + WBS_Element } from '@prisma/client'; -import { addWeeksToDate, ChangeRequestReason, DescriptionBulletPreview, LinkCreateArgs, WorkPackageStage } from 'shared'; +import { + addWeeksToDate, + ChangeRequestReason, + DescriptionBulletPreview, + LinkCreateArgs, + WbsNumber, + WorkPackageProposedChangesCreateArgs, + WorkPackageStage +} from 'shared'; import { HttpException, NotFoundException } from './errors.utils'; import { ChangeRequestStatus } from 'shared'; import { buildChangeDetail, createChange } from './changes.utils'; @@ -19,13 +28,14 @@ import { ChangeRequestQueryArgs } from '../prisma-query-args/change-requests.que import { ProjectProposedChangesQueryArgs, WbsProposedChangeQueryArgs, - workPackageProposedChangesQueryArgs + WorkPackageProposedChangesQueryArgs } from '../prisma-query-args/scope-change-requests.query-args'; import ProjectsService from '../services/projects.services'; import WorkPackagesService from '../services/work-packages.services'; import { transformDate } from './datetime.utils'; import { descriptionBulletToDescriptionBulletPreview } from './description-bullets.utils'; import { sendSlackCRReviewedNotification } from './slack.utils'; +import { validateBlockedBys } from './work-packages.utils'; export const convertCRScopeWhyType = (whyType: Scope_CR_Why_Type): ChangeRequestReason => ({ @@ -171,29 +181,37 @@ export const allChangeRequestsReviewed = (changeRequests: Change_Request[]) => { return changeRequests.every((changeRequest) => changeRequest.dateReviewed); }; -export interface ProposedChangedValidationResult { +export interface ProposedChangedValidationResult { + originalElement: T; links: (LinkCreateArgs & { linkType: Link_Type })[]; descriptionBullets: (DescriptionBulletPreview & { descriptionBulletType: Description_Bullet_Type })[]; + validatedBlockedBys: WBS_Element[]; carId?: string; + workPackageProposedChanges: ProposedChangedValidationResult[]; } /** * Determines if the project lead, project manager, and links all exist + * @param name the name of the wbs element * @param links the links to be verified * @param descriptionBullets the description bullets to be verified + * @param workPackageProposedChanges the work package proposed changes to be verified * @param organizationId the organization id the current user is in * @param carNumber the car number of the change request's WBS element * @param leadId the lead id to be verified * @param managerId the manager id to be verified */ -export const validateProposedChangesFields = async ( +export const validateProposedChangesFields = async ( + originalElement: T, links: LinkCreateArgs[], descriptionBullets: DescriptionBulletPreview[], + blockedBy: WbsNumber[], + workPackageProposedChanges: WorkPackageProposedChangesCreateArgs[], organizationId: string, carNumber?: number, leadId?: string, managerId?: string -): Promise => { +): Promise> => { if (leadId) { const lead = await prisma.user.findUnique({ where: { userId: leadId }, include: { organizations: true } }); if (!lead) throw new NotFoundException('User', leadId); @@ -234,7 +252,8 @@ export const validateProposedChangesFields = async ( }); let foundCarId = undefined; - if (carNumber) { + // Car number could be zero and a truthy check would fail + if (carNumber !== undefined) { const carWbs = await prisma.wBS_Element.findUnique({ where: { wbsNumber: { @@ -252,10 +271,31 @@ export const validateProposedChangesFields = async ( foundCarId = carWbs.car.carId; } + const promises = workPackageProposedChanges.map(async (proposedChange) => { + return await validateProposedChangesFields( + proposedChange, + [], + proposedChange.descriptionBullets, + proposedChange.blockedBy, + [], + organizationId, + carNumber, + proposedChange.leadId, + proposedChange.managerId + ); + }); + + const resolvedChanges = await Promise.all(promises); + + const validatedBlockedBys = await validateBlockedBys(blockedBy, organizationId); + return { + originalElement, links: await Promise.all(linksWithLinkTypes), descriptionBullets: await Promise.all(descriptionBulletsWithTypes), - carId: foundCarId + validatedBlockedBys, + carId: foundCarId, + workPackageProposedChanges: resolvedChanges }; }; @@ -285,7 +325,7 @@ export const validateNoUnreviewedOpenCRs = async (wbsElemId: string) => { export const applyProjectProposedChanges = async ( wbsProposedChanges: Prisma.Wbs_Proposed_ChangesGetPayload, projectProposedChanges: Prisma.Project_Proposed_ChangesGetPayload, - associatedProject: Project, + associatedProject: Project | null, reviewer: User, crId: string, carNumber: number, @@ -302,8 +342,9 @@ export const applyProjectProposedChanges = async ( descriptionBulletToDescriptionBulletPreview ); - if (projectProposedChanges.car?.wbsElement.carNumber !== null) { - await ProjectsService.createProject( + let projectWbsElmeId: string | null = null; + if (!associatedProject) { + const proj = await ProjectsService.createProject( reviewer, crId, carNumber, @@ -317,8 +358,10 @@ export const applyProjectProposedChanges = async ( wbsProposedChanges.managerId, organizationId ); - } else if (associatedProject && projectProposedChanges.car.wbsElement.carNumber === null) { - await ProjectsService.editProject( + + projectWbsElmeId = proj.wbsElementId; + } else if (associatedProject) { + const proj = await ProjectsService.editProject( reviewer, associatedProject.projectId, crId, @@ -331,7 +374,23 @@ export const applyProjectProposedChanges = async ( wbsProposedChanges.managerId, organizationId ); + + projectWbsElmeId = proj.wbsElementId; } + + const promises = projectProposedChanges.workPackageProposedChanges.map(async (proposedChange) => { + await applyWorkPackageProposedChanges( + wbsProposedChanges, + proposedChange, + projectWbsElmeId, + null, + reviewer, + crId, + organizationId + ); + }); + + await Promise.all(promises); } }; @@ -347,24 +406,27 @@ export const applyProjectProposedChanges = async ( */ export const applyWorkPackageProposedChanges = async ( wbsProposedChanges: Prisma.Wbs_Proposed_ChangesGetPayload, - workPackageProposedChanges: Prisma.Work_Package_Proposed_ChangesGetPayload, - associatedProject: Project | null, + workPackageProposedChanges: Prisma.Work_Package_Proposed_ChangesGetPayload, + existingWbsElementId: string | null, associatedWorkPackage: Work_Package | null, reviewer: User, crId: string, organizationId: string ) => { - if (associatedProject) { + if (existingWbsElementId) { await WorkPackagesService.createWorkPackage( reviewer, - wbsProposedChanges.name, + workPackageProposedChanges.wbsProposedChanges.name, crId, workPackageProposedChanges.stage as WorkPackageStage, transformDate(workPackageProposedChanges.startDate), workPackageProposedChanges.duration, workPackageProposedChanges.blockedBy, - wbsProposedChanges.proposedDescriptionBulletChanges.map(descriptionBulletToDescriptionBulletPreview), - organizationId + workPackageProposedChanges.wbsProposedChanges.proposedDescriptionBulletChanges.map( + descriptionBulletToDescriptionBulletPreview + ), + organizationId, + existingWbsElementId ); } else if (associatedWorkPackage) { if (wbsProposedChanges.leadId === null) { diff --git a/src/backend/src/utils/projects.utils.ts b/src/backend/src/utils/projects.utils.ts index 5ebbee2609..7256468e7d 100644 --- a/src/backend/src/utils/projects.utils.ts +++ b/src/backend/src/utils/projects.utils.ts @@ -63,8 +63,8 @@ export const updateProjectAndCreateChanges = async ( include: { wbsElement: { include: { - links: getLinkQueryArgs(organizationId), - descriptionBullets: getDescriptionBulletQueryArgs(organizationId) + links: { where: { dateDeleted: null }, ...getLinkQueryArgs(organizationId) }, + descriptionBullets: { where: { dateDeleted: null }, ...getDescriptionBulletQueryArgs(organizationId) } } } } diff --git a/src/backend/src/utils/validation.utils.ts b/src/backend/src/utils/validation.utils.ts index b2b5a908f9..0fbe7d34cd 100644 --- a/src/backend/src/utils/validation.utils.ts +++ b/src/backend/src/utils/validation.utils.ts @@ -77,6 +77,24 @@ const projectProposedChangesExists = (validationObject: ValidationChain): Valida return validationObject.if((_value: any, { req }: any) => req.body.projectProposedChanges); }; +const workPackageProposedChangesExists = (validationObject: ValidationChain): ValidationChain => { + return validationObject.if((_value: any, { req }: any) => req.body.workPackageProposedChanges); +}; + +export const workPackageProposedChangesValidators = (base: string) => [ + body(base).optional(), + nonEmptyString(workPackageProposedChangesExists(body(`${base}.name`))), + nonEmptyString(body(`${base}.leadId`).optional()), + nonEmptyString(body(`${base}.managerId`).optional()), + isWorkPackageStageOrNone(workPackageProposedChangesExists(body(`${base}.stage`).optional())), + isDate(workPackageProposedChangesExists(body(`${base}.startDate`))), + intMinZero(workPackageProposedChangesExists(body(`${base}.duration`))), + workPackageProposedChangesExists(body(`${base}.blockedBy`)).isArray(), + intMinZero(body(`${base}.blockedBy.*.carNumber`)), + intMinZero(body(`${base}.blockedBy.*.projectNumber`)), + intMinZero(body(`${base}.blockedBy.*.workPackageNumber`)) +]; + export const projectProposedChangesValidators = [ body('projectProposedChanges').optional(), nonEmptyString(projectProposedChangesExists(body('projectProposedChanges.name'))), @@ -89,25 +107,9 @@ export const projectProposedChangesValidators = [ intMinZero(projectProposedChangesExists(body('projectProposedChanges.budget'))), projectProposedChangesExists(body('projectProposedChanges.teamIds')).isArray(), nonEmptyString(body('projectProposedChanges.teamIds.*')), - projectProposedChangesExists(body('projectProposedChanges.carNumber')).optional().isInt() -]; - -const workPackageProposedChangesExists = (validationObject: ValidationChain): ValidationChain => { - return validationObject.if((_value: any, { req }: any) => req.body.workPackageProposedChanges); -}; - -export const workPackageProposedChangesValidators = [ - body('workPackageProposedChanges').optional(), - nonEmptyString(workPackageProposedChangesExists(body('workPackageProposedChanges.name'))), - nonEmptyString(body('workPackageProposedChanges.leadId').optional()), - nonEmptyString(body('workPackageProposedChanges.managerId').optional()), - isWorkPackageStageOrNone(workPackageProposedChangesExists(body('workPackageProposedChanges.stage').optional())), - isDate(workPackageProposedChangesExists(body('workPackageProposedChanges.startDate'))), - intMinZero(workPackageProposedChangesExists(body('workPackageProposedChanges.duration'))), - workPackageProposedChangesExists(body('workPackageProposedChanges.blockedBy')).isArray(), - intMinZero(body('workPackageProposedChanges.blockedBy.*.carNumber')), - intMinZero(body('workPackageProposedChanges.blockedBy.*.projectNumber')), - intMinZero(body('workPackageProposedChanges.blockedBy.*.workPackageNumber')) + projectProposedChangesExists(body('projectProposedChanges.carNumber')).optional().isInt(), + projectProposedChangesExists(body('projectProposedChanges.workPackageProposedChanges')).isArray(), + ...workPackageProposedChangesValidators('projectProposedChanges.workPackageProposedChanges.*') ]; export const isTaskPriority = (validationObject: ValidationChain): ValidationChain => { diff --git a/src/frontend/src/apis/cars.api.ts b/src/frontend/src/apis/cars.api.ts new file mode 100644 index 0000000000..b1869c606a --- /dev/null +++ b/src/frontend/src/apis/cars.api.ts @@ -0,0 +1,12 @@ +import { Car } from 'shared'; +import { CreateCarPayload } from '../hooks/cars.hooks'; +import axios from '../utils/axios'; +import { apiUrls } from '../utils/urls'; + +export const getAllCars = async () => { + return await axios.get(apiUrls.cars()); +}; + +export const createCar = async (payload: CreateCarPayload) => { + return await axios.post(apiUrls.carsCreate(), payload); +}; diff --git a/src/frontend/src/apis/design-reviews.api.ts b/src/frontend/src/apis/design-reviews.api.ts index ecaacceb0b..8611ceefd3 100644 --- a/src/frontend/src/apis/design-reviews.api.ts +++ b/src/frontend/src/apis/design-reviews.api.ts @@ -2,7 +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 { CreateTeamTypePayload, EditDesignReviewPayload } from '../hooks/design-reviews.hooks'; import axios from '../utils/axios'; import { DesignReview } from 'shared'; import { apiUrls } from '../utils/urls'; @@ -30,11 +30,15 @@ export const getAllDesignReviews = () => { * Gets all the team types */ export const getAllTeamTypes = () => { - return axios.get(apiUrls.teamTypes(), { + return axios.get(apiUrls.allTeamTypes(), { transformResponse: (data) => JSON.parse(data) }); }; +export const createTeamType = async (payload: CreateTeamTypePayload) => { + return axios.post(apiUrls.teamTypesCreate(), payload); +}; + /** * Edit a design review * diff --git a/src/frontend/src/components/ChangeRequestDetailCard.tsx b/src/frontend/src/components/ChangeRequestDetailCard.tsx index 82e4c07556..852f6d0007 100644 --- a/src/frontend/src/components/ChangeRequestDetailCard.tsx +++ b/src/frontend/src/components/ChangeRequestDetailCard.tsx @@ -80,7 +80,7 @@ const ChangeRequestDetailCard: React.FC = ({ chang to={`${routes.CHANGE_REQUESTS}/${changeRequest.crId}`} > - {'Change Request #' + changeRequest.crId} + {'Change Request #' + changeRequest.identifier} diff --git a/src/frontend/src/components/ChangeRequestDropdown.tsx b/src/frontend/src/components/ChangeRequestDropdown.tsx index a16830bd32..a30da649b2 100644 --- a/src/frontend/src/components/ChangeRequestDropdown.tsx +++ b/src/frontend/src/components/ChangeRequestDropdown.tsx @@ -46,7 +46,7 @@ const ChangeRequestDropdown = ({ control, name }: ChangeRequestDropdownProps) => const approvedChangeRequestOptions = filteredRequests.map((cr) => ({ label: `${cr.identifier} - ${wbsPipe(cr.wbsNum)} - ${cr.submitter.firstName} ${cr.submitter.lastName} - ${cr.type}`, - id: cr.crId.toString() + id: cr.crId })); return ( diff --git a/src/frontend/src/hooks/auth.hooks.ts b/src/frontend/src/hooks/auth.hooks.ts index d77632bbcd..6aeb5b96e5 100644 --- a/src/frontend/src/hooks/auth.hooks.ts +++ b/src/frontend/src/hooks/auth.hooks.ts @@ -16,7 +16,6 @@ export const useProvideAuth = () => { const [user, setUser] = useState(undefined); const devSignin = async (userId: string) => { - console.log('devSignin', userId); const user = await mutateAsyncDev(userId); setUser(user); localStorage.setItem('devUserId', userId.toString()); diff --git a/src/frontend/src/hooks/cars.hooks.ts b/src/frontend/src/hooks/cars.hooks.ts new file mode 100644 index 0000000000..58e226929b --- /dev/null +++ b/src/frontend/src/hooks/cars.hooks.ts @@ -0,0 +1,33 @@ +import { useMutation, useQuery, useQueryClient } from 'react-query'; +import { Car } from 'shared'; +import { createCar, getAllCars } from '../apis/cars.api'; + +export interface CreateCarPayload { + name: string; +} + +/** + * Custom React Hook to supply all change requests. + */ +export const useGetAllCars = () => { + return useQuery(['cars'], async () => { + const { data } = await getAllCars(); + return data; + }); +}; + +export const useCreateCar = () => { + const queryClient = useQueryClient(); + return useMutation( + ['cars', 'create'], + async (payload) => { + const { data } = await createCar(payload); + return data; + }, + { + onSuccess: () => { + queryClient.invalidateQueries(['cars']); + } + } + ); +}; diff --git a/src/frontend/src/hooks/design-reviews.hooks.ts b/src/frontend/src/hooks/design-reviews.hooks.ts index 5b7ec46845..2a9641035a 100644 --- a/src/frontend/src/hooks/design-reviews.hooks.ts +++ b/src/frontend/src/hooks/design-reviews.hooks.ts @@ -11,7 +11,8 @@ import { getAllDesignReviews, getAllTeamTypes, getSingleDesignReview, - markUserConfirmed + markUserConfirmed, + createTeamType } from '../apis/design-reviews.api'; import { useCurrentUser } from './users.hooks'; @@ -24,6 +25,11 @@ export interface CreateDesignReviewsPayload { meetingTimes: number[]; } +export interface CreateTeamTypePayload { + name: string; + iconName: string; +} + export const useCreateDesignReviews = () => { const queryClient = useQueryClient(); return useMutation( @@ -99,6 +105,27 @@ export const useAllTeamTypes = () => { }); }; +/** + * Custom react hook to create a team type + * + * @returns the team type created + */ +export const useCreateTeamType = () => { + const queryClient = useQueryClient(); + return useMutation( + ['teamTypes', 'create'], + async (teamTypePayload) => { + const { data } = await createTeamType(teamTypePayload); + return data; + }, + { + onSuccess: () => { + queryClient.invalidateQueries(['teamTypes']); + } + } + ); +}; + /** * Custom react hook to delete a design review */ diff --git a/src/frontend/src/pages/AdminToolsPage/AdminToolsPage.tsx b/src/frontend/src/pages/AdminToolsPage/AdminToolsPage.tsx index cb332373a9..57686c2f38 100644 --- a/src/frontend/src/pages/AdminToolsPage/AdminToolsPage.tsx +++ b/src/frontend/src/pages/AdminToolsPage/AdminToolsPage.tsx @@ -10,7 +10,7 @@ import { useCurrentUser } from '../../hooks/users.hooks'; import { isAdmin, isHead } from 'shared'; import PageLayout from '../../components/PageLayout'; import AdminToolsFinanceConfig from './AdminToolsFinanceConfig'; -import TeamsTools from './TeamsTools'; +import TeamsTools from './TeamConfig/TeamsTools'; import AdminToolsBOMConfig from './AdminToolsBOMConfig'; import AdminToolsProjectsConfig from './AdminToolsProjectsConfig'; import { useState } from 'react'; diff --git a/src/frontend/src/pages/AdminToolsPage/AdminToolsProjectsConfig.tsx b/src/frontend/src/pages/AdminToolsPage/AdminToolsProjectsConfig.tsx index e515a40830..605f7c1c77 100644 --- a/src/frontend/src/pages/AdminToolsPage/AdminToolsProjectsConfig.tsx +++ b/src/frontend/src/pages/AdminToolsPage/AdminToolsProjectsConfig.tsx @@ -3,10 +3,15 @@ import { Typography } from '@mui/material'; import WorkPackageTemplateTable from './ProjectsConfig/WorkPackageTemplateTable'; import LinkTypeTable from './ProjectsConfig/LinkTypeTable'; import DescriptionBulletTypeTable from './ProjectsConfig/DescriptionBulletTpeTable'; +import CarsTable from './ProjectsConfig/CarsTable'; const AdminToolsProjectsConfig: React.FC = () => { return ( + + Cars Config + + Links Config diff --git a/src/frontend/src/pages/AdminToolsPage/ProjectsConfig/CarsTable.tsx b/src/frontend/src/pages/AdminToolsPage/ProjectsConfig/CarsTable.tsx new file mode 100644 index 0000000000..2795ede1a9 --- /dev/null +++ b/src/frontend/src/pages/AdminToolsPage/ProjectsConfig/CarsTable.tsx @@ -0,0 +1,49 @@ +import { TableRow, TableCell, Box } from '@mui/material'; +import LoadingIndicator from '../../../components/LoadingIndicator'; +import { datePipe } from '../../../utils/pipes'; +import ErrorPage from '../../ErrorPage'; +import { NERButton } from '../../../components/NERButton'; +import AdminToolTable from '../AdminToolTable'; +import { useGetAllCars } from '../../../hooks/cars.hooks'; +import CreateCarModal from './CreateCarFormModal'; +import { useState } from 'react'; + +const CarsTable: React.FC = () => { + const { data: cars, isLoading: carsIsLoading, isError: carsIsError, error: carsError } = useGetAllCars(); + + const [openModal, setOpenModal] = useState(false); + + if (!cars || carsIsLoading) { + return ; + } + if (carsIsError) { + return ; + } + + const carsTableRows = cars.map((car) => ( + + {car.wbsNum.carNumber} + {car.name} + + {datePipe(car.dateCreated)} + + + )); + + return ( + + setOpenModal(false)} /> + + + setOpenModal(true)}> + New Car + + + + ); +}; + +export default CarsTable; diff --git a/src/frontend/src/pages/AdminToolsPage/ProjectsConfig/CreateCarFormModal.tsx b/src/frontend/src/pages/AdminToolsPage/ProjectsConfig/CreateCarFormModal.tsx new file mode 100644 index 0000000000..7428ee8ad5 --- /dev/null +++ b/src/frontend/src/pages/AdminToolsPage/ProjectsConfig/CreateCarFormModal.tsx @@ -0,0 +1,71 @@ +import { useForm } from 'react-hook-form'; +import NERFormModal from '../../../components/NERFormModal'; +import { FormControl, FormLabel, FormHelperText } from '@mui/material'; +import ReactHookTextField from '../../../components/ReactHookTextField'; +import { useToast } from '../../../hooks/toasts.hooks'; +import ErrorPage from '../../ErrorPage'; +import LoadingIndicator from '../../../components/LoadingIndicator'; +import * as yup from 'yup'; +import { yupResolver } from '@hookform/resolvers/yup'; +import { useCreateCar } from '../../../hooks/cars.hooks'; + +const schema = yup.object().shape({ + name: yup.string().required('Material Type is Required') +}); + +interface CreateCarModalProps { + showModal: boolean; + handleClose: () => void; +} + +const CreateCarModal: React.FC = ({ showModal, handleClose }) => { + const toast = useToast(); + const { isLoading, isError, error, mutateAsync } = useCreateCar(); + + const onSubmit = async (data: { name: string }) => { + try { + await mutateAsync(data); + } catch (error: unknown) { + if (error instanceof Error) { + toast.error(error.message); + } + } + handleClose(); + }; + + const { + handleSubmit, + control, + reset, + formState: { errors } + } = useForm({ + resolver: yupResolver(schema), + defaultValues: { + name: '' + } + }); + + if (isError) return ; + if (isLoading) return ; + + return ( + reset({ name: '' })} + handleUseFormSubmit={handleSubmit} + onFormSubmit={onSubmit} + formId="new-car-form" + showCloseButton + > + + Car + + {errors.name?.message} + + + ); +}; + +export default CreateCarModal; diff --git a/src/frontend/src/pages/AdminToolsPage/TeamConfig/CreateTeamForm.tsx b/src/frontend/src/pages/AdminToolsPage/TeamConfig/CreateTeamForm.tsx new file mode 100644 index 0000000000..39bea88b4d --- /dev/null +++ b/src/frontend/src/pages/AdminToolsPage/TeamConfig/CreateTeamForm.tsx @@ -0,0 +1,189 @@ +import { yupResolver } from '@hookform/resolvers/yup'; +import { useState } from 'react'; +import { Controller, useForm } from 'react-hook-form'; +import { useCreateTeam } from '../../../hooks/teams.hooks'; +import { useToast } from '../../../hooks/toasts.hooks'; +import { useAllUsers } from '../../../hooks/users.hooks'; +import * as yup from 'yup'; +import ErrorPage from '../../ErrorPage'; +import LoadingIndicator from '../../../components/LoadingIndicator'; +import { countWords, isHead, isUnderWordCount } from 'shared'; +import { userComparator, userToAutocompleteOption } from '../../../utils/teams.utils'; +import { Button, FormControl, FormLabel, TextField } from '@mui/material'; +import ReactHookTextField from '../../../components/ReactHookTextField'; +import NERAutocomplete from '../../../components/NERAutocomplete'; +import { Box, useTheme } from '@mui/system'; +import ReactMarkdown from 'react-markdown'; +import { NERButton } from '../../../components/NERButton'; + +const schema = yup.object().shape({ + teamName: yup.string().required('Team Name is Required'), + headId: yup.string().required('You must set a Head'), + slackId: yup.string().required('Team Channel SlackId is required'), + description: yup.string().required('Description is Required') +}); + +interface CreateTeamFormInput { + teamName: string; + headId: string; + slackId: string; + description: string; + isFinanceTeam: boolean; +} + +const defaultValues = { + teamName: '', + slackId: '', + description: '', + headId: '', + isFinanceTeam: false +}; + +const CreateTeamForm = () => { + const theme = useTheme(); + const [showPreview, setShowPreview] = useState(false); + const { isLoading, mutateAsync } = useCreateTeam(); + const { isLoading: allUsersIsLoading, isError: allUsersIsError, error: allUsersError, data: users } = useAllUsers(); + const toast = useToast(); + const { + handleSubmit, + control, + formState: { errors }, + reset + } = useForm({ + resolver: yupResolver(schema), + defaultValues + }); + + if (!users || allUsersIsLoading || isLoading) return ; + + if (allUsersIsError) return ; + const onFormSubmit = async (data: CreateTeamFormInput) => { + try { + await mutateAsync({ ...data }); + reset(defaultValues); + } catch (error: unknown) { + if (error instanceof Error) { + toast.error(error.message, 5000); + } + } + }; + + const headOptions = users + .filter((user) => isHead(user.role)) + .sort(userComparator) + .map(userToAutocompleteOption); + + return ( +
{ + e.preventDefault(); + e.stopPropagation(); + handleSubmit(onFormSubmit)(e); + }} + noValidate + > + + Team Name + + + + Slack Channel ID + + + + Head + ( + option.id === value) || { id: '', label: '' }} + onChange={(_event, newValue) => onChange(newValue ? newValue.id : '')} + options={headOptions} + id="create-team-head" + size="small" + placeholder="Choose a user" + errorMessage={errors.headId} + /> + )} + /> + + + ( + + + Description + + + {showPreview ? ( + {field.value} + ) : ( + { + field.onChange(e); + }} + error={!!errors.description || !isUnderWordCount(field.value, 300)} + helperText={errors.description ? errors.description.message : `${countWords(field.value)}/300 words`} + /> + )} + + )} + /> + + + + Finance Team + ( + field.onChange(!field.value)}> + {field.value ? 'Yes' : 'No'} + + )} + /> + + + Create Team + + +
+ ); +}; + +export default CreateTeamForm; diff --git a/src/frontend/src/pages/AdminToolsPage/TeamConfig/CreateTeamTypeFormModal.tsx b/src/frontend/src/pages/AdminToolsPage/TeamConfig/CreateTeamTypeFormModal.tsx new file mode 100644 index 0000000000..4822ce2590 --- /dev/null +++ b/src/frontend/src/pages/AdminToolsPage/TeamConfig/CreateTeamTypeFormModal.tsx @@ -0,0 +1,96 @@ +import { useForm } from 'react-hook-form'; +import NERFormModal from '../../../components/NERFormModal'; +import { FormControl, FormLabel, FormHelperText, Tooltip, Typography } from '@mui/material'; +import ReactHookTextField from '../../../components/ReactHookTextField'; +import { useToast } from '../../../hooks/toasts.hooks'; +import LoadingIndicator from '../../../components/LoadingIndicator'; +import * as yup from 'yup'; +import { yupResolver } from '@hookform/resolvers/yup'; +import { Box } from '@mui/system'; +import HelpIcon from '@mui/icons-material/Help'; +import { CreateTeamTypePayload, useCreateTeamType } from '../../../hooks/design-reviews.hooks'; + +const schema = yup.object().shape({ + name: yup.string().required('Material Type is Required'), + iconName: yup.string().required('Icon Name is Required') +}); + +interface CreateTeamTypeModalProps { + showModal: boolean; + handleClose: () => void; +} + +const CreateTeamTypeModal: React.FC = ({ showModal, handleClose }) => { + const toast = useToast(); + const { isLoading, mutateAsync } = useCreateTeamType(); + + const onSubmit = async (data: CreateTeamTypePayload) => { + try { + await mutateAsync(data); + } catch (error: unknown) { + if (error instanceof Error) { + toast.error(error.message); + } + } + handleClose(); + }; + + const { + handleSubmit, + control, + reset, + formState: { errors } + } = useForm({ + resolver: yupResolver(schema), + defaultValues: { + name: '', + iconName: '' + } + }); + + if (isLoading) return ; + + const TooltipMessage = () => ( + + Click to view possible icon names. For names with multiple words, seperate them with an _. AttachMoney = attach_money + + ); + + return ( + reset({ name: '', iconName: '' })} + handleUseFormSubmit={handleSubmit} + onFormSubmit={onSubmit} + formId="new-team-type-form" + showCloseButton + > + + Team Type + + {errors.name?.message} + + + + Icon Name + } placement="right"> + + + + + + + {errors.iconName?.message} + + + ); +}; + +export default CreateTeamTypeModal; diff --git a/src/frontend/src/pages/AdminToolsPage/TeamConfig/TeamTypeTable.tsx b/src/frontend/src/pages/AdminToolsPage/TeamConfig/TeamTypeTable.tsx new file mode 100644 index 0000000000..aa8b833336 --- /dev/null +++ b/src/frontend/src/pages/AdminToolsPage/TeamConfig/TeamTypeTable.tsx @@ -0,0 +1,58 @@ +import { TableRow, TableCell, Box, Typography, Icon } from '@mui/material'; +import LoadingIndicator from '../../../components/LoadingIndicator'; +import ErrorPage from '../../ErrorPage'; +import { NERButton } from '../../../components/NERButton'; +import { useState } from 'react'; +import AdminToolTable from '../AdminToolTable'; +import CreateTeamTypeModal from './CreateTeamTypeFormModal'; +import { useAllTeamTypes } from '../../../hooks/design-reviews.hooks'; + +const TeamTypeTable: React.FC = () => { + const { + data: teamTypes, + isLoading: teamTypesIsLoading, + isError: teamTypesIsError, + error: teamTypesError + } = useAllTeamTypes(); + const [createModalShow, setCreateModalShow] = useState(false); + + if (!teamTypes || teamTypesIsLoading) { + return ; + } + if (teamTypesIsError) { + return ; + } + + const teamTypesTableRows = teamTypes.map((teamType) => ( + + {teamType.name} + + + {teamType.iconName} + + {teamType.iconName} + + + + + )); + + return ( + + setCreateModalShow(false)} /> + + + { + setCreateModalShow(true); + }} + > + New Team Type + + + + ); +}; + +export default TeamTypeTable; diff --git a/src/frontend/src/pages/AdminToolsPage/TeamConfig/TeamsTools.tsx b/src/frontend/src/pages/AdminToolsPage/TeamConfig/TeamsTools.tsx new file mode 100644 index 0000000000..bc39202d7d --- /dev/null +++ b/src/frontend/src/pages/AdminToolsPage/TeamConfig/TeamsTools.tsx @@ -0,0 +1,55 @@ +import { Box, Grid, TableCell, TableRow, Typography } from '@mui/material'; +import { routes } from '../../../utils/routes'; +import { Link as RouterLink } from 'react-router-dom'; +import { useAllTeams } from '../../../hooks/teams.hooks'; +import { fullNamePipe } from '../../../utils/pipes'; +import AdminToolTable from '../AdminToolTable'; + +import LoadingIndicator from '../../../components/LoadingIndicator'; +import ErrorPage from '../../ErrorPage'; +import CreateTeamForm from './CreateTeamForm'; +import TeamTypeTable from './TeamTypeTable'; + +const TeamsTools = () => { + const { data: allTeams, isLoading: allTeamsIsLoading, isError: allTeamsIsError, error: allTeamsError } = useAllTeams(); + + if (!allTeams || allTeamsIsLoading) return ; + + if (allTeamsIsError) { + return ; + } + + const teamTableRows = allTeams.map((team) => ( + + {team.teamName} + {fullNamePipe(team.head)} + + {team.members.length} + + + )); + + return ( + + + Team Management + + + + + + + + + + + + + + ); +}; + +export default TeamsTools; diff --git a/src/frontend/src/pages/AdminToolsPage/TeamsTools.tsx b/src/frontend/src/pages/AdminToolsPage/TeamsTools.tsx deleted file mode 100644 index f7460d083f..0000000000 --- a/src/frontend/src/pages/AdminToolsPage/TeamsTools.tsx +++ /dev/null @@ -1,233 +0,0 @@ -import { - Box, - FormControl, - FormLabel, - Grid, - TableCell, - TableRow, - Button, - TextField, - useTheme, - Typography -} from '@mui/material'; -import { routes } from '../../utils/routes'; -import { Link as RouterLink } from 'react-router-dom'; -import { NERButton } from '../../components/NERButton'; -import { useAllTeams, useCreateTeam } from '../../hooks/teams.hooks'; -import { fullNamePipe } from '../../utils/pipes'; -import AdminToolTable from './AdminToolTable'; -import { useAllUsers } from '../../hooks/users.hooks'; -import { useToast } from '../../hooks/toasts.hooks'; -import { Controller, useForm } from 'react-hook-form'; -import { yupResolver } from '@hookform/resolvers/yup'; -import ReactMarkdown from 'react-markdown'; -import * as yup from 'yup'; -import LoadingIndicator from '../../components/LoadingIndicator'; -import ErrorPage from '../ErrorPage'; -import { isHead, isUnderWordCount, countWords } from 'shared'; -import { userComparator, userToAutocompleteOption } from '../../utils/teams.utils'; -import ReactHookTextField from '../../components/ReactHookTextField'; -import NERAutocomplete from '../../components/NERAutocomplete'; -import { useState } from 'react'; - -const schema = yup.object().shape({ - teamName: yup.string().required('Team Name is Required'), - headId: yup.string().required('You must set a Head'), - slackId: yup.string().required('Team Channel SlackId is required'), - description: yup.string().required('Description is Required') -}); - -interface CreateTeamFormInput { - teamName: string; - headId: string; - slackId: string; - description: string; - isFinanceTeam: boolean; -} - -const defaultValues = { - teamName: '', - slackId: '', - description: '', - headId: '', - isFinanceTeam: false -}; - -const TeamsTools = () => { - const { data: allTeams, isLoading: allTeamsIsLoading, isError: allTeamsIsError, error: allTeamsError } = useAllTeams(); - const { isLoading, mutateAsync } = useCreateTeam(); - const { isLoading: allUsersIsLoading, isError: allUsersIsError, error: allUsersError, data: users } = useAllUsers(); - const theme = useTheme(); - const [showPreview, setShowPreview] = useState(false); - const toast = useToast(); - const { - handleSubmit, - control, - formState: { errors }, - reset - } = useForm({ - resolver: yupResolver(schema), - defaultValues - }); - - if (!allTeams || allTeamsIsLoading || !users || allUsersIsLoading || isLoading) return ; - - if (allTeamsIsError) { - return ; - } - - if (allUsersIsError) return ; - const onFormSubmit = async (data: CreateTeamFormInput) => { - try { - await mutateAsync({ ...data }); - reset(defaultValues); - } catch (error: unknown) { - if (error instanceof Error) { - toast.error(error.message, 5000); - } - } - }; - - const headOptions = users - .filter((user) => isHead(user.role)) - .sort(userComparator) - .map(userToAutocompleteOption); - - const teamTableRows = allTeams.map((team) => ( - - {team.teamName} - {fullNamePipe(team.head)} - - {team.members.length} - - - )); - - return ( - - - Team Management - - - -
{ - e.preventDefault(); - e.stopPropagation(); - handleSubmit(onFormSubmit)(e); - }} - noValidate - > - - Team Name - - - - Slack Channel ID - - - - Head - ( - option.id === value) || { id: '', label: '' }} - onChange={(_event, newValue) => onChange(newValue ? newValue.id : '')} - options={headOptions} - id="create-team-head" - size="small" - placeholder="Choose a user" - errorMessage={errors.headId} - /> - )} - /> - - - ( - - - Description - - - {showPreview ? ( - {field.value} - ) : ( - { - field.onChange(e); - }} - error={!!errors.description || !isUnderWordCount(field.value, 300)} - helperText={errors.description ? errors.description.message : `${countWords(field.value)}/300 words`} - /> - )} - - )} - /> - - - - Finance Team - ( - field.onChange(!field.value)}> - {field.value ? 'Yes' : 'No'} - - )} - /> - - - Create Team - - -
-
- - - -
-
- ); -}; - -export default TeamsTools; diff --git a/src/frontend/src/pages/ChangeRequestDetailPage/ChangeRequestDetailsView.tsx b/src/frontend/src/pages/ChangeRequestDetailPage/ChangeRequestDetailsView.tsx index 89ee352749..5d84093f4d 100644 --- a/src/frontend/src/pages/ChangeRequestDetailPage/ChangeRequestDetailsView.tsx +++ b/src/frontend/src/pages/ChangeRequestDetailPage/ChangeRequestDetailsView.tsx @@ -5,7 +5,7 @@ import { ReactElement, useState } from 'react'; import { Link as RouterLink } from 'react-router-dom'; -import { ActivationChangeRequest, ChangeRequest, ChangeRequestType, StandardChangeRequest, isProject } from 'shared'; +import { ActivationChangeRequest, ChangeRequest, ChangeRequestType, StandardChangeRequest } from 'shared'; import { routes } from '../../utils/routes'; import { datePipe, fullNamePipe, wbsPipe } from '../../utils/pipes'; import ActivationDetails from './ActivationDetails'; @@ -16,9 +16,6 @@ import ReviewNotes from './ReviewNotes'; import ProposedSolutionsList from './ProposedSolutionsList'; import { Grid, Typography, Link, Box } from '@mui/material'; import DeleteChangeRequest from './DeleteChangeRequest'; -import { useSingleProject } from '../../hooks/projects.hooks'; -import LoadingIndicator from '../../components/LoadingIndicator'; -import ErrorPage from '../ErrorPage'; import PageLayout from '../../components/PageLayout'; import ChangeRequestActionMenu from './ChangeRequestActionMenu'; import OtherChangeRequestsPopupTabs from './OtherChangeRequestsPopupTabs'; @@ -57,21 +54,6 @@ const ChangeRequestDetailsView: React.FC = ({ const handleDeleteClose = () => setDeleteModalShow(false); const handleDeleteOpen = () => setDeleteModalShow(true); - const { - data: project, - isLoading, - isError, - error - } = useSingleProject({ - carNumber: changeRequest.wbsNum.carNumber, - projectNumber: changeRequest.wbsNum.projectNumber, - workPackageNumber: 0 - }); - if (isError) return ; - if (!project || isLoading) return ; - - const { name: projectName } = project; - const isStandard = changeRequest.type !== ChangeRequestType.Activation && changeRequest.type !== ChangeRequestType.StageGate; @@ -79,7 +61,7 @@ const ChangeRequestDetailsView: React.FC = ({ return ( @@ -104,8 +86,7 @@ const ChangeRequestDetailsView: React.FC = ({ WBS: - {wbsPipe(changeRequest.wbsNum)} - {projectName} - {isProject(changeRequest.wbsNum) ? '' : ' - ' + changeRequest.wbsName} + {changeRequest.wbsName} diff --git a/src/frontend/src/pages/ChangeRequestDetailPage/DeleteChangeRequest.tsx b/src/frontend/src/pages/ChangeRequestDetailPage/DeleteChangeRequest.tsx index c2cac29d25..d8435bdbd0 100644 --- a/src/frontend/src/pages/ChangeRequestDetailPage/DeleteChangeRequest.tsx +++ b/src/frontend/src/pages/ChangeRequestDetailPage/DeleteChangeRequest.tsx @@ -30,16 +30,15 @@ const DeleteChangeRequest: React.FC = ({ const toast = useToast(); const { isLoading, isError, error, mutateAsync } = useDeleteChangeRequest(); - const handleConfirm = async ({ crId }: DeleteChangeRequestInputs) => { + const handleConfirm = async () => { handleClose(); - const numCrId = crId; - await mutateAsync(numCrId).catch((error) => { + await mutateAsync(cr.crId).catch((error) => { if (error instanceof Error) { toast.error(error.message); } }); history.goBack(); - toast.success(`Change Request #${crId} Deleted Successfully!`); + toast.success(`Change Request #${cr.identifier} Deleted Successfully!`); }; if (isLoading) return ; diff --git a/src/frontend/src/pages/ChangeRequestDetailPage/DeleteChangeRequestView.tsx b/src/frontend/src/pages/ChangeRequestDetailPage/DeleteChangeRequestView.tsx index a0b21b684a..0242f83559 100644 --- a/src/frontend/src/pages/ChangeRequestDetailPage/DeleteChangeRequestView.tsx +++ b/src/frontend/src/pages/ChangeRequestDetailPage/DeleteChangeRequestView.tsx @@ -8,7 +8,6 @@ import { FormControl, FormLabel, Typography } from '@mui/material'; import { useForm } from 'react-hook-form'; import * as yup from 'yup'; import { yupResolver } from '@hookform/resolvers/yup'; -import { DeleteChangeRequestInputs } from './DeleteChangeRequest'; import ReactHookTextField from '../../components/ReactHookTextField'; import NERFormModal from '../../components/NERFormModal'; @@ -16,14 +15,15 @@ interface DeleteChangeRequestViewProps { changeRequest: ChangeRequest; modalShow: boolean; onHide: () => void; - onSubmit: (data: DeleteChangeRequestInputs) => Promise; + onSubmit: () => Promise; } const DeleteChangeRequestView: React.FC = ({ changeRequest, modalShow, onHide, onSubmit }) => { - const changeRequestIdTester = (crId: string | undefined) => crId !== undefined && crId === changeRequest.crId.toString(); + const changeRequestIdTester = (identifier: string | undefined) => + identifier !== undefined && identifier === changeRequest.identifier.toString(); const schema = yup.object().shape({ - crId: yup.string().required().test('cr-id-test', 'Change Request ID does not match', changeRequestIdTester) + identifier: yup.string().required().test('cr-identifier-test', 'Change Request ID does not match', changeRequestIdTester) }); const { @@ -34,7 +34,7 @@ const DeleteChangeRequestView: React.FC = ({ chang } = useForm({ resolver: yupResolver(schema), defaultValues: { - crId: '' + identifier: '' }, mode: 'onChange' }); @@ -43,7 +43,7 @@ const DeleteChangeRequestView: React.FC = ({ chang = ({ chang showCloseButton > - Are you sure you want to delete Change Request #{changeRequest.crId}? + Are you sure you want to delete Change Request #{changeRequest.identifier}? This action cannot be undone! @@ -61,8 +61,8 @@ const DeleteChangeRequestView: React.FC = ({ chang = ({ }) => { const theme = useTheme(); - const changeBullets: ChangeBullet[] = []; + const changeBullets: ChangeBullet[][] = [[{ label: 'Proposed Changes', detail: 'None' }]]; for (const projectKey in projectProposedChanges) { if (projectProposedChanges.hasOwnProperty(projectKey)) { - changeBullets.push({ - label: projectKey, - detail: projectProposedChanges[projectKey as keyof ProjectProposedChangesPreview]! - }); + if (projectKey === 'workPackageProposedChanges') { + for (const workPackage of projectProposedChanges.workPackageProposedChanges) { + const wpChangeBullets: ChangeBullet[] = [{ label: 'Work Package Proposed Changes', detail: 'None' }]; + for (let workPackageKey in workPackage) { + if (workPackage.hasOwnProperty(workPackageKey)) { + if (workPackageKey === 'duration') { + workPackageKey = 'endDate'; + + const startDate = new Date( + new Date(workPackage.startDate).getTime() - new Date(workPackage.startDate).getTimezoneOffset() * -6000 + ); + + const { duration } = workPackage; + console.log(duration); + const endDate = calculateEndDate(startDate, duration); + wpChangeBullets.push({ + label: 'endDate', + detail: endDate + }); + } else { + wpChangeBullets.push({ + label: workPackageKey, + detail: workPackage[workPackageKey as keyof WorkPackageProposedChangesPreview]! + }); + } + } + } + changeBullets.push(wpChangeBullets); + } + } else { + changeBullets[0].push({ + label: projectKey, + detail: projectProposedChanges[projectKey as keyof ProjectProposedChangesPreview] as ProposedChangeValue + }); + } } } @@ -44,12 +76,12 @@ const DiffPanel: React.FC = ({ const { duration } = workPackageProposedChanges; const endDate = calculateEndDate(startDate, duration); - changeBullets.push({ + changeBullets[0].push({ label: 'endDate', detail: endDate }); } else { - changeBullets.push({ + changeBullets[0].push({ label: workPackageKey, detail: workPackageProposedChanges[workPackageKey as keyof WorkPackageProposedChangesPreview]! }); @@ -57,71 +89,82 @@ const DiffPanel: React.FC = ({ } } - const renderDetailText = (detailText: string | string[]) => { + const renderDetailText = (detailText: string | string[]): any => { + // We can reason that this function will eventually terminate. However typescript cannot. Take Logic and Computation for more info if (typeof detailText === 'string') { return ( {detailText} ); + } else if (Array.isArray(detailText) && detailText.length > 0) { + if (typeof detailText[0] === 'string') { + return ( + + {(detailText as string[]).map((bullet) => { + const url = bullet.includes('http') ? bullet.split(': ')[1] : undefined; + return ( + + {url ? ( + <> + {bullet.split(': ')[0]}:{' '} + + {bullet.split(': ')[1]} + + + ) : ( + bullet + )} + + ); + })} + + ); + } } - return ( - - {detailText.map((bullet) => { - const url = bullet.includes('http') ? bullet.split(': ')[1] : undefined; - return ( - - {url ? ( - <> - {bullet.split(': ')[0]}:{' '} - - {bullet.split(': ')[1]} - - - ) : ( - bullet - )} - - ); - })} - - ); }; return ( {changeBullets.map((changeBullet) => { - const detailText = changeBulletDetailText(changeBullet); - const potentialChangeType = potentialChangeTypeMap.get(changeBullet.label)!; + return ( + <> + {changeBullet[0].label} + {changeBullet.slice(1).map((changeBullet) => { + const detailText = changeBulletDetailText(changeBullet); + const potentialChangeType = potentialChangeTypeMap.get(changeBullet.label)!; - return potentialChangeType === PotentialChangeType.SAME ? ( - - {labelPipe(changeBullet.label)}: {renderDetailText(detailText)} - - ) : ( - - - - {labelPipe(changeBullet.label)}: - - - - {renderDetailText(detailText)} - - + return potentialChangeType === PotentialChangeType.SAME ? ( + + {labelPipe(changeBullet.label)}: {renderDetailText(detailText)} + + ) : ( + + + + {labelPipe(changeBullet.label)}: + + + + {renderDetailText(detailText)} + + + ); + })} + ); })} diff --git a/src/frontend/src/pages/ChangeRequestDetailPage/DiffSection/DiffSectionEdit.tsx b/src/frontend/src/pages/ChangeRequestDetailPage/DiffSection/DiffSectionEdit.tsx index b9865ae24c..a20c01c187 100644 --- a/src/frontend/src/pages/ChangeRequestDetailPage/DiffSection/DiffSectionEdit.tsx +++ b/src/frontend/src/pages/ChangeRequestDetailPage/DiffSection/DiffSectionEdit.tsx @@ -67,8 +67,42 @@ const DiffSectionEdit: React.FC = ({ if (projectProposedChanges.hasOwnProperty(projectKey)) { const originalValue = projectAsChanges![projectKey as keyof ProjectProposedChangesPreview]!; const proposedValue = projectProposedChanges[projectKey as keyof ProjectProposedChangesPreview]!; + if (projectKey === 'workPackageProposedChanges') { + for (const workPackage of projectProposedChanges.workPackageProposedChanges) { + for (let workPackageKey in workPackage) { + if (workPackage.hasOwnProperty(workPackageKey)) { + if (workPackageKey === 'duration') { + workPackageKey = 'endDate'; - if (valueChanged(originalValue as ProposedChangeValue, proposedValue)) { + const startDate = new Date( + new Date(workPackage.startDate).getTime() - new Date(workPackage.startDate).getTimezoneOffset() * -6000 + ); + + const { duration } = workPackage; + const endDate = calculateEndDate(startDate, duration); + if (valueChanged(originalValue as ProposedChangeValue, endDate)) { + originalMap.set(workPackageKey, PotentialChangeType.REMOVED); + proposedMap.set(workPackageKey, PotentialChangeType.ADDED); + } else { + originalMap.set(workPackageKey, PotentialChangeType.SAME); + proposedMap.set(workPackageKey, PotentialChangeType.SAME); + } + } else if ( + valueChanged( + originalValue as ProposedChangeValue, + workPackage[workPackageKey as keyof WorkPackageProposedChangesPreview]! + ) + ) { + originalMap.set(workPackageKey, PotentialChangeType.REMOVED); + proposedMap.set(workPackageKey, PotentialChangeType.ADDED); + } else { + originalMap.set(workPackageKey, PotentialChangeType.SAME); + proposedMap.set(workPackageKey, PotentialChangeType.SAME); + } + } + } + } + } else if (valueChanged(originalValue as ProposedChangeValue, proposedValue as ProposedChangeValue)) { originalMap.set(projectKey, PotentialChangeType.REMOVED); proposedMap.set(projectKey, PotentialChangeType.ADDED); } else { diff --git a/src/frontend/src/pages/ChangeRequestDetailPage/ReviewChangeRequestView.tsx b/src/frontend/src/pages/ChangeRequestDetailPage/ReviewChangeRequestView.tsx index adf09b2814..4191d9f73b 100644 --- a/src/frontend/src/pages/ChangeRequestDetailPage/ReviewChangeRequestView.tsx +++ b/src/frontend/src/pages/ChangeRequestDetailPage/ReviewChangeRequestView.tsx @@ -132,7 +132,7 @@ const ReviewChangeRequestsView: React.FC = ({ > - {`Review Change Request #${cr.crId}`} + {`Review Change Request #${cr.identifier}`} = ({ > - {`Review Change Request #${cr.crId}`} + {`Review Change Request #${cr.identifier}`}
{ const idColumn: GridColDef = { ...baseColDef, - field: 'crId', + field: 'identifier', type: 'number', headerName: 'ID', maxWidth: 75 @@ -162,7 +162,7 @@ const ChangeRequestsTable: React.FC = () => { const filterValues = JSON.parse( // sets filter to a default value if no filter is stored in local storage - localStorage.getItem('changeRequestsTableFilter') ?? '{"columnField": "crId", "operatorValue": "=", "value": ""}' + localStorage.getItem('changeRequestsTableFilter') ?? '{"columnField": "identifier", "operatorValue": "=", "value": ""}' ); return ( @@ -252,7 +252,7 @@ const ChangeRequestsTable: React.FC = () => { } }, sorting: { - sortModel: [{ field: 'crId', sort: 'desc' }] + sortModel: [{ field: 'identifier', sort: 'desc' }] }, columns: { columnVisibilityModel: { diff --git a/src/frontend/src/pages/GanttPage/AddProjectModal.tsx b/src/frontend/src/pages/GanttPage/AddProjectModal.tsx new file mode 100644 index 0000000000..c6fcd9a98a --- /dev/null +++ b/src/frontend/src/pages/GanttPage/AddProjectModal.tsx @@ -0,0 +1,63 @@ +import { yupResolver } from '@hookform/resolvers/yup'; +import { FormControl, FormHelperText, FormLabel } from '@mui/material'; +import { useForm } from 'react-hook-form'; +import * as yup from 'yup'; +import NERFormModal from '../../components/NERFormModal'; +import ReactHookTextField from '../../components/ReactHookTextField'; + +const schema = yup.object().shape({ + name: yup.string().required('Project name is Required'), + carNumber: yup.number().required('Car Number is Required') +}); + +interface AddProjectModalProps { + showModal: boolean; + handleClose: () => void; + addProject: (project: { name: string; carNumber: number }) => void; +} + +const AddProjectModal: React.FC = ({ showModal, handleClose, addProject }) => { + const onSubmit = async (data: { name: string; carNumber: number }) => { + addProject(data); + handleClose(); + }; + + const { + handleSubmit, + control, + reset, + formState: { errors } + } = useForm({ + resolver: yupResolver(schema), + defaultValues: { + name: '', + carNumber: 0 + } + }); + + return ( + reset({ name: '' })} + handleUseFormSubmit={handleSubmit} + onFormSubmit={onSubmit} + formId="new-project-form" + showCloseButton + > + + Project Name + + {errors.name?.message} + + + Car Number + + {errors.carNumber?.message} + + + ); +}; + +export default AddProjectModal; diff --git a/src/frontend/src/pages/GanttPage/AddWorkPackageModal.tsx b/src/frontend/src/pages/GanttPage/AddWorkPackageModal.tsx new file mode 100644 index 0000000000..fce0f894a5 --- /dev/null +++ b/src/frontend/src/pages/GanttPage/AddWorkPackageModal.tsx @@ -0,0 +1,75 @@ +import { yupResolver } from '@hookform/resolvers/yup'; +import { FormControl, FormHelperText, FormLabel, MenuItem, TextField } from '@mui/material'; +import { Controller, useForm } from 'react-hook-form'; +import { WorkPackageStage } from 'shared'; +import * as yup from 'yup'; +import NERFormModal from '../../components/NERFormModal'; +import ReactHookTextField from '../../components/ReactHookTextField'; + +const schema = yup.object().shape({ + name: yup.string().required('Work Package name is Required') +}); + +interface AddWorkPackageModalProps { + showModal: boolean; + handleClose: () => void; + addWorkPackage: (workPackge: { name: string; stage: WorkPackageStage }) => void; +} + +const AddWorkPackageModal: React.FC = ({ showModal, handleClose, addWorkPackage }) => { + const onSubmit = async (data: { name: string; stage: WorkPackageStage }) => { + addWorkPackage(data); + handleClose(); + }; + + const { + handleSubmit, + control, + reset, + formState: { errors } + } = useForm({ + resolver: yupResolver(schema), + defaultValues: { + name: '', + stage: 'NONE' + } + }); + + return ( + reset({ name: '' })} + handleUseFormSubmit={handleSubmit} + onFormSubmit={onSubmit} + formId="new-work-package-form" + showCloseButton + > + + Name + + {errors.name?.message} + + + Work Package Stage + ( + + NONE + {Object.values(WorkPackageStage).map((stage) => ( + + {stage} + + ))} + + )} + /> + + + ); +}; + +export default AddWorkPackageModal; diff --git a/src/frontend/src/pages/GanttPage/GanttChart.tsx b/src/frontend/src/pages/GanttPage/GanttChart.tsx index 2ec8c33963..0ead76f77a 100644 --- a/src/frontend/src/pages/GanttPage/GanttChart.tsx +++ b/src/frontend/src/pages/GanttPage/GanttChart.tsx @@ -11,6 +11,9 @@ interface GanttChartProps { showWorkPackagesMap: Map; setShowWorkPackagesMap: React.Dispatch>>; highlightedChange?: RequestEventChange; + addProject: (project: GanttTask) => void; + getNewProjectNumber: (carNumber: number) => number; + addWorkPackage: (workPackage: GanttTask) => void; } const GanttChart = ({ @@ -21,7 +24,10 @@ const GanttChart = ({ saveChanges, showWorkPackagesMap, setShowWorkPackagesMap, - highlightedChange + highlightedChange, + addProject, + addWorkPackage, + getNewProjectNumber }: GanttChartProps) => { return ( @@ -38,6 +44,9 @@ const GanttChart = ({ teamName={teamName} projectTasks={projectTasks} highlightedChange={highlightedChange} + addProject={addProject} + addWorkPackage={addWorkPackage} + getNewProjectNumber={getNewProjectNumber} /> ) : ( <> diff --git a/src/frontend/src/pages/GanttPage/GanttChartComponents/GanttChangeModals/GanttProjectCreateModal.tsx b/src/frontend/src/pages/GanttPage/GanttChartComponents/GanttChangeModals/GanttProjectCreateModal.tsx new file mode 100644 index 0000000000..75ec93b8a0 --- /dev/null +++ b/src/frontend/src/pages/GanttPage/GanttChartComponents/GanttChangeModals/GanttProjectCreateModal.tsx @@ -0,0 +1,124 @@ +import { Box, FormControl, InputLabel, MenuItem, Select, SelectChangeEvent, TextField, Typography } from '@mui/material'; +import { RequestEventChange } from '../../../../utils/gantt.utils'; +import { ChangeRequestReason, ChangeRequestType } from 'shared'; +import { useState } from 'react'; +import dayjs from 'dayjs'; +import { CreateStandardChangeRequestPayload, useCreateStandardChangeRequest } from '../../../../hooks/change-requests.hooks'; +import LoadingIndicator from '../../../../components/LoadingIndicator'; +import { useToast } from '../../../../hooks/toasts.hooks'; +import { NERDraggableFormModal } from '../../../../components/NERDraggableFormModal'; +import { useAllTeams } from '../../../../hooks/teams.hooks'; + +interface GanttProjectCreateModalProps { + change: RequestEventChange; + handleClose: () => void; + open: boolean; +} + +export const GanttProjectCreateModal = ({ change, handleClose, open }: GanttProjectCreateModalProps) => { + const toast = useToast(); + const [reasonForChange, setReasonForChange] = useState(ChangeRequestReason.Initialization); + const [explanationForChange, setExplanationForChange] = useState(''); + const { isLoading, mutateAsync } = useCreateStandardChangeRequest(); + const { isLoading: teamsLoading, data } = useAllTeams(); + + if (isLoading || teamsLoading || !data) return ; + + const handleReasonChange = (event: SelectChangeEvent) => { + setReasonForChange(event.target.value as ChangeRequestReason); + }; + + const handleExplanationChange = (event: React.ChangeEvent) => { + setExplanationForChange(event.target.value); + }; + + const changeInTimeline = (startDate: Date, endDate: Date) => { + return `${dayjs(startDate).format('MMMM D, YYYY')} - ${dayjs(endDate).format('MMMM D, YYYY')}`; + }; + + const handleSubmit = async () => { + if (!reasonForChange) { + return; + } + + const selectedTeam = data.find((team) => team.teamName === change.teamName); + + const teamIds = selectedTeam ? [selectedTeam.teamId] : []; + + const payload: CreateStandardChangeRequestPayload = { + wbsNum: change.baseWbs, + type: ChangeRequestType.Issue, + what: `Create New Project with timeline of: ${changeInTimeline(change.newStart, change.newEnd)}`, + why: [ + { + explain: explanationForChange, + type: reasonForChange + } + ], + proposedSolutions: [], + projectProposedChanges: { + name: change.name, + budget: 0, + summary: `New Project for ${change.name}`, + descriptionBullets: [], + leadId: undefined, + managerId: undefined, + links: [], + teamIds, + carNumber: change.baseWbs.carNumber, + workPackageProposedChanges: change.workPackageChanges.map((workPackage) => ({ + name: workPackage.name, + stage: workPackage.stage, + leadId: undefined, + managerId: undefined, + startDate: workPackage.newStart.toLocaleString(), + duration: workPackage.duration / 1000 / 60 / 60 / 24 / 7, + blockedBy: [], + descriptionBullets: [], + links: [] + })) + } + }; + try { + await mutateAsync(payload); + toast.success('Change Request Created Successfully!'); + handleClose(); + } catch (e) { + if (e instanceof Error) { + toast.error(e.message); + } + } + }; + + return ( + + + {`New: ${changeInTimeline(change.newStart, change.newEnd)}`} + + + Reason for Initialization + + + + + + + ); +}; diff --git a/src/frontend/src/pages/GanttPage/GanttChartComponents/GanttChangeModals/GanttRequestChangeModal.tsx b/src/frontend/src/pages/GanttPage/GanttChartComponents/GanttChangeModals/GanttRequestChangeModal.tsx new file mode 100644 index 0000000000..e12645dd76 --- /dev/null +++ b/src/frontend/src/pages/GanttPage/GanttChartComponents/GanttChangeModals/GanttRequestChangeModal.tsx @@ -0,0 +1,23 @@ +import { validateWBS } from 'shared'; +import { RequestEventChange } from '../../../../utils/gantt.utils'; +import { GanttProjectCreateModal } from './GanttProjectCreateModal'; +import { GanttTimeLineChangeModal } from './GanttTimeLineChangeModal'; +import { GanttWorkPackageCreateModal } from './GanttWorkPackageCreateModal'; + +interface GanttRequestChangeModalProps { + change: RequestEventChange; + handleClose: () => void; + open: boolean; +} + +export const GanttRequestChangeModal = ({ change, handleClose, open }: GanttRequestChangeModalProps) => { + if (change.createProject) { + return ; + } + try { + validateWBS(change.eventId); + return ; + } catch { + return ; + } +}; diff --git a/src/frontend/src/pages/GanttPage/GanttChartComponents/GanttRequestChangeModal.tsx b/src/frontend/src/pages/GanttPage/GanttChartComponents/GanttChangeModals/GanttTimeLineChangeModal.tsx similarity index 85% rename from src/frontend/src/pages/GanttPage/GanttChartComponents/GanttRequestChangeModal.tsx rename to src/frontend/src/pages/GanttPage/GanttChartComponents/GanttChangeModals/GanttTimeLineChangeModal.tsx index 765d6e18b3..21caa349d8 100644 --- a/src/frontend/src/pages/GanttPage/GanttChartComponents/GanttRequestChangeModal.tsx +++ b/src/frontend/src/pages/GanttPage/GanttChartComponents/GanttChangeModals/GanttTimeLineChangeModal.tsx @@ -1,22 +1,22 @@ import { Box, FormControl, InputLabel, MenuItem, Select, SelectChangeEvent, TextField, Typography } from '@mui/material'; -import { RequestEventChange } from '../../../utils/gantt.utils'; +import { RequestEventChange } from '../../../../utils/gantt.utils'; import { ChangeRequestReason, ChangeRequestType, validateWBS } from 'shared'; import { useState } from 'react'; import dayjs from 'dayjs'; -import { useCreateStandardChangeRequest } from '../../../hooks/change-requests.hooks'; -import LoadingIndicator from '../../../components/LoadingIndicator'; -import ErrorPage from '../../ErrorPage'; -import { useSingleWorkPackage } from '../../../hooks/work-packages.hooks'; -import { useToast } from '../../../hooks/toasts.hooks'; -import { NERDraggableFormModal } from '../../../components/NERDraggableFormModal'; +import { useCreateStandardChangeRequest } from '../../../../hooks/change-requests.hooks'; +import LoadingIndicator from '../../../../components/LoadingIndicator'; +import ErrorPage from '../../../ErrorPage'; +import { useSingleWorkPackage } from '../../../../hooks/work-packages.hooks'; +import { useToast } from '../../../../hooks/toasts.hooks'; +import { NERDraggableFormModal } from '../../../../components/NERDraggableFormModal'; -interface GanttRequestChangeModalProps { +interface GanttTimeLineChangeModalProps { change: RequestEventChange; handleClose: () => void; open: boolean; } -export const GanttRequestChangeModal = ({ change, handleClose, open }: GanttRequestChangeModalProps) => { +export const GanttTimeLineChangeModal = ({ change, handleClose, open }: GanttTimeLineChangeModalProps) => { const toast = useToast(); const [reasonForChange, setReasonForChange] = useState(ChangeRequestReason.Estimation); const [explanationForChange, setExplanationForChange] = useState(''); @@ -74,6 +74,7 @@ export const GanttRequestChangeModal = ({ change, handleClose, open }: GanttRequ links: [] } }; + try { await mutateAsync(payload); toast.success('Change Request Created Successfully!'); diff --git a/src/frontend/src/pages/GanttPage/GanttChartComponents/GanttChangeModals/GanttWorkPackageCreateModal.tsx b/src/frontend/src/pages/GanttPage/GanttChartComponents/GanttChangeModals/GanttWorkPackageCreateModal.tsx new file mode 100644 index 0000000000..3a7b26f361 --- /dev/null +++ b/src/frontend/src/pages/GanttPage/GanttChartComponents/GanttChangeModals/GanttWorkPackageCreateModal.tsx @@ -0,0 +1,107 @@ +import { Box, FormControl, InputLabel, MenuItem, Select, SelectChangeEvent, TextField, Typography } from '@mui/material'; +import { RequestEventChange } from '../../../../utils/gantt.utils'; +import { ChangeRequestReason, ChangeRequestType } from 'shared'; +import { useState } from 'react'; +import dayjs from 'dayjs'; +import { useCreateStandardChangeRequest } from '../../../../hooks/change-requests.hooks'; +import LoadingIndicator from '../../../../components/LoadingIndicator'; +import { useToast } from '../../../../hooks/toasts.hooks'; +import { NERDraggableFormModal } from '../../../../components/NERDraggableFormModal'; + +interface GanttWorkPackageCreateModalProps { + change: RequestEventChange; + handleClose: () => void; + open: boolean; +} + +export const GanttWorkPackageCreateModal = ({ change, handleClose, open }: GanttWorkPackageCreateModalProps) => { + const toast = useToast(); + const [reasonForChange, setReasonForChange] = useState(ChangeRequestReason.Initialization); + const [explanationForChange, setExplanationForChange] = useState(''); + const { isLoading, mutateAsync } = useCreateStandardChangeRequest(); + + if (isLoading) return ; + + const handleReasonChange = (event: SelectChangeEvent) => { + setReasonForChange(event.target.value as ChangeRequestReason); + }; + + const handleExplanationChange = (event: React.ChangeEvent) => { + setExplanationForChange(event.target.value); + }; + + const changeInTimeline = (startDate: Date, endDate: Date) => { + return `${dayjs(startDate).format('MMMM D, YYYY')} - ${dayjs(endDate).format('MMMM D, YYYY')}`; + }; + + const handleSubmit = async () => { + if (!reasonForChange) { + return; + } + + const payload = { + wbsNum: change.baseWbs, + type: ChangeRequestType.Issue, + what: `Create New Work Package with timeline of: ${changeInTimeline(change.newStart, change.newEnd)}`, + why: [ + { + explain: explanationForChange, + type: reasonForChange + } + ], + proposedSolutions: [], + workPackageProposedChanges: { + name: change.name, + stage: change.stage, + duration: change.duration / 1000 / 60 / 60 / 24 / 7, + startDate: change.newStart.toLocaleDateString(), + blockedBy: [], + descriptionBullets: [], + leadId: undefined, + managerId: undefined, + links: [] + } + }; + try { + await mutateAsync(payload); + toast.success('Change Request Created Successfully!'); + handleClose(); + } catch (e) { + if (e instanceof Error) { + toast.error(e.message); + } + } + }; + + return ( + + + {`New: ${changeInTimeline(change.newStart, change.newEnd)}`} + + + Reason for Initialization + + + + + + + ); +}; diff --git a/src/frontend/src/pages/GanttPage/GanttChartComponents/GanttTaskBar/GanttTaskBar.tsx b/src/frontend/src/pages/GanttPage/GanttChartComponents/GanttTaskBar/GanttTaskBar.tsx index 61ab3085ea..f6b7217b54 100644 --- a/src/frontend/src/pages/GanttPage/GanttChartComponents/GanttTaskBar/GanttTaskBar.tsx +++ b/src/frontend/src/pages/GanttPage/GanttChartComponents/GanttTaskBar/GanttTaskBar.tsx @@ -17,7 +17,8 @@ const GanttTaskBar = ({ onWorkPackageToggle, handleOnMouseLeave, showWorkPackages = false, - highlightedChange + highlightedChange, + addWorkPackage = () => {} }: { days: Date[]; event: GanttTaskData; @@ -28,8 +29,9 @@ const GanttTaskBar = ({ onWorkPackageToggle?: () => void; showWorkPackages?: boolean; highlightedChange?: RequestEventChange; + addWorkPackage?: (task: GanttTaskData) => void; }) => { - const isProject = !event.project; + const isProject = !event.projectId; const getStartCol = (event: GanttTaskData) => { const startCol = days.findIndex((day) => dateToString(day) === dateToString(getMonday(event.start))) + 1; @@ -57,6 +59,7 @@ const GanttTaskBar = ({ getStartCol={getStartCol} getEndCol={getEndCol} isProject={isProject} + addWorkPackage={addWorkPackage} /> ) : ( number; getEndCol: (event: GanttTaskData) => number; isProject: boolean; + addWorkPackage: (task: GanttTaskData) => void; }) => { const theme = useTheme(); const id = useId() || 'id'; // id for creating event changes @@ -82,12 +93,46 @@ const GanttTaskBarEdit = ({ } }, [bounds, width]); + const [showAddWorkPackageModal, setShowAddWorkPackageModal] = useState(false); + return (
+ setShowAddWorkPackageModal(false)} + addWorkPackage={(workPackage) => { + const dup = id + event.workPackages.length + 1; + addWorkPackage({ + id: dup, + name: workPackage.name, + start: new Date(), + end: new Date(Date.now() + 1000 * 60 * 60 * 24 * 7), + type: 'task', + workPackages: [], + projectId: event.id, + projectNumber: event.projectNumber, + carNumber: event.carNumber, + styles: { + color: GanttWorkPackageTextColorPipe(workPackage.stage), + backgroundColor: GanttWorkPackageStageColorPipe(workPackage.stage, WbsElementStatus.Inactive) + } + }); + + createChange({ + id, + eventId: dup, + type: 'create-work-package', + name: workPackage.name, + stage: workPackage.stage, + start: new Date(), + end: new Date(Date.now() + 1000 * 60 * 60 * 24 * 7) + }); + }} + /> +
+ {isProject && ( + { + setShowAddWorkPackageModal(true); + }} + /> + )}
); diff --git a/src/frontend/src/pages/GanttPage/GanttChartPage.tsx b/src/frontend/src/pages/GanttPage/GanttChartPage.tsx index 02907fcafa..1e58c051ca 100644 --- a/src/frontend/src/pages/GanttPage/GanttChartPage.tsx +++ b/src/frontend/src/pages/GanttPage/GanttChartPage.tsx @@ -17,10 +17,10 @@ import { sortTeamNames, GanttTask, transformProjectToGanttTask, - getProjectTeamsName, EventChange, RequestEventChange, - aggregateGanttChanges + aggregateGanttChanges, + getProjectsTeamNames } from '../../utils/gantt.utils'; import { routes } from '../../utils/routes'; import { Box } from '@mui/material'; @@ -33,7 +33,7 @@ import GanttChart from './GanttChart'; import { useAllTeamTypes } from '../../hooks/design-reviews.hooks'; import { Team, TeamType } from 'shared'; import { useAllTeams } from '../../hooks/teams.hooks'; -import { GanttRequestChangeModal } from './GanttChartComponents/GanttRequestChangeModal'; +import { GanttRequestChangeModal } from './GanttChartComponents/GanttChangeModals/GanttRequestChangeModal'; const GanttChartPage: FC = () => { const query = useQuery(); @@ -49,10 +49,13 @@ const GanttChartPage: FC = () => { data: teamTypes, error: teamTypesError } = useAllTeamTypes(); + const { isLoading: teamsIsLoading, isError: teamsIsError, data: teams, error: teamsError } = useAllTeams(); const [searchText, setSearchText] = useState(''); const [ganttTaskChanges, setGanttTaskChanges] = useState([]); const [showWorkPackagesMap, setShowWorkPackagesMap] = useState>(new Map()); + const [addedProjects, setAddedProjects] = useState([]); + const [addedWorkPackages, setAddedWorkPackages] = useState([]); /******************** Filters ***************************/ const showCars = query.getAll('car').map((car) => parseInt(car)); @@ -74,7 +77,14 @@ const GanttChartPage: FC = () => { const sortedProjects = filteredProjects.sort( (a, b) => (a.startDate || new Date()).getTime() - (b.startDate || new Date()).getTime() ); - const ganttProjectTasks = sortedProjects.flatMap((project) => transformProjectToGanttTask(project)); + const ganttProjectTasks = sortedProjects.flatMap((project) => transformProjectToGanttTask(project)).concat(addedProjects); + const teamNameToGanttTasksMap = new Map(); + + ganttProjectTasks.forEach((ganttTask) => { + const tasks: GanttTask[] = teamNameToGanttTasksMap.get(ganttTask.teamName) || []; + tasks.push(ganttTask); + teamNameToGanttTasksMap.set(ganttTask.teamName, tasks); + }); if (projectsIsLoading || teamTypesIsLoading || teamsIsLoading || !teams || !projects || !teamTypes) return ; @@ -165,13 +175,21 @@ const GanttChartPage: FC = () => { /***************************************************** */ - const teamNameToGanttTasksMap = new Map(); + const addProjectHandler = (project: GanttTask) => { + setAddedProjects((prev) => [...prev, project]); + }; - ganttProjectTasks.forEach((ganttTask) => { - const tasks: GanttTask[] = teamNameToGanttTasksMap.get(ganttTask.teamName) || []; - tasks.push(ganttTask); - teamNameToGanttTasksMap.set(ganttTask.teamName, tasks); - }); + const addWorkPackageHandler = (workPackage: GanttTask) => { + setAddedWorkPackages((prev) => [...prev, workPackage]); + }; + + for (const project of ganttProjectTasks) { + const workPackagesToAdd = addedWorkPackages.filter( + (wp) => wp.projectId === project.id && project.workPackages.indexOf(wp) === -1 + ); + if (workPackagesToAdd.length === 0) continue; + project.workPackages.push(...workPackagesToAdd); + } const allGanttTasks = ganttProjectTasks .flatMap((projectTask) => @@ -207,7 +225,7 @@ const GanttChartPage: FC = () => { ) : add(Date.now(), { weeks: 15 }); - const teamList = Array.from(new Set(projects.map(getProjectTeamsName))); + const teamList = Array.from(getProjectsTeamNames(projects)); const sortedTeamList: string[] = teamList.sort(sortTeamNames); const saveChanges = (eventChanges: EventChange[]) => { @@ -232,6 +250,13 @@ const GanttChartPage: FC = () => { }); }; + const getNewProjectNumber = (carNumber: number) => { + const existingCarProjects = projects.filter((project) => project.wbsNum.carNumber === carNumber).length; + const newCarProjects = addedProjects.filter((project) => project.carNumber === carNumber).length; + + return existingCarProjects + newCarProjects + 1; + }; + const headerRight = ( @@ -275,6 +300,9 @@ const GanttChartPage: FC = () => { showWorkPackagesMap={showWorkPackagesMap} setShowWorkPackagesMap={setShowWorkPackagesMap} highlightedChange={ganttTaskChanges[ganttTaskChanges.length - 1]} + addProject={addProjectHandler} + addWorkPackage={addWorkPackageHandler} + getNewProjectNumber={getNewProjectNumber} /> {ganttTaskChanges.map((change) => ( removeActiveModal(change.eventId)} /> diff --git a/src/frontend/src/pages/GanttPage/GanttChartSection.tsx b/src/frontend/src/pages/GanttPage/GanttChartSection.tsx index 99175c0617..ceb3d3cc1c 100644 --- a/src/frontend/src/pages/GanttPage/GanttChartSection.tsx +++ b/src/frontend/src/pages/GanttPage/GanttChartSection.tsx @@ -19,6 +19,7 @@ interface GanttChartSectionProps { showWorkPackagesMap: Map; setShowWorkPackagesMap: React.Dispatch>>; highlightedChange?: RequestEventChange; + addWorkPackage: (task: GanttTaskData) => void; } const GanttChartSection = ({ @@ -29,7 +30,8 @@ const GanttChartSection = ({ createChange, showWorkPackagesMap, setShowWorkPackagesMap, - highlightedChange + highlightedChange, + addWorkPackage }: GanttChartSectionProps) => { const days = eachDayOfInterval({ start, end }).filter((day) => isMonday(day)); const [currentTask, setCurrentTask] = useState(undefined); @@ -42,6 +44,11 @@ const GanttChartSection = ({ } }; + const handleCreateProjectChange = (change: EventChange) => { + createChange(change); + setCurrentTask(undefined); + }; + const handleOnMouseLeave = () => { setCurrentTask(undefined); }; @@ -56,17 +63,18 @@ const GanttChartSection = ({ {projects.map((project) => { return ( <> - + toggleWorkPackages(project)} showWorkPackages={showWorkPackagesMap.get(project.id)} + addWorkPackage={addWorkPackage} /> @@ -94,7 +102,7 @@ const GanttChartSection = ({ {currentTask && ( number; + addProject: (project: GanttTask) => void; + addWorkPackage: (workPackage: GanttTask) => void; } const GanttChartTeamSection = ({ @@ -23,11 +28,16 @@ const GanttChartTeamSection = ({ setShowWorkPackagesMap, teamName, projectTasks, - highlightedChange + getNewProjectNumber, + highlightedChange, + addProject, + addWorkPackage }: GanttChartTeamSectionProps) => { const theme = useTheme(); const [eventChanges, setEventChanges] = useState([]); const [isEditMode, setIsEditMode] = useState(false); + const [showAddProjectModal, setShowAddProjectModal] = useState(false); + const id = useId() || 'id'; const createChange = (change: EventChange) => { setEventChanges([...eventChanges, change]); @@ -52,6 +62,10 @@ const GanttChartTeamSection = ({ task.workPackages.sort((a, b) => a.start.getTime() - b.start.getTime()); }); + const handleAddWorkPackage = (workPackage: GanttTaskData) => { + addWorkPackage({ ...workPackage, teamName }); + }; + const displayedProjects = isEditMode ? applyChangesToEvents(projectTasks, eventChanges) : projectTasks; return ( @@ -77,12 +91,52 @@ const GanttChartTeamSection = ({ height: '30px' }} > + setShowAddProjectModal(false)} + addProject={(project) => { + const newProject: GanttTask = { + id: id + projectTasks.length + 1, + name: project.name, + start: new Date(), + carNumber: project.carNumber, + projectNumber: getNewProjectNumber(project.carNumber), + end: new Date(Date.now() + 1000 * 60 * 60 * 24 * 30), + workPackages: [], + teamName, + type: 'project' + }; + + addProject(newProject); + + projectTasks.push(newProject); + + handleEdit(); + + createChange({ + id, + eventId: newProject.id, + type: 'create-project' + }); + }} + /> {teamName} {isEditMode ? ( - + + + { + setIsEditMode(false); + setEventChanges([]); + }} + sx={{ marginRight: '10px' }} + /> + setShowAddProjectModal(true)} /> + ) : ( @@ -99,6 +153,7 @@ const GanttChartTeamSection = ({ showWorkPackagesMap={showWorkPackagesMap} setShowWorkPackagesMap={setShowWorkPackagesMap} highlightedChange={highlightedChange} + addWorkPackage={handleAddWorkPackage} /> diff --git a/src/frontend/src/pages/HomePage/UsefulLinks.tsx b/src/frontend/src/pages/HomePage/UsefulLinks.tsx index 45c358ff6f..827a943aa4 100644 --- a/src/frontend/src/pages/HomePage/UsefulLinks.tsx +++ b/src/frontend/src/pages/HomePage/UsefulLinks.tsx @@ -92,6 +92,18 @@ const UsefulLinks: React.FC = () => { > Confluence + , + <> + + + Sponsership Form + ]; diff --git a/src/frontend/src/pages/LoginPage/LoginDev.tsx b/src/frontend/src/pages/LoginPage/LoginDev.tsx index d656e51389..304899e752 100644 --- a/src/frontend/src/pages/LoginPage/LoginDev.tsx +++ b/src/frontend/src/pages/LoginPage/LoginDev.tsx @@ -39,8 +39,6 @@ const LoginDev: React.FC = ({ devSetUser, devFormSubmit }) => { .sort((a, b) => a.lastName.localeCompare(b.lastName)) .sort((a, b) => rankUserRole(b.role) - rankUserRole(a.role)); - devSetUser(sortedUsers[0].userId); - return ( @@ -49,7 +47,7 @@ const LoginDev: React.FC = ({ devSetUser, devFormSubmit }) => { label="Local Dev User" labelId="localDevUser" onChange={(e: any) => devSetUser(e.target.value)} - defaultValue={sortedUsers[0].userId} + defaultValue={''} endAdornment={ diff --git a/src/frontend/src/pages/ProjectDetailPage/ProjectForm/ProjectCreateContainer.tsx b/src/frontend/src/pages/ProjectDetailPage/ProjectForm/ProjectCreateContainer.tsx index 1616c66f2b..3dca3be43b 100644 --- a/src/frontend/src/pages/ProjectDetailPage/ProjectForm/ProjectCreateContainer.tsx +++ b/src/frontend/src/pages/ProjectDetailPage/ProjectForm/ProjectCreateContainer.tsx @@ -59,7 +59,7 @@ const ProjectCreateContainer: React.FC = () => { const schema = yup.object().shape({ name: yup.string().required('Name is required!'), // TODO update upper bound here once new car model is made - carNumber: yup.number().min(0).max(3).required('A car number is required!'), + carNumber: yup.number().min(0).required('A car number is required!'), teamIds: yup.array().of(yup.string()).required('Teams are required'), budget: yup.number().optional(), summary: yup.string().required('Summary is required!'), @@ -79,6 +79,9 @@ const ProjectCreateContainer: React.FC = () => { const onSubmitChangeRequest = async (data: ProjectCreateChangeRequestFormInput) => { const { name, budget, summary, links, teamIds, carNumber, descriptionBullets, type, what, why } = data; + // Car number could be zero and a truthy check would fail + if (carNumber === undefined) throw new Error('Car number is required!'); + try { const projectPayload: ProjectProposedChangesCreateArgs = { name, @@ -89,11 +92,11 @@ const ProjectCreateContainer: React.FC = () => { links, leadId, managerId, - carNumber + carNumber, + workPackageProposedChanges: [] }; const changeRequestPayload: CreateStandardChangeRequestPayload = { wbsNum: { - // TODO change this to use the car model when we add it to the schema carNumber, projectNumber: 0, workPackageNumber: 0 @@ -116,13 +119,16 @@ const ProjectCreateContainer: React.FC = () => { const onSubmit = async (data: ProjectFormInput) => { const { name, budget, summary, links, crId, teamIds, carNumber, descriptionBullets } = data; + // Car number could be zero and a truthy check would fail + if (carNumber === undefined) throw new Error('Car number is required!'); + try { const payload: CreateSingleProjectPayload = { crId, name, carNumber, summary, - teamIds: teamIds.map((number) => '' + number), + teamIds, budget, descriptionBullets, links, diff --git a/src/frontend/src/pages/ProjectDetailPage/ProjectForm/ProjectEditContainer.tsx b/src/frontend/src/pages/ProjectDetailPage/ProjectForm/ProjectEditContainer.tsx index 76692562c3..f4c61ac6ee 100644 --- a/src/frontend/src/pages/ProjectDetailPage/ProjectForm/ProjectEditContainer.tsx +++ b/src/frontend/src/pages/ProjectDetailPage/ProjectForm/ProjectEditContainer.tsx @@ -102,7 +102,7 @@ const ProjectEditContainer: React.FC = ({ project, ex }); const onSubmitChangeRequest = async (data: ProjectCreateChangeRequestFormInput) => { - const { name, budget, summary, links, carNumber, type, what, why } = data; + const { name, budget, summary, links, type, what, why, descriptionBullets } = data; try { const projectPayload: ProjectProposedChangesCreateArgs = { @@ -112,9 +112,9 @@ const ProjectEditContainer: React.FC = ({ project, ex budget, descriptionBullets, links, - carNumber, leadId, - managerId + managerId, + workPackageProposedChanges: [] }; const changeRequestPayload: CreateStandardChangeRequestPayload = { wbsNum: project.wbsNum, diff --git a/src/frontend/src/pages/ProjectDetailPage/ProjectForm/ProjectForm.tsx b/src/frontend/src/pages/ProjectDetailPage/ProjectForm/ProjectForm.tsx index bbd500446c..4a03ae696b 100644 --- a/src/frontend/src/pages/ProjectDetailPage/ProjectForm/ProjectForm.tsx +++ b/src/frontend/src/pages/ProjectDetailPage/ProjectForm/ProjectForm.tsx @@ -32,8 +32,8 @@ export interface ProjectFormInput { summary: string; links: LinkCreateArgs[]; crId: string; - carNumber: number; - teamIds: number[]; + carNumber: number | undefined; + teamIds: string[]; descriptionBullets: DescriptionBulletPreview[]; } diff --git a/src/frontend/src/pages/ProjectDetailPage/ProjectViewContainer/ProjectDetails.tsx b/src/frontend/src/pages/ProjectDetailPage/ProjectViewContainer/ProjectDetails.tsx index d2d1b2ca19..b76e13a87d 100644 --- a/src/frontend/src/pages/ProjectDetailPage/ProjectViewContainer/ProjectDetails.tsx +++ b/src/frontend/src/pages/ProjectDetailPage/ProjectViewContainer/ProjectDetails.tsx @@ -14,7 +14,10 @@ import WorkPackageSummary from './WorkPackageSummary'; import DetailDisplay from '../../../components/DetailDisplay'; import LinkView from '../../../components/Link/LinkView'; import GroupIcon from '@mui/icons-material/Group'; -import { getProjectTeamsName } from '../../../utils/gantt.utils'; + +export const getProjectTeamsName = (project: Project): string => { + return project.teams.map((team) => team.teamName).join(', '); +}; interface ProjectDetailsProps { project: Project; diff --git a/src/frontend/src/pages/ProjectDetailPage/ProjectViewContainer/ScopeTab.tsx b/src/frontend/src/pages/ProjectDetailPage/ProjectViewContainer/ScopeTab.tsx index b39656fb04..2e5f0c1677 100644 --- a/src/frontend/src/pages/ProjectDetailPage/ProjectViewContainer/ScopeTab.tsx +++ b/src/frontend/src/pages/ProjectDetailPage/ProjectViewContainer/ScopeTab.tsx @@ -3,36 +3,33 @@ * See the LICENSE file in the repository root folder for details. */ -import { Project } from 'shared'; -import { Box, Typography } from '@mui/material'; - -const styles = { - bulletList: { - paddingLeft: '35px', - marginBottom: '0em' - } -}; +import { DescriptionBullet, Project } from 'shared'; +import { Typography } from '@mui/material'; export const ScopeTab = ({ project }: { project: Project }) => { - const descriptionBullets = project.descriptionBullets.map((bullet, index) =>
  • {bullet}
  • ); + const descriptoinBulletsSplitByType = new Map(); + for (const bullet of project.descriptionBullets) { + if (bullet.dateDeleted) continue; + if (!descriptoinBulletsSplitByType.has(bullet.type)) { + descriptoinBulletsSplitByType.set(bullet.type, []); + } + descriptoinBulletsSplitByType.get(bullet.type)!.push(bullet); + } return ( - - - - - - Description Bullets - - -
      {descriptionBullets}
    -
    -
    -
    + <> + {Array.from(descriptoinBulletsSplitByType.entries()).map(([type, bullets]) => ( + <> + + {type} + +
      + {bullets.map((db) => ( +
    • {db.detail}
    • + ))} +
    + + ))} + ); }; diff --git a/src/frontend/src/pages/ProjectsPage/ProjectsTable.tsx b/src/frontend/src/pages/ProjectsPage/ProjectsTable.tsx index db102ec37a..1843a43806 100644 --- a/src/frontend/src/pages/ProjectsPage/ProjectsTable.tsx +++ b/src/frontend/src/pages/ProjectsPage/ProjectsTable.tsx @@ -12,8 +12,8 @@ import { useAllProjects } from '../../hooks/projects.hooks'; import { fullNamePipe, wbsPipe, weeksPipe } from '../../utils/pipes'; import { routes } from '../../utils/routes'; import { GridColDefStyle } from '../../utils/tables'; -import { getProjectTeamsName } from '../../utils/gantt.utils'; import TableCustomToolbar from '../../components/TableCustomToolbar'; +import { getProjectTeamsName } from '../ProjectDetailPage/ProjectViewContainer/ProjectDetails'; /** * Table of all projects. diff --git a/src/frontend/src/utils/diff-page.utils.ts b/src/frontend/src/utils/diff-page.utils.ts index 281d0993d4..b1276cb245 100644 --- a/src/frontend/src/utils/diff-page.utils.ts +++ b/src/frontend/src/utils/diff-page.utils.ts @@ -59,7 +59,7 @@ export const changeBulletDetailText = (changeBullet: ChangeBullet): string | str return detail as string[]; } else if ('teamName' in testVal) { return (detail as TeamPreview[]).map((team) => team.teamName); - } else if ('id' in testVal) { + } else if ('userChecked' in testVal) { return (detail as DescriptionBullet[]).map((bullet) => bullet.detail); } else if ('carNumber' in testVal) { return (detail as WbsNumber[]).map(wbsPipe); @@ -85,8 +85,6 @@ export const getPotentialChangeBackground = (potentialChangeType: PotentialChang }; export const valueChanged = (original: ProposedChangeValue, proposed: ProposedChangeValue) => { - console.log(typeof original, typeof proposed, original, proposed); - if (typeof original === 'string' || typeof original === 'number') return original !== proposed; if (original === undefined) return proposed !== undefined; @@ -139,7 +137,8 @@ export const projectToProposedChangesPreview = (project: Project | undefined): P teams: project.teams, budget: project.budget, descriptionBullets: project.descriptionBullets, - links: project.links + links: project.links, + workPackageProposedChanges: project.workPackages }; }; @@ -172,7 +171,8 @@ export const projectProposedChangesToPreview = ( teams: proposedChanges.teams, budget: proposedChanges.budget, descriptionBullets: proposedChanges.descriptionBullets, - links: proposedChanges.links + links: proposedChanges.links, + workPackageProposedChanges: proposedChanges.workPackageProposedChanges } ); }; diff --git a/src/frontend/src/utils/gantt.utils.ts b/src/frontend/src/utils/gantt.utils.ts index e71dc98abb..25269c8a21 100644 --- a/src/frontend/src/utils/gantt.utils.ts +++ b/src/frontend/src/utils/gantt.utils.ts @@ -3,7 +3,7 @@ * See the LICENSE file in the repository root folder for details. */ -import { Project, User, WbsElementStatus, WbsNumber, wbsPipe, WorkPackage, WorkPackageStage } from 'shared'; +import { Project, User, validateWBS, WbsElementStatus, WbsNumber, wbsPipe, WorkPackage, WorkPackageStage } from 'shared'; import { projectWbsPipe } from './pipes'; import dayjs from 'dayjs'; import { deepOrange, green, grey, indigo, orange, pink, yellow } from '@mui/material/colors'; @@ -21,12 +21,15 @@ export interface GanttTaskData { start: Date; end: Date; workPackages: GanttTaskData[]; + projectNumber: number; + carNumber: number; styles?: { color?: string; backgroundColor?: string; backgroundSelectedColor?: string; }; - project?: string; + stage?: WorkPackageStage; + projectId?: string; onClick?: () => void; lead?: User; manager?: User; @@ -37,6 +40,8 @@ export type Date_Event = { id: string; start: Date; end: Date; title: string }; export type EventChange = { id: string; eventId: string } & ( | { type: 'change-end-date'; originalEnd: Date; newEnd: Date } | { type: 'shift-by-days'; days: number } + | { type: 'create-project' } + | { type: 'create-work-package'; name: string; stage: WorkPackageStage; start: Date; end: Date } ); export type RequestEventChange = { @@ -47,14 +52,22 @@ export type RequestEventChange = { newStart: Date; newEnd: Date; duration: number; + stage?: WorkPackageStage; + teamName: string; + baseWbs: WbsNumber; + workPackageChanges: RequestEventChange[]; + createProject?: boolean; }; -export const applyChangeToEvent = (event: GanttTaskData, eventChanges: EventChange[]): GanttTaskData => { - const workPackages = event.workPackages && event.workPackages.map((wpEvent) => applyChangeToEvent(wpEvent, eventChanges)); +export const applyChangeToEvent = (eventChanges: EventChange[], task: GanttTaskData): GanttTaskData => { + let workPackages: GanttTaskData[] = []; + if (task && task.workPackages) { + workPackages = task.workPackages.map((wpEvent) => applyChangeToEvent(eventChanges, wpEvent)); + } - const currentEventChanges = eventChanges.filter((ec) => ec.eventId === event.id); + const currentEventChanges = eventChanges.filter((ec) => ec.eventId === task.id); - const changedEvent = { ...event }; + const changedEvent = { ...task }; for (const eventChange of currentEventChanges) { switch (eventChange.type) { case 'change-end-date': { @@ -73,7 +86,7 @@ export const applyChangeToEvent = (event: GanttTaskData, eventChanges: EventChan export const applyChangesToEvents = (events: GanttTaskData[], eventChanges: EventChange[]): GanttTaskData[] => { return events.map((event) => { - return applyChangeToEvent(event, eventChanges); + return applyChangeToEvent(eventChanges, event); }); }; @@ -149,9 +162,12 @@ export const transformWorkPackageToGanttTask = (workPackage: WorkPackage, teamNa name: wbsPipe(workPackage.wbsNum) + ' ' + workPackage.name, start: workPackage.startDate, end: workPackage.endDate, - project: projectWbsPipe(workPackage.wbsNum), + projectId: projectWbsPipe(workPackage.wbsNum), + projectNumber: workPackage.wbsNum.projectNumber, + carNumber: workPackage.wbsNum.carNumber, type: 'task', teamName, + stage: workPackage.stage, workPackages: [], styles: { color: GanttWorkPackageTextColorPipe(workPackage.stage), @@ -165,27 +181,37 @@ export const transformWorkPackageToGanttTask = (workPackage: WorkPackage, teamNa }; }; -export const getProjectTeamsName = (project: Project): string => { - return project.teams.length === 0 ? NO_TEAM : project.teams.map((team) => team.teamName).join(', '); +export const getProjectsTeamNames = (projects: Project[]): Set => { + const teamNames: Set = new Set(); + projects.forEach((project) => { + project.teams.forEach((team) => { + teamNames.add(team.teamName); + }); + }); + + return teamNames; }; export const transformProjectToGanttTask = (project: Project): GanttTask[] => { - const teamName = getProjectTeamsName(project); - const projectTask: GanttTask = { - id: wbsPipe(project.wbsNum), - name: wbsPipe(project.wbsNum) + ' - ' + project.name, - start: project.startDate || new Date(), - end: project.endDate || new Date(), - type: 'project', - teamName, - lead: project.lead, - manager: project.manager, - workPackages: project.workPackages.map((wp) => transformWorkPackageToGanttTask(wp, teamName)), - onClick: () => { - window.open(`/projects/${wbsPipe(project.wbsNum)}`, '_blank'); - } - }; - return [projectTask]; + const teamNames = Array.from(getProjectsTeamNames([project])); + return teamNames.map((teamName) => { + return { + id: wbsPipe(project.wbsNum), + name: wbsPipe(project.wbsNum) + ' - ' + project.name, + start: project.startDate || new Date(), + end: project.endDate || new Date(), + type: 'project', + projectNumber: project.wbsNum.projectNumber, + carNumber: project.wbsNum.carNumber, + teamName, + lead: project.lead, + manager: project.manager, + workPackages: project.workPackages.map((wp) => transformWorkPackageToGanttTask(wp, teamName)), + onClick: () => { + window.open(`/projects/${wbsPipe(project.wbsNum)}`, '_blank'); + } + }; + }); }; /** @@ -318,16 +344,47 @@ export const aggregateGanttChanges = (eventChanges: EventChange[], ganttTasks: G // Loop through each eventChange eventChanges.forEach((eventChange) => { if (aggregatedMap.has(eventChange.eventId)) { - aggregatedMap.get(eventChange.eventId)?.push(eventChange); + aggregatedMap.get(eventChange.eventId)!.push(eventChange); } else { aggregatedMap.set(eventChange.eventId, [eventChange]); } }); - const updatedEvents = Array.from(aggregatedMap.entries()).map(([eventId, changeEvents]) => { + const orderedEvents = Array.from(aggregatedMap.entries()).sort((a, b) => { + const aTask = ganttTasks.find((task) => task.id === a[0]); + const bTask = ganttTasks.find((task) => task.id === b[0]); + + if (!aTask || !bTask) { + throw new Error('Task not found'); + } + + return aTask.workPackages.length - bTask.workPackages.length; + }); + + // We want to ignore any work packages that were created on a new project as we will add them in the project creation + const filteredEvents = orderedEvents.filter(([eventId, _changeEvents]) => { const task = ganttTasks.find((task) => task.id === eventId); + if (!task) { + throw new Error('Task not found'); + } - const updatedEvent = applyChangeToEvent(task!, changeEvents); + if (!task.projectId) return true; + + try { + validateWBS(task.projectId); + return true; + } catch { + return false; + } + }); + + const updatedEvents = filteredEvents.map(([eventId, changeEvents]) => { + const task = ganttTasks.find((task) => task.id === eventId); + if (!task) { + throw new Error('Task not found'); + } + + const updatedEvent = applyChangeToEvent(changeEvents, task); const start = dayjs(updatedEvent.start); const end = dayjs(updatedEvent.end); @@ -338,14 +395,44 @@ export const aggregateGanttChanges = (eventChanges: EventChange[], ganttTasks: G // Calculate the number of weeks const duration = Math.ceil(diffInDays / 7); + const newProject = updatedEvent.type === 'project'; + + const workPackageChanges: RequestEventChange[] = []; + + if (newProject) { + updatedEvent.workPackages.forEach((wp) => { + workPackageChanges.push({ + eventId: wp.id, + name: wp.name, + prevStart: new Date(), + prevEnd: new Date(), + newStart: wp.start, + newEnd: wp.end, + duration: wp.end.getTime() - wp.start.getTime(), + stage: wp.stage, + teamName: task.teamName, + workPackageChanges: [], + baseWbs: { carNumber: wp.carNumber, projectNumber: wp.projectNumber, workPackageNumber: 0 } + }); + }); + } + const change: RequestEventChange = { eventId: updatedEvent.id, - name: task!.name, - prevStart: task!.start, - prevEnd: task!.end, + stage: task.stage, + teamName: task.teamName, + name: updatedEvent.name, + prevStart: task?.start ?? new Date(), + prevEnd: task?.end ?? new Date(), newStart: updatedEvent.start, newEnd: updatedEvent.end, - duration + duration, + createProject: updatedEvent.type === 'project', + workPackageChanges, + baseWbs: + updatedEvent.type === 'project' + ? { carNumber: updatedEvent.carNumber, projectNumber: 0, workPackageNumber: 0 } + : { carNumber: updatedEvent.carNumber, projectNumber: updatedEvent.projectNumber, workPackageNumber: 0 } }; return change; diff --git a/src/frontend/src/utils/urls.ts b/src/frontend/src/utils/urls.ts index 6daaaebd6e..b23bc0b355 100644 --- a/src/frontend/src/utils/urls.ts +++ b/src/frontend/src/utils/urls.ts @@ -84,7 +84,9 @@ const teamsSetHead = (id: string) => `${teamsById(id)}/set-head`; const teamsSetDescription = (id: string) => `${teamsById(id)}/edit-description`; const teamsCreate = () => `${teams()}/create`; const teamsSetLeads = (id: string) => `${teamsById(id)}/set-leads`; -const teamTypes = () => `${teams()}/teamType/all`; +const teamTypes = () => `${teams()}/teamType`; +const allTeamTypes = () => `${teamTypes()}/all`; +const teamTypesCreate = () => `${teamTypes()}/create`; /**************** Description Bullet Endpoints ****************/ const descriptionBullets = () => `${API_URL}/description-bullets`; @@ -150,7 +152,7 @@ const designReviewById = (id: string) => `${designReviews()}/${id}`; const designReviewDelete = (id: string) => `${designReviewById(id)}/delete`; const designReviewMarkUserConfirmed = (id: string) => `${designReviewById(id)}/confirm-schedule`; -/******************* Work Package Template Endpoints********************/ +/******************* Work Package Template Endpoints ********************/ const workPackageTemplates = () => `${API_URL}/templates`; const workPackageTemplatesById = (workPackageTemplateId: string) => `${workPackageTemplates()}/${workPackageTemplateId}`; @@ -158,6 +160,10 @@ const workPackageTemplatesEdit = (workPackageTemplateId: string) => `${workPackageTemplatesById(workPackageTemplateId)}/edit`; const workPackageTemplatesCreate = () => `${workPackageTemplates()}/create`; +/******************* Car Endpoints ********************/ +const cars = () => `${API_URL}/cars`; +const carsCreate = () => `${cars()}/create`; + /**************** Other Endpoints ****************/ const version = () => `https://api.github.com/repos/Northeastern-Electric-Racing/FinishLine/releases/latest`; @@ -221,8 +227,9 @@ export const apiUrls = { teamsSetDescription, teamsCreate, teamsSetLeads, - teamTypes, + allTeamTypes, teamsSetTeamType, + teamTypesCreate, descriptionBulletsCheck, descriptionBulletTypes, @@ -286,5 +293,8 @@ export const apiUrls = { workPackageTemplatesEdit, workPackageTemplatesCreate, + cars, + carsCreate, + version }; diff --git a/src/shared/src/types/change-request-types.ts b/src/shared/src/types/change-request-types.ts index b6a4761505..a3e43979bf 100644 --- a/src/shared/src/types/change-request-types.ts +++ b/src/shared/src/types/change-request-types.ts @@ -130,6 +130,7 @@ export interface ProjectProposedChangesCreateArgs extends WBSProposedChangesCrea budget: number; summary: string; teamIds: string[]; + workPackageProposedChanges: WorkPackageProposedChangesCreateArgs[]; carNumber?: number; } diff --git a/src/shared/src/types/project-types.ts b/src/shared/src/types/project-types.ts index 0c23cdf6df..688f7c2aca 100644 --- a/src/shared/src/types/project-types.ts +++ b/src/shared/src/types/project-types.ts @@ -108,6 +108,7 @@ export interface ProjectProposedChanges extends WbsProposedChanges { budget: number; teams: TeamPreview[]; carNumber?: number; + workPackageProposedChanges: WorkPackageProposedChanges[]; } export interface WorkPackageProposedChanges extends WbsProposedChanges { @@ -139,3 +140,5 @@ export interface DescriptionBulletTypeCreatePayload { workPackageRequired: boolean; projectRequired: boolean; } + +export interface Car extends WbsElement {} diff --git a/src/shared/src/validate-wbs.ts b/src/shared/src/validate-wbs.ts index 6bde6723a9..9a28402b63 100644 --- a/src/shared/src/validate-wbs.ts +++ b/src/shared/src/validate-wbs.ts @@ -66,7 +66,11 @@ export const validateWBS = (wbsNum: string): WbsNumber => { * @param wbsNum WBS number to check */ export const isProject = (wbsNum: WbsNumber) => { - return wbsNum.workPackageNumber === 0; + return wbsNum.workPackageNumber === 0 && wbsNum.projectNumber !== 0; +}; + +export const isWorkPackage = (wbsNum: WbsNumber) => { + return wbsNum.workPackageNumber !== 0; }; /**