diff --git a/src/backend/src/controllers/change-requests.controllers.ts b/src/backend/src/controllers/change-requests.controllers.ts index d14ed33544..208937bac8 100644 --- a/src/backend/src/controllers/change-requests.controllers.ts +++ b/src/backend/src/controllers/change-requests.controllers.ts @@ -140,6 +140,25 @@ export default class ChangeRequestsController { } } + static async createLeadershipChangeRequest(req: Request, res: Response, next: NextFunction) { + try { + const { wbsNum, leadId, managerId } = req.body; + + const cr = await ChangeRequestsService.createLeadershipChangeRequest( + req.currentUser, + wbsNum.carNumber, + wbsNum.projectNumber, + wbsNum.workPackageNumber, + leadId, + managerId, + req.organization + ); + res.status(200).json(cr); + } catch (error: unknown) { + next(error); + } + } + static async createStandardChangeRequest(req: Request, res: Response, next: NextFunction) { try { const { wbsNum, type, what, why, proposedSolutions, projectProposedChanges, workPackageProposedChanges } = req.body; 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 e5b8fcfdfa..abae733c5d 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 @@ -39,7 +39,10 @@ export const getChangeRequestQueryArgs = (organizationId: string) => }, budgetChangeRequest: true, deletedBy: getUserQueryArgs(organizationId), - requestedReviewers: getUserQueryArgs(organizationId) + requestedReviewers: getUserQueryArgs(organizationId), + leadershipChangeRequest: { + include: { lead: getUserQueryArgs(organizationId), manager: getUserQueryArgs(organizationId) } + } } }); @@ -58,7 +61,10 @@ export const getManyChangeRequestQueryArgs = (organizationId: string) => }, budgetChangeRequest: true, deletedBy: getUserQueryArgs(organizationId), - requestedReviewers: getUserQueryArgs(organizationId) + requestedReviewers: getUserQueryArgs(organizationId), + leadershipChangeRequest: { + include: { lead: getUserQueryArgs(organizationId), manager: getUserQueryArgs(organizationId) } + } } }); @@ -101,6 +107,9 @@ export const getChangeRequestWithProjectAndWorkPackageQueryArgs = (organizationI }, budgetChangeRequest: true, deletedBy: getUserQueryArgs(organizationId), - requestedReviewers: getUserQueryArgs(organizationId) + requestedReviewers: getUserQueryArgs(organizationId), + leadershipChangeRequest: { + include: { lead: getUserQueryArgs(organizationId), manager: getUserQueryArgs(organizationId) } + } } }); diff --git a/src/backend/src/prisma/migrations/20260223001543_leadership_cr/migration.sql b/src/backend/src/prisma/migrations/20260223001543_leadership_cr/migration.sql new file mode 100644 index 0000000000..e3eed0574b --- /dev/null +++ b/src/backend/src/prisma/migrations/20260223001543_leadership_cr/migration.sql @@ -0,0 +1,27 @@ +-- AlterEnum +ALTER TYPE "CR_Type" ADD VALUE 'LEADERSHIP'; + +-- CreateTable +CREATE TABLE "Leadership_CR" ( + "leadershipCrId" TEXT NOT NULL, + "changeRequestId" TEXT NOT NULL, + "leadId" TEXT, + "managerId" TEXT, + + CONSTRAINT "Leadership_CR_pkey" PRIMARY KEY ("leadershipCrId") +); + +-- CreateIndex +CREATE UNIQUE INDEX "Leadership_CR_changeRequestId_key" ON "Leadership_CR"("changeRequestId"); + +-- CreateIndex +CREATE INDEX "Leadership_CR_changeRequestId_idx" ON "Leadership_CR"("changeRequestId"); + +-- AddForeignKey +ALTER TABLE "Leadership_CR" ADD CONSTRAINT "Leadership_CR_changeRequestId_fkey" FOREIGN KEY ("changeRequestId") REFERENCES "Change_Request"("crId") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Leadership_CR" ADD CONSTRAINT "Leadership_CR_leadId_fkey" FOREIGN KEY ("leadId") REFERENCES "User"("userId") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Leadership_CR" ADD CONSTRAINT "Leadership_CR_managerId_fkey" FOREIGN KEY ("managerId") REFERENCES "User"("userId") ON DELETE SET NULL ON UPDATE CASCADE; diff --git a/src/backend/src/prisma/schema.prisma b/src/backend/src/prisma/schema.prisma index 3d3ba18d35..2271ba3f24 100644 --- a/src/backend/src/prisma/schema.prisma +++ b/src/backend/src/prisma/schema.prisma @@ -15,6 +15,7 @@ generator client { enum CR_Type { ISSUE DEFINITION_CHANGE + LEADERSHIP OTHER STAGE_GATE ACTIVATION @@ -278,6 +279,8 @@ model User { deniedEvents Event[] @relation(name: "deniedEventAttendee") deletedDocuments Document[] @relation(name: "deletedDocuments") createdDocuments Document[] @relation(name: "documentsCreatedBy") + leadershipCrAsLead Leadership_CR[] @relation(name: "leadershipCrLead") + leadershipCrAsManager Leadership_CR[] @relation(name: "leadershipCrManager") } model Role { @@ -367,6 +370,7 @@ model Change_Request { stageGateChangeRequest Stage_Gate_CR? activationChangeRequest Activation_CR? budgetChangeRequest Budget_CR? + leadershipChangeRequest Leadership_CR? notificationSlackThreads Message_Info[] @@unique([identifier, organizationId], name: "uniqueChangeRequest") @@ -464,6 +468,18 @@ model Budget_CR { @@index([changeRequestId]) } +model Leadership_CR { + leadershipCrId String @id @default(uuid()) + changeRequestId String @unique + changeRequest Change_Request @relation(fields: [changeRequestId], references: [crId]) + leadId String? + lead User? @relation(name: "leadershipCrLead", fields: [leadId], references: [userId]) + managerId String? + manager User? @relation(name: "leadershipCrManager", fields: [managerId], references: [userId]) + + @@index([changeRequestId]) +} + model Change { changeId String @id @default(uuid()) changeRequestId String diff --git a/src/backend/src/routes/change-requests.routes.ts b/src/backend/src/routes/change-requests.routes.ts index 887e080b00..dd63454325 100644 --- a/src/backend/src/routes/change-requests.routes.ts +++ b/src/backend/src/routes/change-requests.routes.ts @@ -115,4 +115,15 @@ changeRequestsRouter.post( ChangeRequestsController.requestCRReview ); +changeRequestsRouter.post( + '/new/leadership', + intMinZero(body('wbsNum.carNumber')), + intMinZero(body('wbsNum.projectNumber')), + intMinZero(body('wbsNum.workPackageNumber')), + nonEmptyString(body('leadId')).optional(), + nonEmptyString(body('managerId')).optional(), + validateInputs, + ChangeRequestsController.createLeadershipChangeRequest +); + export default changeRequestsRouter; diff --git a/src/backend/src/services/change-requests.services.ts b/src/backend/src/services/change-requests.services.ts index 2a432ba641..8797183640 100644 --- a/src/backend/src/services/change-requests.services.ts +++ b/src/backend/src/services/change-requests.services.ts @@ -1008,6 +1008,135 @@ export default class ChangeRequestsService { return changeRequestTransformer(createdChangeRequest); } + /** + * Validates and creates a leadership change request, auto-approved immediately. + * Updates the lead and/or manager of a project or work package without requiring review. + * @param submitter the user creating the cr + * @param carNumber the car number for the wbs element + * @param projectNumber the project number for the wbs element + * @param workPackageNumber the work package number for the wbs element + * @param leadId the id of the new lead + * @param managerId the id of the new manager + * @param organization the organization the user is currently in + * @returns the id of the created cr + */ + static async createLeadershipChangeRequest( + submitter: User, + carNumber: number, + projectNumber: number, + workPackageNumber: number, + leadId: string | undefined, + managerId: string | undefined, + organization: Organization + ): Promise { + if (await userHasPermission(submitter.userId, organization.organizationId, isGuest)) + throw new AccessDeniedGuestException('create leadership change requests'); + + // verify wbs element exists + const wbsElement = await prisma.wBS_Element.findUnique({ + where: { + wbsNumber: { + carNumber, + projectNumber, + workPackageNumber, + organizationId: organization.organizationId + } + } + }); + + if (!wbsElement) throw new NotFoundException('WBS Element', wbsPipe({ carNumber, projectNumber, workPackageNumber })); + if (wbsElement.dateDeleted) + throw new DeletedException('WBS Element', wbsPipe({ carNumber, projectNumber, workPackageNumber })); + if (wbsElement.organizationId !== organization.organizationId) throw new InvalidOrganizationException('WBS Element'); + + // avoid merge conflicts + await validateNoUnreviewedOpenCRs(wbsElement.wbsElementId); + + const numChangeRequests = await prisma.change_Request.count({ + where: { organizationId: organization.organizationId } + }); + + const createdCR = await prisma.change_Request.create({ + data: { + submitter: { connect: { userId: submitter.userId } }, + wbsElement: { connect: { wbsElementId: wbsElement.wbsElementId } }, + type: CR_Type.LEADERSHIP, + organization: { connect: { organizationId: organization.organizationId } }, + identifier: numChangeRequests + 1, + leadershipChangeRequest: { + create: { + ...(leadId && { lead: { connect: { userId: leadId } } }), + ...(managerId && { manager: { connect: { userId: managerId } } }) + } + } + } + }); + + await ChangeRequestsService.applyLeadershipChangeRequest(createdCR.crId, wbsElement, submitter, leadId, managerId); + + return createdCR.crId; + } + + /** + * Applies a leadership change request by updating the wbs element's lead/manager + * and auto-approving the change request. + */ + private static async applyLeadershipChangeRequest( + crId: string, + wbsElement: { wbsElementId: string; leadId: string | null; managerId: string | null }, + submitter: User, + leadId: string | undefined, + managerId: string | undefined + ): Promise { + await prisma.$transaction(async (tx) => { + await tx.change_Request.update({ + where: { crId }, + data: { + reviewer: { connect: { userId: submitter.userId } }, + dateReviewed: new Date(), + accepted: true, + reviewNotes: 'Auto-approved: leadership change only' + } + }); + + await tx.wBS_Element.update({ + where: { wbsElementId: wbsElement.wbsElementId }, + data: { + ...(leadId && { lead: { connect: { userId: leadId } } }), + ...(managerId && { manager: { connect: { userId: managerId } } }) + } + }); + + const changes: { changeRequestId: string; implementerId: string; wbsElementId: string; detail: string }[] = []; + + if (leadId !== undefined) { + const oldLead = await getUserFullName(wbsElement.leadId ?? null); + const newLead = await getUserFullName(leadId); + changes.push({ + changeRequestId: crId, + implementerId: submitter.userId, + wbsElementId: wbsElement.wbsElementId, + detail: buildChangeDetail('lead', oldLead, newLead) + }); + } + + if (managerId !== undefined) { + const oldManager = await getUserFullName(wbsElement.managerId ?? null); + const newManager = await getUserFullName(managerId); + changes.push({ + changeRequestId: crId, + implementerId: submitter.userId, + wbsElementId: wbsElement.wbsElementId, + detail: buildChangeDetail('manager', oldManager, newManager) + }); + } + + if (changes.length > 0) { + await tx.change.createMany({ data: changes }); + } + }); + } + /** * Validates and creates a standard change request * @param submitter The user creating the cr diff --git a/src/backend/src/transformers/change-requests.transformer.ts b/src/backend/src/transformers/change-requests.transformer.ts index 4d7e311869..1c39c2f040 100644 --- a/src/backend/src/transformers/change-requests.transformer.ts +++ b/src/backend/src/transformers/change-requests.transformer.ts @@ -9,7 +9,8 @@ import { WorkPackageProposedChanges, WorkPackageStage, BudgetChangeRequest, - isWorkPackageWbs + isWorkPackageWbs, + LeadershipChangeRequest } from 'shared'; import { wbsNumOf } from '../utils/utils.js'; import { calculateChangeRequestStatus, convertCRScopeWhyType } from '../utils/change-requests.utils.js'; @@ -76,7 +77,13 @@ const workPackageProposedChangesTransformer = ( export const changeRequestManyTransformer = ( changeRequest: Prisma.Change_RequestGetPayload -): ChangeRequest | StandardChangeRequest | ActivationChangeRequest | StageGateChangeRequest | BudgetChangeRequest => { +): + | ChangeRequest + | StandardChangeRequest + | ActivationChangeRequest + | StageGateChangeRequest + | BudgetChangeRequest + | LeadershipChangeRequest => { const status = calculateChangeRequestStatus(changeRequest); return { @@ -108,13 +115,17 @@ export const changeRequestManyTransformer = ( proposedSolutions: undefined, originalProjectData: undefined, originalWorkPackageData: undefined, - // activation cr fields - lead: changeRequest.activationChangeRequest?.lead - ? userTransformer(changeRequest.activationChangeRequest.lead) - : undefined, - manager: changeRequest.activationChangeRequest?.manager - ? userTransformer(changeRequest.activationChangeRequest.manager) - : undefined, + // activation + leadership cr fields + lead: changeRequest.leadershipChangeRequest?.lead + ? userTransformer(changeRequest.leadershipChangeRequest.lead) + : changeRequest.activationChangeRequest?.lead + ? userTransformer(changeRequest.activationChangeRequest.lead) + : undefined, + manager: changeRequest.leadershipChangeRequest?.manager + ? userTransformer(changeRequest.leadershipChangeRequest.manager) + : changeRequest.activationChangeRequest?.manager + ? userTransformer(changeRequest.activationChangeRequest.manager) + : undefined, startDate: changeRequest.activationChangeRequest?.startDate ?? undefined, confirmDetails: changeRequest.activationChangeRequest?.confirmDetails ?? undefined, // stage gate cr fields @@ -128,7 +139,13 @@ export const changeRequestManyTransformer = ( const changeRequestTransformer = ( changeRequest: Prisma.Change_RequestGetPayload -): ChangeRequest | StandardChangeRequest | ActivationChangeRequest | StageGateChangeRequest | BudgetChangeRequest => { +): + | ChangeRequest + | StandardChangeRequest + | ActivationChangeRequest + | StageGateChangeRequest + | BudgetChangeRequest + | LeadershipChangeRequest => { const status = calculateChangeRequestStatus(changeRequest); const wbsName = changeRequest.wbsElement @@ -189,13 +206,17 @@ const changeRequestTransformer = ( originalWorkPackageData: changeRequest.scopeChangeRequest?.wbsOriginalData?.workPackageProposedChanges ? workPackageProposedChangesTransformer(changeRequest.scopeChangeRequest.wbsOriginalData.workPackageProposedChanges) : undefined, - // activation cr fields - lead: changeRequest.activationChangeRequest?.lead - ? userTransformer(changeRequest.activationChangeRequest.lead) - : undefined, - manager: changeRequest.activationChangeRequest?.manager - ? userTransformer(changeRequest.activationChangeRequest.manager) - : undefined, + // activation + leadership cr fields + lead: changeRequest.leadershipChangeRequest?.lead + ? userTransformer(changeRequest.leadershipChangeRequest.lead) + : changeRequest.activationChangeRequest?.lead + ? userTransformer(changeRequest.activationChangeRequest.lead) + : undefined, + manager: changeRequest.leadershipChangeRequest?.manager + ? userTransformer(changeRequest.leadershipChangeRequest.manager) + : changeRequest.activationChangeRequest?.manager + ? userTransformer(changeRequest.activationChangeRequest.manager) + : undefined, startDate: changeRequest.activationChangeRequest?.startDate ?? undefined, confirmDetails: changeRequest.activationChangeRequest?.confirmDetails ?? undefined, // stage gate cr fields diff --git a/src/frontend/src/apis/change-requests.api.ts b/src/frontend/src/apis/change-requests.api.ts index e2f83fbc42..7f101a45de 100644 --- a/src/frontend/src/apis/change-requests.api.ts +++ b/src/frontend/src/apis/change-requests.api.ts @@ -154,6 +154,29 @@ export const createBudgetChangeRequest = ( }); }; +/** + * Create a leadership change request + * Updating the lead and/or manager of a project or work package does not require review + * @param submitterId The id of the user creating the change request + * @param wbsNum The WBS number of the project or work package being updated + * @param leadId The id of the new lead + * @param managerId The id of the new manager + */ +export const createLeadershipChangeRequest = ( + submitterId: string, + wbsNum: WbsNumber, + leadId?: string, + managerId?: string +) => { + return axios.post<{ message: string }>(apiUrls.changeRequestsCreateLeadership(), { + submitterId, + wbsNum, + leadId, + managerId, + type: ChangeRequestType.Leadership + }); +}; + /** * Create a propose solution * @param submitterId The ID of the user creating the change request. diff --git a/src/frontend/src/hooks/change-requests.hooks.ts b/src/frontend/src/hooks/change-requests.hooks.ts index e2e4cd0034..84061fac51 100644 --- a/src/frontend/src/hooks/change-requests.hooks.ts +++ b/src/frontend/src/hooks/change-requests.hooks.ts @@ -11,7 +11,8 @@ import { ProjectProposedChangesCreateArgs, ProposedSolutionCreateArgs, WbsNumber, - WorkPackageProposedChangesCreateArgs + WorkPackageProposedChangesCreateArgs, + LeadershipChangeCreateArgs } from 'shared'; import { createActivationChangeRequest, @@ -26,7 +27,8 @@ import { getToReviewChangeRequests, getUnreviewedChangeRequests, getApprovedChangeRequests, - createBudgetChangeRequest + createBudgetChangeRequest, + createLeadershipChangeRequest } from '../apis/change-requests.api'; /** @@ -238,6 +240,33 @@ export const useCreateBudgetChangeRequest = () => { ); }; +/** + * Custome React hook to create a leadership change request + * to change lead and/or manager of a project or work package + */ +export const useCreateLeadershipChangeRequest = () => { + const queryClient = useQueryClient(); + return useMutation<{ message: string }, Error, LeadershipChangeCreateArgs>( + ['change requests', 'create', 'leadership'], + async (payload: LeadershipChangeCreateArgs) => { + const { data } = await createLeadershipChangeRequest( + payload.submitterId, + payload.wbsNum, + payload.leadId, + payload.managerId + ); + return data; + }, + { + onSuccess: () => { + queryClient.invalidateQueries(['change requests']); + queryClient.invalidateQueries(['projects']); + queryClient.invalidateQueries(['work packages']); + } + } + ); +}; + /** * Custom React Hook to create a proposed solution */ diff --git a/src/frontend/src/pages/ProjectDetailPage/ProjectForm/ProjectEditContainer.tsx b/src/frontend/src/pages/ProjectDetailPage/ProjectForm/ProjectEditContainer.tsx index 91c9a66c4a..a66012a2cb 100644 --- a/src/frontend/src/pages/ProjectDetailPage/ProjectForm/ProjectEditContainer.tsx +++ b/src/frontend/src/pages/ProjectDetailPage/ProjectForm/ProjectEditContainer.tsx @@ -16,12 +16,17 @@ import { useQuery } from '../../../hooks/utils.hooks'; import * as yup from 'yup'; import { StandardChangeRequestType } from '../../CreateChangeRequestPage/CreateChangeRequestView'; import { FormInput, FormInput as ChangeRequestFormInput } from '../../CreateChangeRequestPage/CreateChangeRequestView'; -import { CreateStandardChangeRequestPayload, useCreateStandardChangeRequest } from '../../../hooks/change-requests.hooks'; +import { + CreateStandardChangeRequestPayload, + useCreateLeadershipChangeRequest, + useCreateStandardChangeRequest +} from '../../../hooks/change-requests.hooks'; import { routes } from '../../../utils/routes'; import { useHistory } from 'react-router-dom'; import { yupResolver } from '@hookform/resolvers/yup'; import { useForm } from 'react-hook-form'; import ProjectFormContainer from './ProjectForm'; +import { useCurrentUser } from '../../../hooks/users.hooks'; interface ProjectEditContainerProps { project: Project; @@ -34,6 +39,7 @@ const ProjectEditContainer: React.FC = ({ project, ex const toast = useToast(); const query = useQuery(); const history = useHistory(); + const user = useCurrentUser(); const { name, budget, summary, workPackages } = project; const [managerId, setManagerId] = useState(project.manager?.userId.toString()); const [leadId, setLeadId] = useState(project.lead?.userId.toString()); @@ -42,6 +48,9 @@ const ProjectEditContainer: React.FC = ({ project, ex const { mutateAsync, isLoading } = useEditSingleProject(project.wbsNum); const { mutateAsync: mutateCRAsync, isLoading: isCRHookLoading } = useCreateStandardChangeRequest(); + + const { mutateAsync: mutateLeadershipCR, isLoading: isLeadershipCRLoading } = useCreateLeadershipChangeRequest(); + const { data: allLinkTypes, isLoading: allLinkTypesIsLoading, @@ -106,7 +115,7 @@ const ProjectEditContainer: React.FC = ({ project, ex } }); - if (isLoading || isCRHookLoading) return ; + if (isLoading || isCRHookLoading || isLeadershipCRLoading) return ; if (!allLinkTypes || allLinkTypesIsLoading) return ; if (allLinkTypesIsError) return ; @@ -163,6 +172,25 @@ const ProjectEditContainer: React.FC = ({ project, ex ) }); + // Check if only lead/manager changed + const checkOnlyLeadershipChanged = ( + formName: string, + formBudget: number, + formSummary: string, + formLinks: any[], + formDescriptionBullets: any[] + ) => { + return ( + formName === project.name && + formBudget === project.budget && + formSummary === project.summary && + JSON.stringify(formLinks.map((l) => `${l.linkTypeName}:${l.url}`).sort()) === + JSON.stringify(project.links.map((l) => `${l.linkType.name}:${l.url}`).sort()) && + JSON.stringify(formDescriptionBullets) === JSON.stringify(bulletsToObject(project.descriptionBullets)) && + (leadId !== project.lead?.userId.toString() || managerId !== project.manager?.userId.toString()) + ); + }; + const onSubmitChangeRequest = async (data: ProjectCreateChangeRequestFormInput) => { const { name, budget, summary, links, type, what, why, descriptionBullets } = data; @@ -199,6 +227,20 @@ const ProjectEditContainer: React.FC = ({ project, ex const { name, budget, summary, links, descriptionBullets, crId } = data; try { + const onlyLeadershipChanged = checkOnlyLeadershipChanged(name, budget, summary, links, descriptionBullets); + + if (onlyLeadershipChanged) { + const autoCRPayload = { + submitterId: user.userId, + wbsNum: project.wbsNum, + leadId, + managerId + }; + await mutateLeadershipCR(autoCRPayload); + exitEditMode(); + return; + } + if (!crId) throw new Error('Change request id is required for editing project'); const payload: EditSingleProjectPayload = { @@ -221,6 +263,15 @@ const ProjectEditContainer: React.FC = ({ project, ex } }; + // calculate for submit button status + const onlyLeadershipChanged = checkOnlyLeadershipChanged( + defaultValues.name, + defaultValues.budget, + defaultValues.summary, + defaultValues.links, + defaultValues.descriptionBullets + ); + return ( = ({ project, ex managerId={managerId} onSubmitChangeRequest={onSubmitChangeRequest} setCarNumber={setCarNumber} + onlyLeadershipChanged={onlyLeadershipChanged} /> ); }; diff --git a/src/frontend/src/pages/ProjectDetailPage/ProjectForm/ProjectForm.tsx b/src/frontend/src/pages/ProjectDetailPage/ProjectForm/ProjectForm.tsx index 4052d429a2..e9a56dc052 100644 --- a/src/frontend/src/pages/ProjectDetailPage/ProjectForm/ProjectForm.tsx +++ b/src/frontend/src/pages/ProjectDetailPage/ProjectForm/ProjectForm.tsx @@ -59,6 +59,7 @@ interface ProjectFormContainerProps { setCarNumber: (carNumber: number) => void; carNumber?: number; changeRequestFormReturn: ChangeRequestFormReturn; + onlyLeadershipChanged?: boolean; } const ProjectFormContainer: React.FC = ({ @@ -73,7 +74,8 @@ const ProjectFormContainer: React.FC = ({ managerId, onSubmitChangeRequest, setCarNumber, - changeRequestFormReturn + changeRequestFormReturn, + onlyLeadershipChanged }) => { const [isModalOpen, setIsModalOpen] = useState(false); let changeRequestFormInput: ChangeRequestFormInput | undefined = undefined; @@ -239,7 +241,7 @@ const ProjectFormContainer: React.FC = ({ variant="contained" onClick={() => setIsModalOpen(true)} sx={{ mx: 1 }} - disabled={changeRequestInputExists} + disabled={changeRequestInputExists || !onlyLeadershipChanged} > Create Change Request @@ -249,7 +251,7 @@ const ProjectFormContainer: React.FC = ({ Cancel { wbsNum={validateWBS(wbsNum)} workPackageMutateAsync={createWorkPackageWrapper} createWorkPackageScopeCR={createWorkPackageScopeCR} + createLeadershipCR={() => {}} // leadership changes can't happen on creation exitActiveMode={() => history.push(`${routes.PROJECTS}/${projectWbsPipe(validateWBS(wbsNum))}`)} crId={crId ?? undefined} schema={schema} diff --git a/src/frontend/src/pages/WorkPackageForm/EditWorkPackageForm.tsx b/src/frontend/src/pages/WorkPackageForm/EditWorkPackageForm.tsx index 7ed3267b42..fe1c593bfc 100644 --- a/src/frontend/src/pages/WorkPackageForm/EditWorkPackageForm.tsx +++ b/src/frontend/src/pages/WorkPackageForm/EditWorkPackageForm.tsx @@ -4,7 +4,7 @@ import { useEditWorkPackage } from '../../hooks/work-packages.hooks'; import { useHistory } from 'react-router-dom'; import LoadingIndicator from '../../components/LoadingIndicator'; import * as yup from 'yup'; -import { useCreateStandardChangeRequest } from '../../hooks/change-requests.hooks'; +import { useCreateLeadershipChangeRequest, useCreateStandardChangeRequest } from '../../hooks/change-requests.hooks'; import { routes } from '../../utils/routes'; import { WorkPackageApiInputs } from '../../apis/work-packages.api'; @@ -21,7 +21,9 @@ const EditWorkPackageForm: React.FC = ({ wbsNum, workP const { mutateAsync: createWorkPackageScopeCR, isLoading: createStandardChangeRequestIsLoading } = useCreateStandardChangeRequest(); - if (isLoading || createStandardChangeRequestIsLoading) return ; + const { mutateAsync: mutateLeadershipCR, isLoading: isLeadershipCRLoading } = useCreateLeadershipChangeRequest(); + + if (isLoading || createStandardChangeRequestIsLoading || isLeadershipCRLoading) return ; const schema = yup.object().shape({ name: yup.string().required('Name is required!'), @@ -53,6 +55,7 @@ const EditWorkPackageForm: React.FC = ({ wbsNum, workP wbsNum={wbsNum} workPackageMutateAsync={editWorkPackageWrapper} createWorkPackageScopeCR={createWorkPackageScopeCR} + createLeadershipCR={mutateLeadershipCR} exitActiveMode={() => { setPageMode(false); history.push(`${history.location.pathname}`); diff --git a/src/frontend/src/pages/WorkPackageForm/WorkPackageForm.tsx b/src/frontend/src/pages/WorkPackageForm/WorkPackageForm.tsx index e8b516d516..da46cc3c9c 100644 --- a/src/frontend/src/pages/WorkPackageForm/WorkPackageForm.tsx +++ b/src/frontend/src/pages/WorkPackageForm/WorkPackageForm.tsx @@ -9,6 +9,7 @@ import { useSingleProject } from '../../hooks/projects.hooks'; import { WorkPackageApiInputs } from '../../apis/work-packages.api'; import { ObjectSchema } from 'yup'; import { CreateStandardChangeRequestPayload } from '../../hooks/change-requests.hooks'; +import { LeadershipChangeCreateArgs } from '../../../../shared'; interface WorkPackageFormProps { wbsNum: WbsNumber; @@ -16,6 +17,7 @@ interface WorkPackageFormProps { crId?: string; workPackageMutateAsync: (data: WorkPackageApiInputs) => void; createWorkPackageScopeCR: (data: CreateStandardChangeRequestPayload) => void; + createLeadershipCR: (data: LeadershipChangeCreateArgs) => void; schema: ObjectSchema; breadcrumbs: { name: string; route: string }[]; } @@ -24,6 +26,7 @@ const WorkPackageForm: React.FC = ({ wbsNum, workPackageMutateAsync, createWorkPackageScopeCR, + createLeadershipCR, exitActiveMode, crId, schema, @@ -76,6 +79,7 @@ const WorkPackageForm: React.FC = ({ exitActiveMode={exitActiveMode} workPackageMutateAsync={workPackageMutateAsync} createWorkPackageScopeCR={createWorkPackageScopeCR} + createLeadershipCR={createLeadershipCR} defaultValues={defaultValues} wbsElement={wbsElement} leadOrManagerOptions={leadOrManagerOptions} diff --git a/src/frontend/src/pages/WorkPackageForm/WorkPackageFormView.tsx b/src/frontend/src/pages/WorkPackageForm/WorkPackageFormView.tsx index 231bf2722a..521950a885 100644 --- a/src/frontend/src/pages/WorkPackageForm/WorkPackageFormView.tsx +++ b/src/frontend/src/pages/WorkPackageForm/WorkPackageFormView.tsx @@ -11,7 +11,9 @@ import { validateWBS, WbsElement, wbsPipe, - WorkPackageTemplate + WorkPackageTemplate, + WorkPackageStage, + LeadershipChangeCreateArgs } from 'shared'; import { Control, @@ -34,7 +36,6 @@ import { useToast } from '../../hooks/toasts.hooks'; import { useCurrentUser } from '../../hooks/users.hooks'; import PageBreadcrumbs from '../../layouts/PageTitle/PageBreadcrumbs'; import { WorkPackageApiInputs } from '../../apis/work-packages.api'; -import { WorkPackageStage } from 'shared'; import { ObjectSchema } from 'yup'; import { getMonday, transformDate } from '../../utils/datetime.utils'; import { CreateStandardChangeRequestPayload } from '../../hooks/change-requests.hooks'; @@ -68,6 +69,7 @@ interface WorkPackageFormViewProps { exitActiveMode: () => void; workPackageMutateAsync: (data: WorkPackageApiInputs) => void; createWorkPackageScopeCR: (data: CreateStandardChangeRequestPayload) => void; + createLeadershipCR: (data: LeadershipChangeCreateArgs) => void; defaultValues?: WorkPackageFormViewPayload; wbsElement: WbsElement; leadOrManagerOptions: User[]; @@ -92,6 +94,7 @@ const WorkPackageFormView: React.FC = ({ exitActiveMode, workPackageMutateAsync, createWorkPackageScopeCR, + createLeadershipCR, defaultValues, wbsElement, leadOrManagerOptions, @@ -223,10 +226,53 @@ const WorkPackageFormView: React.FC = ({ if (workPackageTemplateisLoading || !workPackageTemplates) return ; if (workPackageTemplateisError) return ; + // Check if only lead/manager changed + const checkOnlyLeadershipChanged = ( + formName: string, + formStartDate: Date, + formDuration: number, + formBlockedBy: string[], + formStage: string, + formDescriptionBullets: DescriptionBulletPreview[] + ) => { + if (!defaultValues) return false; // Only relevant for edits + + return ( + formName === defaultValues.name && + transformDate(formStartDate) === transformDate(defaultValues.startDate) && + formDuration === defaultValues.duration && + JSON.stringify(formBlockedBy.sort()) === JSON.stringify((defaultValues.blockedBy || []).sort()) && + formStage === defaultValues.stage && + JSON.stringify(formDescriptionBullets) === JSON.stringify(defaultValues.descriptionBullets) && + (leadId !== wbsElement.lead?.userId.toString() || managerId !== wbsElement.manager?.userId.toString()) + ); + }; + const onSubmit = async (data: WorkPackageFormViewPayload) => { const { name, startDate, duration, blockedBy, crId, stage, descriptionBullets } = data; const blockedByWbsNums = blockedBy.map((blocker) => validateWBS(blocker)); try { + const onlyLeadershipChanged = checkOnlyLeadershipChanged( + name, + startDate, + duration, + blockedBy, + stage, + descriptionBullets + ); + + if (onlyLeadershipChanged) { + const autoCRPayload = { + submitterId: user.userId, + wbsNum: wbsElement.wbsNum, + leadId, + managerId + }; + await createLeadershipCR(autoCRPayload); + exitActiveMode(); + return; + } + const payload = { leadId, managerId, @@ -270,6 +316,18 @@ const WorkPackageFormView: React.FC = ({ const startDate = watch('startDate'); const duration = watch('duration'); + // Calculate for submit button status + const onlyLeadershipChanged = defaultValues + ? checkOnlyLeadershipChanged( + watch('name'), + watch('startDate'), + watch('duration'), + watch('blockedBy'), + watch('stage'), + watch('descriptionBullets') + ) + : false; + const calculatedEndDate = dayjs(startDate) .add(7 * duration, 'day') .toDate(); @@ -311,7 +369,7 @@ const WorkPackageFormView: React.FC = ({ setIsModalOpen(true)} sx={{ mx: 1 }} @@ -328,7 +386,7 @@ const WorkPackageFormView: React.FC = ({ variant="contained" type="submit" sx={{ mx: 1 }} - disabled={!changeRequestInputExists && !!defaultValues} + disabled={!changeRequestInputExists && !!defaultValues && !onlyLeadershipChanged} > Submit diff --git a/src/frontend/src/utils/enum-pipes.ts b/src/frontend/src/utils/enum-pipes.ts index 57926da60c..201e66fd01 100644 --- a/src/frontend/src/utils/enum-pipes.ts +++ b/src/frontend/src/utils/enum-pipes.ts @@ -55,6 +55,8 @@ export const ChangeRequestTypeTextPipe: (type: ChangeRequestType) => string = (t return 'Other'; case ChangeRequestType.Budget: return 'Budget'; + case ChangeRequestType.Leadership: + return 'Leadership'; } }; diff --git a/src/frontend/src/utils/urls.ts b/src/frontend/src/utils/urls.ts index 316e21837d..393a7ce87e 100644 --- a/src/frontend/src/utils/urls.ts +++ b/src/frontend/src/utils/urls.ts @@ -131,6 +131,7 @@ const changeRequestsCreate = () => `${changeRequests()}/new`; const changeRequestsCreateActivation = () => `${changeRequestsCreate()}/activation`; const changeRequestsCreateStageGate = () => `${changeRequestsCreate()}/stage-gate`; const changeRequestsCreateBudget = () => `${changeRequestsCreate()}/budget`; +const changeRequestsCreateLeadership = () => `${changeRequestsCreate()}/leadership`; const changeRequestsCreateStandard = () => `${changeRequestsCreate()}/standard`; const changeRequestCreateProposeSolution = () => `${changeRequestsCreate()}/proposed-solution`; const changeRequestRequestReviewer = (id: string) => changeRequestsById(id) + '/request-review'; @@ -580,6 +581,7 @@ export const apiUrls = { changeRequestsCreateActivation, changeRequestsCreateStageGate, changeRequestsCreateBudget, + changeRequestsCreateLeadership, changeRequestsCreateStandard, changeRequestCreateProposeSolution, changeRequestRequestReviewer, diff --git a/src/shared/src/types/change-request-types.ts b/src/shared/src/types/change-request-types.ts index 0646ee41fe..a57b56b354 100644 --- a/src/shared/src/types/change-request-types.ts +++ b/src/shared/src/types/change-request-types.ts @@ -34,7 +34,8 @@ export const ChangeRequestType = { Other: 'OTHER', StageGate: 'STAGE_GATE', Activation: 'ACTIVATION', - Budget: 'BUDGET' + Budget: 'BUDGET', + Leadership: 'LEADERSHIP' } as const; // eslint-disable-next-line @typescript-eslint/no-redeclare export type ChangeRequestType = (typeof ChangeRequestType)[keyof typeof ChangeRequestType]; @@ -79,6 +80,11 @@ export interface BudgetChangeRequest extends ChangeRequest { proposedBudget: number; } +export interface LeadershipChangeRequest extends ChangeRequest { + lead?: User; + manager?: User; +} + export interface ChangeRequestExplanation { type: ChangeRequestReason; explain: string; @@ -155,3 +161,10 @@ export interface WorkPackageProposedChangesCreateArgs extends WBSProposedChanges stage?: WorkPackageStage; blockedBy: WbsNumber[]; } + +export interface LeadershipChangeCreateArgs { + submitterId: string; + wbsNum: WbsNumber; + leadId?: string; + managerId?: string; +}