diff --git a/src/backend/custom.d.ts b/src/backend/custom.d.ts index 964ba270d6..39cdc3623c 100644 --- a/src/backend/custom.d.ts +++ b/src/backend/custom.d.ts @@ -1,4 +1,4 @@ -import { Organization } from '@prisma/client'; +import { Organization, Prisma } from '@prisma/client'; import { User as SharedUser } from 'shared'; declare global { @@ -6,6 +6,7 @@ declare global { export interface Request { currentUser: SharedUser; organization: Organization; + currentCar?: Prisma.CarGetPayload<{ include: { wbsElement: true } }>; } } } diff --git a/src/backend/index.ts b/src/backend/index.ts index 0df1277a2d..6443801418 100644 --- a/src/backend/index.ts +++ b/src/backend/index.ts @@ -3,6 +3,7 @@ import cors from 'cors'; import cookieParser from 'cookie-parser'; import { getUserAndOrganization, prodHeaders, requireJwtDev, requireJwtProd } from './src/utils/auth.utils.js'; import { errorHandler } from './src/utils/errors.utils.js'; +import { getCurrentCar } from './src/utils/car.utils.js'; import userRouter from './src/routes/users.routes.js'; import projectRouter from './src/routes/projects.routes.js'; import teamsRouter from './src/routes/teams.routes.js'; @@ -90,6 +91,9 @@ app.use(isProd ? requireJwtProd : requireJwtDev); // get user and organization app.use(getUserAndOrganization); +// get current car +app.use(getCurrentCar); + // routes app.use('/users', userRouter); app.use('/projects', projectRouter); diff --git a/src/backend/src/controllers/change-requests.controllers.ts b/src/backend/src/controllers/change-requests.controllers.ts index 3618191093..0dd94267ba 100644 --- a/src/backend/src/controllers/change-requests.controllers.ts +++ b/src/backend/src/controllers/change-requests.controllers.ts @@ -16,7 +16,7 @@ export default class ChangeRequestsController { static async getAllChangeRequests(req: Request, res: Response, next: NextFunction) { try { - const changeRequests = await ChangeRequestsService.getAllChangeRequests(req.organization); + const changeRequests = await ChangeRequestsService.getAllChangeRequests(req.organization, req.currentCar?.carId); res.status(200).json(changeRequests); } catch (error: unknown) { next(error); @@ -34,7 +34,11 @@ export default class ChangeRequestsController { static async getToReviewChangeRequests(req: Request, res: Response, next: NextFunction) { try { - const changeRequests = await ChangeRequestsService.getToReviewChangeRequests(req.currentUser, req.organization); + const changeRequests = await ChangeRequestsService.getToReviewChangeRequests( + req.currentUser, + req.organization, + req.currentCar?.carId + ); res.status(200).json(changeRequests); } catch (error: unknown) { next(error); @@ -50,7 +54,8 @@ export default class ChangeRequestsController { const changeRequests = await ChangeRequestsService.getUnreviewedChangeRequests( req.currentUser, validatedWbs, - req.organization + req.organization, + req.currentCar?.carId ); res.status(200).json(changeRequests); } catch (error: unknown) { @@ -67,7 +72,8 @@ export default class ChangeRequestsController { const changeRequests = await ChangeRequestsService.getApprovedChangeRequests( req.currentUser, validatedWbs, - req.organization + req.organization, + req.currentCar?.carId ); res.status(200).json(changeRequests); } catch (error: unknown) { diff --git a/src/backend/src/controllers/finance.controllers.ts b/src/backend/src/controllers/finance.controllers.ts index 7caaefee22..016a0ce498 100644 --- a/src/backend/src/controllers/finance.controllers.ts +++ b/src/backend/src/controllers/finance.controllers.ts @@ -172,17 +172,17 @@ export default class FinanceController { static async getReimbursementRequestTeamData(req: Request, res: Response, next: NextFunction) { try { const { teamId } = req.params as Record; - const { startDate, endDate, carNumber } = req.query; + const { startDate, endDate } = req.query; const parsedStartDate = typeof startDate === 'string' ? new Date(startDate) : undefined; const parsedEndDate = typeof endDate === 'string' ? new Date(endDate) : undefined; - const parsedCarNumber = typeof carNumber === 'string' ? Number(carNumber) : undefined; + const carNumber = req.currentCar?.wbsElement.carNumber; const rrData = await FinanceServices.getReimbursementRequestTeamData( req.organization, teamId, parsedStartDate, parsedEndDate, - parsedCarNumber + carNumber ); res.status(200).json(rrData); } catch (error: unknown) { @@ -193,17 +193,17 @@ export default class FinanceController { static async getReimbursementRequestTeamTypeData(req: Request, res: Response, next: NextFunction) { try { const { teamTypeId } = req.params as Record; - const { startDate, endDate, carNumber } = req.query; + const { startDate, endDate } = req.query; const parsedStartDate = typeof startDate === 'string' ? new Date(startDate) : undefined; const parsedEndDate = typeof endDate === 'string' ? new Date(endDate) : undefined; - const parsedCarNumber = typeof carNumber === 'string' ? Number(carNumber) : undefined; + const carNumber = req.currentCar?.wbsElement.carNumber; const rrData = await FinanceServices.getReimbursementRequestTeamTypeData( req.organization, teamTypeId, parsedStartDate, parsedEndDate, - parsedCarNumber + carNumber ); res.status(200).json(rrData); } catch (error: unknown) { @@ -214,17 +214,17 @@ export default class FinanceController { static async getSpendingBarTeamData(req: Request, res: Response, next: NextFunction) { try { const { teamId } = req.params as Record; - const { startDate, endDate, carNumber } = req.query; + const { startDate, endDate } = req.query; const parsedStartDate = typeof startDate === 'string' ? new Date(startDate) : undefined; const parsedEndDate = typeof endDate === 'string' ? new Date(endDate) : undefined; - const parsedCarNumber = typeof carNumber === 'string' ? Number(carNumber) : undefined; + const carNumber = req.currentCar?.wbsElement.carNumber; const spendingBarData = await FinanceServices.getSpendingBarTeamData( req.organization, teamId, parsedStartDate, parsedEndDate, - parsedCarNumber + carNumber ); res.status(200).json(spendingBarData); } catch (error: unknown) { @@ -235,17 +235,17 @@ export default class FinanceController { static async getSpendingBarTeamTypeData(req: Request, res: Response, next: NextFunction) { try { const { teamTypeId } = req.params as Record; - const { startDate, endDate, carNumber } = req.query; + const { startDate, endDate } = req.query; const parsedStartDate = typeof startDate === 'string' ? new Date(startDate) : undefined; const parsedEndDate = typeof endDate === 'string' ? new Date(endDate) : undefined; - const parsedCarNumber = typeof carNumber === 'string' ? Number(carNumber) : undefined; + const carNumber = req.currentCar?.wbsElement.carNumber; const spendingBarData = await FinanceServices.getSpendingBarTeamTypeData( req.organization, teamTypeId, parsedStartDate, parsedEndDate, - parsedCarNumber + carNumber ); res.status(200).json(spendingBarData); } catch (error: unknown) { @@ -255,16 +255,16 @@ export default class FinanceController { static async getAllReimbursementRequestData(req: Request, res: Response, next: NextFunction) { try { - const { startDate, endDate, carNumber } = req.query; + const { startDate, endDate } = req.query; const parsedStartDate = typeof startDate === 'string' ? new Date(startDate) : undefined; const parsedEndDate = typeof endDate === 'string' ? new Date(endDate) : undefined; - const parsedCarNumber = typeof carNumber === 'string' ? Number(carNumber) : undefined; + const carNumber = req.currentCar?.wbsElement.carNumber; const rrData = await FinanceServices.getAllReimbursementRequestData( req.organization, parsedStartDate, parsedEndDate, - parsedCarNumber + carNumber ); res.status(200).json(rrData); } catch (error: unknown) { @@ -275,17 +275,17 @@ export default class FinanceController { static async getReimbursementRequestCategoryData(req: Request, res: Response, next: NextFunction) { try { const { otherReasonId } = req.params as Record; - const { startDate, endDate, carNumber } = req.query; + const { startDate, endDate } = req.query; const parsedStartDate = typeof startDate === 'string' ? new Date(startDate) : undefined; const parsedEndDate = typeof endDate === 'string' ? new Date(endDate) : undefined; - const parsedCarNumber = typeof carNumber === 'string' ? Number(carNumber) : undefined; + const carNumber = req.currentCar?.wbsElement.carNumber; const rrData = await FinanceServices.getReimbursementRequestCategoryData( otherReasonId, req.organization, parsedStartDate, parsedEndDate, - parsedCarNumber + carNumber ); res.status(200).json(rrData); } catch (error: unknown) { @@ -295,16 +295,16 @@ export default class FinanceController { static async getAllSpendingBarData(req: Request, res: Response, next: NextFunction) { try { - const { startDate, endDate, carNumber } = req.query; + const { startDate, endDate } = req.query; const parsedStartDate = typeof startDate === 'string' ? new Date(startDate) : undefined; const parsedEndDate = typeof endDate === 'string' ? new Date(endDate) : undefined; - const parsedCarNumber = typeof carNumber === 'string' ? Number(carNumber) : undefined; + const carNumber = req.currentCar?.wbsElement.carNumber; const spendingBarData = await FinanceServices.getAllSpendingBarData( req.organization, parsedStartDate, parsedEndDate, - parsedCarNumber + carNumber ); res.status(200).json(spendingBarData); } catch (error: unknown) { @@ -314,7 +314,17 @@ export default class FinanceController { static async getSpendingBarCategoryData(req: Request, res: Response, next: NextFunction) { try { - const spendingBarData = await FinanceServices.getSpendingBarCategoryData(req.organization); + const { startDate, endDate } = req.query; + const parsedStartDate = typeof startDate === 'string' ? new Date(startDate) : undefined; + const parsedEndDate = typeof endDate === 'string' ? new Date(endDate) : undefined; + const carNumber = req.currentCar?.wbsElement.carNumber; + + const spendingBarData = await FinanceServices.getSpendingBarCategoryData( + req.organization, + parsedStartDate, + parsedEndDate, + carNumber + ); res.status(200).json(spendingBarData); } catch (error: unknown) { next(error); diff --git a/src/backend/src/controllers/projects.controllers.ts b/src/backend/src/controllers/projects.controllers.ts index a76df6cdd3..058e6af3c8 100644 --- a/src/backend/src/controllers/projects.controllers.ts +++ b/src/backend/src/controllers/projects.controllers.ts @@ -16,7 +16,7 @@ import BillOfMaterialsService from '../services/boms.services.js'; export default class ProjectsController { static async getAllProjectsGantt(req: Request, res: Response, next: NextFunction) { try { - const projects: ProjectGantt[] = await ProjectsService.getAllProjectsGantt(req.organization); + const projects: ProjectGantt[] = await ProjectsService.getAllProjectsGantt(req.organization, req.currentCar?.carId); res.status(200).json(projects); } catch (error: unknown) { next(error); @@ -25,7 +25,7 @@ export default class ProjectsController { static async getAllProjects(req: Request, res: Response, next: NextFunction) { try { - const projects: ProjectPreview[] = await ProjectsService.getAllProjects(req.organization); + const projects: ProjectPreview[] = await ProjectsService.getAllProjects(req.organization, req.currentCar?.carId); res.status(200).json(projects); } catch (error: unknown) { next(error); @@ -34,7 +34,11 @@ export default class ProjectsController { static async getUsersTeamsProjects(req: Request, res: Response, next: NextFunction) { try { - const projects: ProjectOverview[] = await ProjectsService.getUsersTeamsProjects(req.currentUser, req.organization); + const projects: ProjectOverview[] = await ProjectsService.getUsersTeamsProjects( + req.currentUser, + req.organization, + req.currentCar?.carId + ); res.status(200).json(projects); } catch (error: unknown) { next(error); @@ -43,7 +47,11 @@ export default class ProjectsController { static async getUsersLeadingProjects(req: Request, res: Response, next: NextFunction) { try { - const projects: ProjectOverview[] = await ProjectsService.getUsersLeadingProjects(req.currentUser, req.organization); + const projects: ProjectOverview[] = await ProjectsService.getUsersLeadingProjects( + req.currentUser, + req.organization, + req.currentCar?.carId + ); res.status(200).json(projects); } catch (error: unknown) { next(error); @@ -53,7 +61,7 @@ export default class ProjectsController { static async getTeamsProjects(req: Request, res: Response, next: NextFunction) { try { const { teamId } = req.params as Record; - const projects: Project[] = await ProjectsService.getTeamsProjects(req.organization, teamId); + const projects: Project[] = await ProjectsService.getTeamsProjects(req.organization, teamId, req.currentCar?.carId); res.status(200).json(projects); } catch (error: unknown) { next(error); diff --git a/src/backend/src/controllers/reimbursement-requests.controllers.ts b/src/backend/src/controllers/reimbursement-requests.controllers.ts index ed28d1b410..5b29bb17cd 100644 --- a/src/backend/src/controllers/reimbursement-requests.controllers.ts +++ b/src/backend/src/controllers/reimbursement-requests.controllers.ts @@ -13,9 +13,11 @@ import { HttpException } from '../utils/errors.utils.js'; export default class ReimbursementRequestsController { static async getCurrentUserReimbursementRequests(req: Request, res: Response, next: NextFunction) { try { + const carNumber = req.currentCar?.wbsElement.carNumber; const userReimbursementRequests = await ReimbursementRequestService.getUserReimbursementRequests( req.currentUser, - req.organization + req.organization, + carNumber ); res.status(200).json(userReimbursementRequests); } catch (error: unknown) { @@ -25,9 +27,11 @@ export default class ReimbursementRequestsController { static async getCurrentUserAssignedReimbursementRequests(req: Request, res: Response, next: NextFunction) { try { + const carNumber = req.currentCar?.wbsElement.carNumber; const assignedReimbursementRequests = await ReimbursementRequestService.getUserAssignedReimbursementRequests( req.currentUser, - req.organization + req.organization, + carNumber ); res.status(200).json(assignedReimbursementRequests); } catch (error: unknown) { @@ -46,9 +50,11 @@ export default class ReimbursementRequestsController { static async getCurrentUsersTeamsReimbursementRequests(req: Request, res: Response, next: NextFunction) { try { + const carNumber = req.currentCar?.wbsElement.carNumber; const userTeamsReimbursementRequests = await ReimbursementRequestService.getUsersTeamsReimbursementRequests( req.currentUser, - req.organization + req.organization, + carNumber ); res.status(200).json(userTeamsReimbursementRequests); } catch (error: unknown) { @@ -208,9 +214,11 @@ export default class ReimbursementRequestsController { static async getPendingAdvisorList(req: Request, res: Response, next: NextFunction) { try { + const carNumber = req.currentCar?.wbsElement.carNumber; const requestsPendingAdvisors: ReimbursementRequest[] = await ReimbursementRequestService.getPendingAdvisorList( req.currentUser, - req.organization + req.organization, + carNumber ); res.status(200).json(requestsPendingAdvisors); } catch (error: unknown) { @@ -308,9 +316,11 @@ export default class ReimbursementRequestsController { static async getAllReimbursementRequests(req: Request, res: Response, next: NextFunction) { try { + const carNumber = req.currentCar?.wbsElement.carNumber; const reimbursementRequests: ReimbursementRequest[] = await ReimbursementRequestService.getAllReimbursementRequests( req.currentUser, - req.organization + req.organization, + carNumber ); res.status(200).json(reimbursementRequests); } catch (error: unknown) { diff --git a/src/backend/src/controllers/users.controllers.ts b/src/backend/src/controllers/users.controllers.ts index 814f302fff..75fe877002 100644 --- a/src/backend/src/controllers/users.controllers.ts +++ b/src/backend/src/controllers/users.controllers.ts @@ -74,7 +74,11 @@ export default class UsersController { static async getUsersFavoriteProjects(req: Request, res: Response, next: NextFunction) { try { - const projects = await UsersService.getUsersFavoriteProjects(req.currentUser.userId, req.organization); + const projects = await UsersService.getUsersFavoriteProjects( + req.currentUser.userId, + req.organization, + req.currentCar?.carId + ); res.status(200).json(projects); } catch (error: unknown) { diff --git a/src/backend/src/controllers/work-packages.controllers.ts b/src/backend/src/controllers/work-packages.controllers.ts index 2c0b34ddf9..1d7224e7bc 100644 --- a/src/backend/src/controllers/work-packages.controllers.ts +++ b/src/backend/src/controllers/work-packages.controllers.ts @@ -7,9 +7,13 @@ export default class WorkPackagesController { // Fetch all work packages, optionally filtered by query parameters static async getAllWorkPackages(req: Request, res: Response, next: NextFunction) { try { - const { query } = req; + const { status, daysUntilDeadline } = req.query as { status?: string; daysUntilDeadline?: string }; - const outputWorkPackages: WorkPackage[] = await WorkPackagesService.getAllWorkPackages(query, req.organization); + const outputWorkPackages: WorkPackage[] = await WorkPackagesService.getAllWorkPackages( + { status, daysUntilDeadline }, + req.organization, + req.currentCar?.carId + ); res.status(200).json(outputWorkPackages); } catch (error: unknown) { @@ -24,7 +28,8 @@ export default class WorkPackagesController { const outputWorkPackages: WorkPackagePreview[] = await WorkPackagesService.getAllWorkPackagesPreview( status, - req.organization + req.organization, + req.currentCar?.carId ); res.status(200).json(outputWorkPackages); @@ -158,7 +163,8 @@ export default class WorkPackagesController { const workPackages: WorkPackagePreview[] = await WorkPackagesService.getHomePageWorkPackages( req.currentUser, req.organization, - selection as WorkPackageSelection + selection as WorkPackageSelection, + req.currentCar?.carId ); res.status(200).json(workPackages); diff --git a/src/backend/src/prisma/seed-data/reimbursement-requests.seed.ts b/src/backend/src/prisma/seed-data/reimbursement-requests.seed.ts index c36f9800ec..64da4be25b 100644 --- a/src/backend/src/prisma/seed-data/reimbursement-requests.seed.ts +++ b/src/backend/src/prisma/seed-data/reimbursement-requests.seed.ts @@ -85,7 +85,7 @@ export const seedReimbursementRequests = async ( { name: 'High Performance Battery Pack', reason: { - carNumber: 0, + carNumber: 25, projectNumber: 1, workPackageNumber: 0 }, @@ -116,7 +116,7 @@ export const seedReimbursementRequests = async ( { name: 'Development Tools Kit', reason: { - carNumber: 0, + carNumber: 25, projectNumber: 1, workPackageNumber: 0 }, @@ -152,7 +152,7 @@ export const seedReimbursementRequests = async ( { name: 'Cloud Storage Subscription', reason: { - carNumber: 0, + carNumber: 25, projectNumber: 2, workPackageNumber: 0 }, @@ -193,7 +193,7 @@ export const seedReimbursementRequests = async ( { name: 'Unnecessary Luxury Item', reason: { - carNumber: 0, + carNumber: 25, projectNumber: 1, workPackageNumber: 0 }, @@ -224,7 +224,7 @@ export const seedReimbursementRequests = async ( { name: 'Safety Equipment - Helmets', reason: { - carNumber: 0, + carNumber: 25, projectNumber: 1, workPackageNumber: 0 }, @@ -239,7 +239,7 @@ export const seedReimbursementRequests = async ( { name: 'Safety Equipment - Gloves', reason: { - carNumber: 0, + carNumber: 25, projectNumber: 1, workPackageNumber: 0 }, @@ -275,7 +275,7 @@ export const seedReimbursementRequests = async ( { name: 'Office Supplies', reason: { - carNumber: 0, + carNumber: 25, projectNumber: 2, workPackageNumber: 0 }, @@ -316,7 +316,7 @@ export const seedReimbursementRequests = async ( { name: 'Testing Equipment', reason: { - carNumber: 0, + carNumber: 25, projectNumber: 1, workPackageNumber: 0 }, @@ -346,7 +346,7 @@ export const seedReimbursementRequests = async ( { name: 'Software Licenses', reason: { - carNumber: 0, + carNumber: 25, projectNumber: 2, workPackageNumber: 0 }, @@ -387,7 +387,7 @@ export const seedReimbursementRequests = async ( { name: 'Training Materials', reason: { - carNumber: 0, + carNumber: 25, projectNumber: 1, workPackageNumber: 0 }, @@ -423,7 +423,7 @@ export const seedReimbursementRequests = async ( { name: 'Research Database Access', reason: { - carNumber: 0, + carNumber: 25, projectNumber: 2, workPackageNumber: 0 }, @@ -464,7 +464,7 @@ export const seedReimbursementRequests = async ( { name: 'Workshop Snacks', reason: { - carNumber: 0, + carNumber: 25, projectNumber: 1, workPackageNumber: 0 }, @@ -494,7 +494,7 @@ export const seedReimbursementRequests = async ( { name: 'Sensor Components', reason: { - carNumber: 0, + carNumber: 25, projectNumber: 1, workPackageNumber: 0 }, @@ -541,7 +541,7 @@ export const seedReimbursementRequests = async ( { name: 'Emergency Replacement Parts', reason: { - carNumber: 0, + carNumber: 25, projectNumber: 2, workPackageNumber: 0 }, @@ -577,7 +577,7 @@ export const seedReimbursementRequests = async ( { name: 'Team Building Materials', reason: { - carNumber: 0, + carNumber: 25, projectNumber: 1, workPackageNumber: 0 }, @@ -612,7 +612,7 @@ export const seedReimbursementRequests = async ( { name: 'Learning Resources', reason: { - carNumber: 0, + carNumber: 25, projectNumber: 1, workPackageNumber: 0 }, @@ -648,7 +648,7 @@ export const seedReimbursementRequests = async ( { name: 'Presentation Materials', reason: { - carNumber: 0, + carNumber: 25, projectNumber: 2, workPackageNumber: 0 }, @@ -689,7 +689,7 @@ export const seedReimbursementRequests = async ( { name: 'Personal Electronics', reason: { - carNumber: 0, + carNumber: 25, projectNumber: 1, workPackageNumber: 0 }, @@ -720,7 +720,7 @@ export const seedReimbursementRequests = async ( { name: 'CAD Software License', reason: { - carNumber: 0, + carNumber: 25, projectNumber: 2, workPackageNumber: 0 }, @@ -761,7 +761,7 @@ export const seedReimbursementRequests = async ( { name: 'Microcontrollers', reason: { - carNumber: 0, + carNumber: 25, projectNumber: 1, workPackageNumber: 0 }, @@ -797,7 +797,7 @@ export const seedReimbursementRequests = async ( { name: 'Video Conferencing Equipment', reason: { - carNumber: 0, + carNumber: 25, projectNumber: 2, workPackageNumber: 0 }, @@ -844,7 +844,7 @@ export const seedReimbursementRequests = async ( { name: 'Workshop Cleaning Supplies', reason: { - carNumber: 0, + carNumber: 25, projectNumber: 1, workPackageNumber: 0 }, @@ -874,7 +874,7 @@ export const seedReimbursementRequests = async ( { name: 'Hand Tools Set', reason: { - carNumber: 0, + carNumber: 25, projectNumber: 1, workPackageNumber: 0 }, @@ -915,7 +915,7 @@ export const seedReimbursementRequests = async ( { name: '3D Printing Filament', reason: { - carNumber: 0, + carNumber: 25, projectNumber: 1, workPackageNumber: 0 }, @@ -951,7 +951,7 @@ export const seedReimbursementRequests = async ( { name: 'Carbon Fiber Sheets', reason: { - carNumber: 0, + carNumber: 25, projectNumber: 1, workPackageNumber: 0 }, @@ -966,7 +966,7 @@ export const seedReimbursementRequests = async ( { name: 'Epoxy Resin', reason: { - carNumber: 0, + carNumber: 25, projectNumber: 1, workPackageNumber: 0 }, @@ -981,7 +981,7 @@ export const seedReimbursementRequests = async ( { name: 'Aluminum Stock', reason: { - carNumber: 0, + carNumber: 25, projectNumber: 1, workPackageNumber: 0 }, @@ -1028,7 +1028,7 @@ export const seedReimbursementRequests = async ( { name: 'High-Speed Data Acquisition System', reason: { - carNumber: 0, + carNumber: 25, projectNumber: 2, workPackageNumber: 0 }, @@ -1058,7 +1058,7 @@ export const seedReimbursementRequests = async ( { name: 'Power Supply Units', reason: { - carNumber: 0, + carNumber: 25, projectNumber: 1, workPackageNumber: 0 }, @@ -1109,7 +1109,7 @@ export const seedReimbursementRequests = async ( { name: 'Development Software Licenses', reason: { - carNumber: 0, + carNumber: 25, projectNumber: 2, workPackageNumber: 0 }, @@ -1160,7 +1160,7 @@ export const seedReimbursementRequests = async ( { name: 'Cloud Computing Credits', reason: { - carNumber: 0, + carNumber: 25, projectNumber: 1, workPackageNumber: 0 }, @@ -1217,7 +1217,7 @@ export const seedReimbursementRequests = async ( { name: 'Safety Equipment', reason: { - carNumber: 0, + carNumber: 25, projectNumber: 1, workPackageNumber: 0 }, @@ -1274,7 +1274,7 @@ export const seedReimbursementRequests = async ( { name: 'Tablets for Design Team', reason: { - carNumber: 0, + carNumber: 25, projectNumber: 2, workPackageNumber: 0 }, @@ -1342,7 +1342,7 @@ export const seedReimbursementRequests = async ( { name: 'Bulk Workshop Supplies', reason: { - carNumber: 0, + carNumber: 25, projectNumber: 1, workPackageNumber: 0 }, @@ -1410,7 +1410,7 @@ export const seedReimbursementRequests = async ( { name: 'Battery Testing Equipment', reason: { - carNumber: 0, + carNumber: 25, projectNumber: 1, workPackageNumber: 0 }, @@ -1483,7 +1483,7 @@ export const seedReimbursementRequests = async ( { name: 'PCB Manufacturing', reason: { - carNumber: 0, + carNumber: 25, projectNumber: 2, workPackageNumber: 0 }, @@ -1556,7 +1556,7 @@ export const seedReimbursementRequests = async ( { name: 'Team Event Supplies', reason: { - carNumber: 0, + carNumber: 25, projectNumber: 1, workPackageNumber: 0 }, diff --git a/src/backend/src/prisma/seed.ts b/src/backend/src/prisma/seed.ts index 4ac671baca..6734f879af 100644 --- a/src/backend/src/prisma/seed.ts +++ b/src/backend/src/prisma/seed.ts @@ -300,6 +300,40 @@ const performSeed: () => Promise = async () => { } }); + await prisma.car.create({ + data: { + wbsElement: { + create: { + name: 'NER-24', + carNumber: 24, + projectNumber: 0, + workPackageNumber: 0, + organizationId + } + } + }, + include: { + wbsElement: true + } + }); + + const car25 = await prisma.car.create({ + data: { + wbsElement: { + create: { + name: 'NER-25', + carNumber: 25, + projectNumber: 0, + workPackageNumber: 0, + organizationId + } + } + }, + include: { + wbsElement: true + } + }); + const miles = await prisma.car.create({ data: { wbsElement: { @@ -318,11 +352,11 @@ const performSeed: () => Promise = async () => { }); /** - * Make an initial change request for car 1 using the wbs of the genesis project + * Make an initial change request for NER-25 using the wbs of the genesis project */ const changeRequest1: StandardChangeRequest = await ChangeRequestsService.createStandardChangeRequest( cyborg, - fergus.wbsElement.carNumber, + car25.wbsElement.carNumber, fergus.wbsElement.projectNumber, fergus.wbsElement.workPackageNumber, CR_Type.OTHER, @@ -607,7 +641,7 @@ const performSeed: () => Promise = async () => { } = await seedProject( thomasEmrax, changeRequest1.crId, - fergus.wbsElement.carNumber, + car25.wbsElement.carNumber, 'Impact Attenuator', 'Develop rules-compliant impact attenuator', [huskies.teamId], @@ -635,7 +669,7 @@ const performSeed: () => Promise = async () => { const { projectWbsNumber: projectHuskies2WbsNumber, projectId: projectHuskies2Id } = await seedProject( thomasEmrax, changeRequest1.crId, - fergus.wbsElement.carNumber, + car25.wbsElement.carNumber, 'Bodywork', 'Develop rules-compliant bodywork', [huskies.teamId], @@ -663,7 +697,7 @@ const performSeed: () => Promise = async () => { const { projectWbsNumber: projectHuskies3WbsNumber, projectId: projectHuskies3Id } = await seedProject( thomasEmrax, changeRequest1.crId, - fergus.wbsElement.carNumber, + car25.wbsElement.carNumber, 'Battery Box', 'Develop rules-compliant battery box.', [huskies.teamId], @@ -691,7 +725,7 @@ const performSeed: () => Promise = async () => { const { projectWbsNumber: projectHuskies4WbsNumber, projectId: projectHuskies4Id } = await seedProject( thomasEmrax, changeRequest1.crId, - fergus.wbsElement.carNumber, + car25.wbsElement.carNumber, 'Motor Controller Integration', 'Develop rules-compliant motor controller integration.', [huskies.teamId], @@ -724,7 +758,7 @@ const performSeed: () => Promise = async () => { } = await seedProject( thomasEmrax, changeRequest1.crId, - fergus.wbsElement.carNumber, + car25.wbsElement.carNumber, 'Wiring Harness', 'Develop rules-compliant wiring harness.', [slackBotTeam.teamId], @@ -809,7 +843,7 @@ const performSeed: () => Promise = async () => { const { projectWbsNumber: projectAvatar1WbsNumber, projectId: projectAvatar1Id } = await seedProject( aang, changeRequest1.crId, - 0, + car25.wbsElement.carNumber, 'Appa Plush', 'Manufacture plushes of Appa for moral support.', [avatarBenders.teamId], @@ -838,7 +872,7 @@ const performSeed: () => Promise = async () => { const { projectWbsNumber: projectJustice1WbsNumber, projectId: projectJustice1Id } = await seedProject( lexLuther, changeRequest1.crId, - 0, + car25.wbsElement.carNumber, 'Laser Cannon Prototype', 'Develop a prototype of a laser cannon for the Justice League', [justiceLeague.teamId], @@ -895,7 +929,7 @@ const performSeed: () => Promise = async () => { const { projectWbsNumber: projectRavens1WbsNumber } = await seedProject( ryanGiggs, changeRequest1.crId, - 0, + car25.wbsElement.carNumber, 'Stadium Renovation', `Renovate the team's stadium to improve fan experience`, [ravens.teamId], @@ -3472,7 +3506,7 @@ const performSeed: () => Promise = async () => { '1', thomasEmrax, { - carNumber: 0, + carNumber: car25.wbsElement.carNumber, projectNumber: 1, workPackageNumber: 0 }, @@ -3486,7 +3520,7 @@ const performSeed: () => Promise = async () => { 'Resistor', 'https://www.youtube.com/watch?v=dQw4w9WgXcQ', { - carNumber: 0, + carNumber: car25.wbsElement.carNumber, projectNumber: 1, workPackageNumber: 0 }, @@ -3509,7 +3543,7 @@ const performSeed: () => Promise = async () => { 'Resistor', 'https://www.youtube.com/watch?v=dQw4w9WgXcQ', { - carNumber: 0, + carNumber: car25.wbsElement.carNumber, projectNumber: 1, workPackageNumber: 0 }, @@ -3532,7 +3566,7 @@ const performSeed: () => Promise = async () => { 'Resistor', 'https://www.youtube.com/watch?v=dQw4w9WgXcQ', { - carNumber: 0, + carNumber: car25.wbsElement.carNumber, projectNumber: 1, workPackageNumber: 0 }, @@ -3559,7 +3593,7 @@ const performSeed: () => Promise = async () => { [thomasEmrax.userId, batman.userId], [superman.userId, wonderwoman.userId], { - carNumber: 0, + carNumber: car25.wbsElement.carNumber, projectNumber: 1, workPackageNumber: 0 }, diff --git a/src/backend/src/routes/work-packages.routes.ts b/src/backend/src/routes/work-packages.routes.ts index 328bcad2a5..b3aa8ac529 100644 --- a/src/backend/src/routes/work-packages.routes.ts +++ b/src/backend/src/routes/work-packages.routes.ts @@ -62,7 +62,9 @@ workPackagesRouter.post( WorkPackagesController.editWorkPackage ); workPackagesRouter.delete('/:wbsNum/delete', WorkPackagesController.deleteWorkPackage); + workPackagesRouter.get('/:wbsNum/blocking', WorkPackagesController.getBlockingWorkPackages); + workPackagesRouter.post( '/slack-upcoming-deadlines', isDateOnly(body('deadline')), diff --git a/src/backend/src/services/change-requests.services.ts b/src/backend/src/services/change-requests.services.ts index 58bb0b3f77..8afab03ff5 100644 --- a/src/backend/src/services/change-requests.services.ts +++ b/src/backend/src/services/change-requests.services.ts @@ -93,9 +93,13 @@ export default class ChangeRequestsService { * @param organization The organization the user is currently in * @returns All of the change requests */ - static async getAllChangeRequests(organization: Organization): Promise { + static async getAllChangeRequests(organization: Organization, carId?: string): Promise { const changeRequests = await prisma.change_Request.findMany({ - where: { dateDeleted: null, organizationId: organization.organizationId }, + where: { + dateDeleted: null, + organizationId: organization.organizationId, + ...(carId && { wbsElement: { OR: [{ project: { carId } }, { workPackage: { project: { carId } } }] } }) + }, ...getManyChangeRequestQueryArgs(organization.organizationId) }); @@ -123,7 +127,7 @@ export default class ChangeRequestsService { * @param organization The organization the user is in * @returns The user's change requests for them to review */ - static async getToReviewChangeRequests(user: User, organization: Organization): Promise { + static async getToReviewChangeRequests(user: User, organization: Organization, carId?: string): Promise { const wbsOr: Prisma.WBS_ElementWhereInput[] = [{ managerId: user.userId }, { leadId: user.userId }]; if (await userHasPermission(user.userId, organization.organizationId, isLeadership)) { @@ -167,7 +171,8 @@ export default class ChangeRequestsService { }, { NOT: [{ scopeChangeRequest: null }, { submitterId: user.userId }] - } + }, + ...(carId ? [{ wbsElement: { OR: [{ project: { carId } }, { workPackage: { project: { carId } } }] } }] : []) ], organizationId: organization.organizationId, OR: queryOr @@ -189,7 +194,8 @@ export default class ChangeRequestsService { static async getUnreviewedChangeRequests( user: User, wbsnum: WbsNumber | undefined, - organization: Organization + organization: Organization, + carId?: string ): Promise { // Check that its unreviewed and a scope change request, omit activation and stage gate const queryAnd: Prisma.Change_RequestWhereInput[] = [ @@ -202,7 +208,12 @@ export default class ChangeRequestsService { ]; if (wbsnum) queryAnd.push({ wbsElementId: (await validateWbsElement(wbsnum, organization)).wbsElementId }); - else queryAnd.push({ submitterId: user.userId }); + else { + queryAnd.push({ submitterId: user.userId }); + queryAnd.push( + ...(carId ? [{ wbsElement: { OR: [{ project: { carId } }, { workPackage: { project: { carId } } }] } }] : []) + ); + } const changeRequests = await prisma.change_Request.findMany({ where: { @@ -227,13 +238,17 @@ export default class ChangeRequestsService { static async getApprovedChangeRequests( user: User, wbsnum: WbsNumber | undefined, - organization: Organization + organization: Organization, + carId?: string ): Promise { const currentDate = new Date(); const fiveDaysAgo = new Date(currentDate.getTime() - 1000 * 60 * 60 * 24 * 5); // Change requests that were reviewed less than five days ago const queryAnd = wbsnum ? [{ wbsElementId: (await validateWbsElement(wbsnum, organization)).wbsElementId }] - : [{ submitterId: user.userId }]; + : [ + { submitterId: user.userId }, + ...(carId ? [{ wbsElement: { OR: [{ project: { carId } }, { workPackage: { project: { carId } } }] } }] : []) + ]; const changeRequests = await prisma.change_Request.findMany({ where: { diff --git a/src/backend/src/services/finance.services.ts b/src/backend/src/services/finance.services.ts index 1f31fcd1b3..1740bf7075 100644 --- a/src/backend/src/services/finance.services.ts +++ b/src/backend/src/services/finance.services.ts @@ -520,6 +520,10 @@ export default class FinanceServices { return data; } + // Finance data filters by carNumber (integer) rather than carId (UUID) because the + // finance schema links through WbsElement, which owns carNumber directly. Filtering + // by carId would require nested Prisma joins across every finance utility function. + // carNumber is sourced from req.currentCar?.wbsElement.carNumber via middleware. static async getReimbursementRequestTeamData( organization: Organization, teamId: string, @@ -1139,7 +1143,12 @@ export default class FinanceServices { return data; } - static async getSpendingBarCategoryData(organization: Organization): Promise { + static async getSpendingBarCategoryData( + organization: Organization, + startDate?: Date, + endDate?: Date, + carNumber?: number + ): Promise { const { organizationId } = organization; const otherReasons = await prisma.reimbursement_Product_Other_Reason.findMany({ where: { @@ -1151,7 +1160,13 @@ export default class FinanceServices { }); const spendingInfoPromises = otherReasons.map((r) => - this.getReimbursementRequestCategoryData(r.otherReimbursementProductReasonId, organization) + this.getReimbursementRequestCategoryData( + r.otherReimbursementProductReasonId, + organization, + startDate, + endDate, + carNumber + ) ); const spendingInfos = await Promise.all(spendingInfoPromises); diff --git a/src/backend/src/services/projects.services.ts b/src/backend/src/services/projects.services.ts index f0684d5885..5267a81529 100644 --- a/src/backend/src/services/projects.services.ts +++ b/src/backend/src/services/projects.services.ts @@ -47,11 +47,12 @@ export default class ProjectsService { /** * Get all the non deleted projects in the database for the given organization * @param organization the organization the user is currently in + * @param carId optional car id to filter projects by * @returns all the projects with query args for use in the gantt chart */ - static async getAllProjectsGantt(organization: Organization): Promise { + static async getAllProjectsGantt(organization: Organization, carId?: string): Promise { const projects = await prisma.project.findMany({ - where: { wbsElement: { dateDeleted: null, organizationId: organization.organizationId } }, + where: { wbsElement: { dateDeleted: null, organizationId: organization.organizationId }, ...(carId && { carId }) }, ...getProjectGanttQueryArgs(organization.organizationId) }); @@ -61,11 +62,12 @@ export default class ProjectsService { /** * Get all projects for given organization * @param organization the organization the user is in + * @param carId optional car id to filter projects by * @returns all the projects with preview query args */ - static async getAllProjects(organization: Organization): Promise { + static async getAllProjects(organization: Organization, carId?: string): Promise { const projects = await prisma.project.findMany({ - where: { wbsElement: { dateDeleted: null, organizationId: organization.organizationId } }, + where: { wbsElement: { dateDeleted: null, organizationId: organization.organizationId }, ...(carId && { carId }) }, orderBy: { wbsElement: { dateCreated: 'desc' } }, ...getProjectPreviewQueryArgs(organization.organizationId) }); @@ -77,16 +79,18 @@ export default class ProjectsService { * Get all projects that the user is the lead or manager of * @param user the user making the request * @param organization the oranization the user is in + * @param carId optional car id to filter projects by * @returns the projects the user is a lead or manager of with preview query args */ - static async getUsersLeadingProjects(user: User, organization: Organization): Promise { + static async getUsersLeadingProjects(user: User, organization: Organization, carId?: string): Promise { const projects = await prisma.project.findMany({ where: { wbsElement: { organizationId: organization.organizationId, dateDeleted: null, OR: [{ leadId: user.userId }, { managerId: user.userId }] - } + }, + ...(carId && { carId }) }, ...getProjectOverviewQueryArgs(organization.organizationId) }); @@ -98,9 +102,10 @@ export default class ProjectsService { * Get all projects related to teams the user is on * @param user the user making the request * @param organization the organization the user is in + * @param carId optional car id to filter projects by * @returns all projects associated with teams the user is on with overview card query args */ - static async getUsersTeamsProjects(user: User, organization: Organization): Promise { + static async getUsersTeamsProjects(user: User, organization: Organization, carId?: string): Promise { const projects = await prisma.project.findMany({ where: { wbsElement: { @@ -129,7 +134,8 @@ export default class ProjectsService { } ] } - } + }, + ...(carId && { carId }) }, ...getProjectOverviewQueryArgs(organization.organizationId) }); @@ -141,9 +147,10 @@ export default class ProjectsService { * Get the projects for a given team * @param organization * @param teamId + * @param carId optional car id to filter projects by * @returns all the projects for the given team with full project query args */ - static async getTeamsProjects(organization: Organization, teamId: string): Promise { + static async getTeamsProjects(organization: Organization, teamId: string, carId?: string): Promise { const projects = await prisma.project.findMany({ where: { wbsElement: { @@ -154,7 +161,8 @@ export default class ProjectsService { some: { teamId } - } + }, + ...(carId && { carId }) }, ...getProjectQueryArgs(organization.organizationId) }); diff --git a/src/backend/src/services/reimbursement-requests.services.ts b/src/backend/src/services/reimbursement-requests.services.ts index a5c383883b..f5d1b779fe 100644 --- a/src/backend/src/services/reimbursement-requests.services.ts +++ b/src/backend/src/services/reimbursement-requests.services.ts @@ -85,9 +85,23 @@ export default class ReimbursementRequestService { * @param recipient The user retrieving their reimbursement requests * @param organizationId The organization the user is currently in */ - static async getUserReimbursementRequests(recipient: User, organization: Organization): Promise { + static async getUserReimbursementRequests( + recipient: User, + organization: Organization, + carNumber?: number + ): Promise { const userReimbursementRequests = await prisma.reimbursement_Request.findMany({ - where: { dateDeleted: null, recipientId: recipient.userId, organizationId: organization.organizationId }, + where: { + dateDeleted: null, + recipientId: recipient.userId, + organizationId: organization.organizationId, + ...(carNumber !== undefined && + carNumber !== null && { + reimbursementProducts: { + some: { reimbursementProductReason: { wbsElement: { carNumber } } } + } + }) + }, ...getReimbursementRequestQueryArgs(organization.organizationId) }); return userReimbursementRequests.map(reimbursementRequestTransformer); @@ -101,10 +115,21 @@ export default class ReimbursementRequestService { */ static async getUserAssignedReimbursementRequests( assignee: User, - organization: Organization + organization: Organization, + carNumber?: number ): Promise { const assignedReimbursementRequests = await prisma.reimbursement_Request.findMany({ - where: { dateDeleted: null, assigneeId: assignee.userId, organizationId: organization.organizationId }, + where: { + dateDeleted: null, + assigneeId: assignee.userId, + organizationId: organization.organizationId, + ...(carNumber !== undefined && + carNumber !== null && { + reimbursementProducts: { + some: { reimbursementProductReason: { wbsElement: { carNumber } } } + } + }) + }, ...getReimbursementRequestQueryArgs(organization.organizationId) }); return assignedReimbursementRequests.map(reimbursementRequestTransformer); @@ -117,7 +142,8 @@ export default class ReimbursementRequestService { */ static async getUsersTeamsReimbursementRequests( recipient: User, - organization: Organization + organization: Organization, + carNumber?: number ): Promise { const teams = await prisma.team.findMany({ where: { @@ -160,7 +186,13 @@ export default class ReimbursementRequestService { where: { dateDeleted: null, recipientId: { in: Array.from(teamUserIds) }, - organizationId: organization.organizationId + organizationId: organization.organizationId, + ...(carNumber !== undefined && + carNumber !== null && { + reimbursementProducts: { + some: { reimbursementProductReason: { wbsElement: { carNumber } } } + } + }) }, ...getReimbursementRequestQueryArgs(organization.organizationId) }); @@ -579,7 +611,11 @@ export default class ReimbursementRequestService { * @param organizationId the organization the user is currently in * @returns reimbursement requests with no advisor approved reimbursement status */ - static async getPendingAdvisorList(requester: User, organization: Organization): Promise { + static async getPendingAdvisorList( + requester: User, + organization: Organization, + carNumber?: number + ): Promise { await validateUserIsPartOfFinanceTeamOrHead(requester, organization.organizationId); const requestsPendingAdvisors = await prisma.reimbursement_Request.findMany({ @@ -593,7 +629,13 @@ export default class ReimbursementRequestService { type: Reimbursement_Status_Type.ADVISOR_APPROVED } }, - accountCode: { organizationId: organization.organizationId } + accountCode: { organizationId: organization.organizationId }, + ...(carNumber !== undefined && + carNumber !== null && { + reimbursementProducts: { + some: { reimbursementProductReason: { wbsElement: { carNumber } } } + } + }) }, ...getReimbursementRequestQueryArgs(organization.organizationId) }); @@ -980,13 +1022,26 @@ export default class ReimbursementRequestService { * @param organizationId the organization the user is currently in * @returns an array of the prisma version of the reimbursement requests transformed to the shared version */ - static async getAllReimbursementRequests(user: User, organization: Organization): Promise { + static async getAllReimbursementRequests( + user: User, + organization: Organization, + carNumber?: number + ): Promise { if (!(await isUserFinanceTeamOrHead(user, organization.organizationId))) { throw new AccessDeniedException(`You are not a member of the finance team!`); } const reimbursementRequests = await prisma.reimbursement_Request.findMany({ - where: { dateDeleted: null, accountCode: { organizationId: organization.organizationId } }, + where: { + dateDeleted: null, + accountCode: { organizationId: organization.organizationId }, + ...(carNumber !== undefined && + carNumber !== null && { + reimbursementProducts: { + some: { reimbursementProductReason: { wbsElement: { carNumber } } } + } + }) + }, ...getReimbursementRequestQueryArgs(organization.organizationId) }); diff --git a/src/backend/src/services/users.services.ts b/src/backend/src/services/users.services.ts index dd6c40f929..69bf4cf9e5 100644 --- a/src/backend/src/services/users.services.ts +++ b/src/backend/src/services/users.services.ts @@ -161,7 +161,11 @@ export default class UsersService { * @param organizationId the id of the organization the user is in * @returns the user's favorite projects */ - static async getUsersFavoriteProjects(userId: string, organization: Organization): Promise { + static async getUsersFavoriteProjects( + userId: string, + organization: Organization, + carId?: string + ): Promise { const requestedUser = await prisma.user.findUnique({ where: { userId } }); if (!requestedUser) throw new NotFoundException('User', userId); @@ -175,7 +179,8 @@ export default class UsersService { wbsElement: { organizationId: organization.organizationId, dateDeleted: null - } + }, + ...(carId && { carId }) }, ...getProjectOverviewQueryArgs(organization.organizationId) }); diff --git a/src/backend/src/services/work-packages.services.ts b/src/backend/src/services/work-packages.services.ts index 5db4b1a99f..83feb938d8 100644 --- a/src/backend/src/services/work-packages.services.ts +++ b/src/backend/src/services/work-packages.services.ts @@ -46,21 +46,26 @@ export default class WorkPackagesService { * * @param query the filters on the query * @param organizationId the id of the organization that the user is currently in + * @param carId the car number to filter by (only returns work packages from this car when provided) * @returns a list of work packages */ static async getAllWorkPackages( query: { - status?: WbsElementStatus; + status?: WbsElementStatus | string; daysUntilDeadline?: string; }, - organization: Organization + organization: Organization, + carId?: string ): Promise { const workPackages = await prisma.work_Package.findMany({ - where: { wbsElement: { dateDeleted: null, organizationId: organization.organizationId } }, + where: { + wbsElement: { dateDeleted: null, organizationId: organization.organizationId }, + ...(carId && { project: { carId } }) + }, ...getWorkPackageQueryArgs(organization.organizationId) }); - const outputWorkPackages = workPackages.map(workPackageTransformer).filter((wp) => { + const filteredWorkPackages = workPackages.map(workPackageTransformer).filter((wp) => { let passes = true; if (query.status) passes &&= wp.status === query.status; if (query.daysUntilDeadline) { @@ -70,9 +75,9 @@ export default class WorkPackagesService { return passes; }); - outputWorkPackages.sort((wpA, wpB) => wpA.endDate.getTime() - wpB.endDate.getTime()); + filteredWorkPackages.sort((wpA, wpB) => wpA.endDate.getTime() - wpB.endDate.getTime()); - return outputWorkPackages; + return filteredWorkPackages; } /** @@ -80,11 +85,13 @@ export default class WorkPackagesService { * * @param status Optional status filter * @param organization the organization + * @param carId the car number to filter by (only returns work packages from this car when provided) * @returns a list of work package previews */ static async getAllWorkPackagesPreview( status: WbsElementStatus | string | undefined, - organization: Organization + organization: Organization, + carId?: string ): Promise { const workPackages = await prisma.work_Package.findMany({ where: { @@ -92,7 +99,8 @@ export default class WorkPackagesService { dateDeleted: null, organizationId: organization.organizationId, ...(status ? { status: status as WbsElementStatus } : {}) - } + }, + ...(carId && { project: { carId } }) }, ...getWorkPackagePreviewQueryArgs() }); @@ -141,6 +149,7 @@ export default class WorkPackagesService { * Retrieve a subset of work packages. * @param wbsNums the WBS numbers of the work packages to retrieve * @param organizationId the id of the organization that the user is currently in + * @param carId optional car number to filter work packages by * @returns the work packages with the given WBS numbers * @throws if any of the work packages are not found or are not part of the organization */ @@ -154,12 +163,24 @@ export default class WorkPackagesService { } }); - const workPackagePromises = wbsNums.map(async (wbsNum) => { - return WorkPackagesService.getSingleWorkPackage(wbsNum, organization); + const whereConditions = wbsNums.map((wbsNum) => ({ + wbsElement: { + carNumber: wbsNum.carNumber, + projectNumber: wbsNum.projectNumber, + workPackageNumber: wbsNum.workPackageNumber, + organizationId: organization.organizationId, + dateDeleted: null + } + })); + + const workPackages = await prisma.work_Package.findMany({ + where: { + OR: whereConditions + }, + ...getWorkPackageQueryArgs(organization.organizationId) }); - const resolvedWorkPackages = await Promise.all(workPackagePromises); - return resolvedWorkPackages; + return workPackages.map(workPackageTransformer); } /** @@ -518,6 +539,7 @@ export default class WorkPackagesService { * Gets the work packages the given work package is blocking * @param wbsNum the wbs number of the work package to get the blocking work packages for * @param organizationId the id of the organization that the user is currently in + * @param carId the optional carId to filter work packages by * @returns the blocking work packages for the given work package */ static async getBlockingWorkPackages(wbsNum: WbsNumber, organization: Organization): Promise { @@ -548,7 +570,6 @@ export default class WorkPackagesService { throw new InvalidOrganizationException('Work Package'); const blockingWorkPackages = await getBlockingWorkPackages(workPackage); - return blockingWorkPackages.map(workPackageTransformer); } @@ -593,12 +614,14 @@ export default class WorkPackagesService { * * @param user The current user * @param organization The organization the current user is logged in for - * @param onlyOverdue Whether to only return overdue workpackages + * @param selection The selection type for filtering workpackages + * @param carId Optional car number to filter work packages by */ static async getHomePageWorkPackages( user: User, organization: Organization, - selection: WorkPackageSelection + selection: WorkPackageSelection, + carId?: string ): Promise { const selectionArgs = selection === WorkPackageSelection.ALL_OVERDUE @@ -634,7 +657,8 @@ export default class WorkPackagesService { dateDeleted: null, organizationId: organization.organizationId, status: { not: WBS_Element_Status.COMPLETE } - } + }, + ...(carId && { project: { carId } }) }, select: { project: { select: { projectId: true, wbsElement: { select: { name: true } } } }, diff --git a/src/backend/src/utils/car.utils.ts b/src/backend/src/utils/car.utils.ts new file mode 100644 index 0000000000..9e645280dd --- /dev/null +++ b/src/backend/src/utils/car.utils.ts @@ -0,0 +1,30 @@ +import { Request, Response, NextFunction } from 'express'; +import prisma from '../prisma/prisma.js'; +import { NotFoundException } from './errors.utils.js'; + +export const getCurrentCar = async (req: Request, _res: Response, next: NextFunction) => { + const carId = req.headers.carid; + + if (!carId || typeof carId !== 'string') { + return next(); + } + + try { + const car = await prisma.car.findUnique({ + where: { + carId, + wbsElement: { organizationId: req.organization.organizationId } + }, + include: { wbsElement: true } + }); + + if (!car) { + throw new NotFoundException('Car', carId); + } + + req.currentCar = car; + return next(); + } catch (error) { + return next(error); + } +}; diff --git a/src/backend/src/utils/validation.utils.ts b/src/backend/src/utils/validation.utils.ts index 153b32a0bf..5e5a8758d5 100644 --- a/src/backend/src/utils/validation.utils.ts +++ b/src/backend/src/utils/validation.utils.ts @@ -317,8 +317,7 @@ export const partPopupValidators = [ export const financeDashboardFilterValidators = [ nonEmptyString(query('startDate')).optional(), - nonEmptyString(query('endDate')).optional(), - nonEmptyString(query('carNumber')).optional() + nonEmptyString(query('endDate')).optional() ]; export const requireFile = (chain: ValidationChain): ValidationChain => { diff --git a/src/backend/src/utils/work-packages.utils.ts b/src/backend/src/utils/work-packages.utils.ts index 4e17ebd75b..ab4c231273 100644 --- a/src/backend/src/utils/work-packages.utils.ts +++ b/src/backend/src/utils/work-packages.utils.ts @@ -37,7 +37,7 @@ export const getBlockingWorkPackages = async (initialWorkPackage: Prisma.Work_Pa where: { wbsElementId: currWbsId }, include: { blocking: true, - workPackage: { ...getWorkPackageQueryArgs(initialWorkPackage.wbsElement.organizationId) } + workPackage: getWorkPackageQueryArgs(initialWorkPackage.wbsElement.organizationId) } }); diff --git a/src/backend/tests/test-utils.ts b/src/backend/tests/test-utils.ts index 4c8bcba6c1..f46df77c0a 100644 --- a/src/backend/tests/test-utils.ts +++ b/src/backend/tests/test-utils.ts @@ -397,7 +397,7 @@ export const createTestLinkType = async (user: User, organizationId?: string) => return linkType; }; -export const createTestCar = async (orgId?: string, userIdentification?: string) => { +export const createTestCar = async (orgId?: string, userIdentification?: string, carNumber: number = 0) => { if (!orgId) orgId = (await createTestOrganization()).organizationId; if (!userIdentification) userIdentification = (await createTestUser(supermanAdmin, orgId)).userId; @@ -405,7 +405,7 @@ export const createTestCar = async (orgId?: string, userIdentification?: string) data: { wbsElement: { create: { - carNumber: 0, + carNumber, projectNumber: 0, workPackageNumber: 0, dateCreated: new Date('01/01/2023'), @@ -427,6 +427,7 @@ export const createTestProject = async ( organizationId?: string, teamId?: string, carId?: string, + carNumber: number = 0, projectNumber: number = 1, dateDeleted?: Date ): Promise => { @@ -437,7 +438,7 @@ export const createTestProject = async ( data: { wbsElement: { create: { - carNumber: 0, + carNumber, projectNumber, workPackageNumber: 0, dateCreated: new Date('01/01/2023'), @@ -477,6 +478,36 @@ export const createTestProject = async ( return genesisProject; }; +export const createTestWorkPackage = async ( + user: User, + organizationId: string, + projectId: string, + carNumber: number = 0, + projectNumber: number = 1, + workPackageNumber: number = 1 +) => + prisma.work_Package.create({ + data: { + wbsElement: { + create: { + carNumber, + projectNumber, + workPackageNumber, + name: `WP ${carNumber}.${projectNumber}.${workPackageNumber}`, + status: WBS_Element_Status.ACTIVE, + leadId: user.userId, + managerId: user.userId, + organizationId + } + }, + project: { connect: { projectId } }, + startDate: new Date('2024-01-01'), + duration: 4, + orderInProject: workPackageNumber + }, + include: { wbsElement: true } + }); + export const createTestReimbursementRequest = async () => { const organization = await createTestOrganization(); await createFinanceTeamAndLead(organization); diff --git a/src/backend/tests/unit/car.utils.test.ts b/src/backend/tests/unit/car.utils.test.ts new file mode 100644 index 0000000000..c78f4a1344 --- /dev/null +++ b/src/backend/tests/unit/car.utils.test.ts @@ -0,0 +1,85 @@ +/// +/// +import { Request, Response, NextFunction } from 'express'; +import { createTestCar, createTestOrganization, createTestUser, resetUsers } from '../test-utils.js'; +import { getCurrentCar } from '../../src/utils/car.utils.js'; +import { NotFoundException } from '../../src/utils/errors.utils.js'; +import { supermanAdmin } from '../test-data/users.test-data.js'; +import prisma from '../../src/prisma/prisma.js'; + +describe('getCurrentCar Middleware', () => { + let orgId: string; + + beforeEach(async () => { + const org = await createTestOrganization(); + orgId = org.organizationId; + }); + + afterEach(async () => { + await resetUsers(); + }); + + it('calls next() without setting req.currentCar when no carId header is present', async () => { + const req = { headers: {} } as unknown as Request; + const res = {} as Response; + const next = vi.fn() as unknown as NextFunction; + + await getCurrentCar(req, res, next); + + expect(next).toHaveBeenCalledWith(); + expect(req.currentCar).toBeUndefined(); + }); + + it('sets req.currentCar with wbsElement and calls next() when carId header matches an existing car', async () => { + const user = await createTestUser(supermanAdmin, orgId); + const car = await createTestCar(orgId, user.userId); + + const req = { headers: { carid: car.carId }, organization: { organizationId: orgId } } as unknown as Request; + const res = {} as Response; + const next = vi.fn() as unknown as NextFunction; + + await getCurrentCar(req, res, next); + + expect(next).toHaveBeenCalledWith(); + expect(req.currentCar).toBeDefined(); + expect(req.currentCar?.carId).toBe(car.carId); + expect(req.currentCar?.wbsElement).toBeDefined(); + }); + + it('calls next() with a NotFoundException when carId header does not match any car', async () => { + const req = { headers: { carid: 'non-existent-car-id' }, organization: { organizationId: orgId } } as unknown as Request; + const res = {} as Response; + const next = vi.fn() as unknown as NextFunction; + + await getCurrentCar(req, res, next); + + expect(next).toHaveBeenCalledWith(expect.any(NotFoundException)); + expect(req.currentCar).toBeUndefined(); + }); + + it('calls next() without error and does not set req.currentCar when carId header is an array', async () => { + const req = { headers: { carid: ['id-one', 'id-two'] } } as unknown as Request; + const res = {} as Response; + const next = vi.fn() as unknown as NextFunction; + + await getCurrentCar(req, res, next); + + expect(next).toHaveBeenCalledWith(); + expect(req.currentCar).toBeUndefined(); + }); + + it('calls next() with the thrown error when Prisma throws unexpectedly', async () => { + const dbError = new Error('DB connection lost'); + const spy = vi.spyOn(prisma.car, 'findUnique').mockRejectedValueOnce(dbError); + + const req = { headers: { carid: 'some-car-id' }, organization: { organizationId: orgId } } as unknown as Request; + const res = {} as Response; + const next = vi.fn() as unknown as NextFunction; + + await getCurrentCar(req, res, next); + + expect(next).toHaveBeenCalledWith(dbError); + + spy.mockRestore(); + }); +}); diff --git a/src/backend/tests/unit/change-requests.test.ts b/src/backend/tests/unit/change-requests.test.ts index 4b109c90f1..cfdd23fecc 100644 --- a/src/backend/tests/unit/change-requests.test.ts +++ b/src/backend/tests/unit/change-requests.test.ts @@ -1,5 +1,5 @@ import { CR_Type, Organization, Scope_CR_Why_Type, User, WBS_Element_Status } from '@prisma/client'; -import { createTestOrganization, createTestUser, resetUsers } from '../test-utils.js'; +import { createTestCar, createTestOrganization, createTestProject, createTestUser, resetUsers } from '../test-utils.js'; import ChangeRequestsService from '../../src/services/change-requests.services.js'; import { supermanAdmin, @@ -494,4 +494,328 @@ describe('Change Request Tests', () => { ).rejects.toThrow(AccessDeniedException); }); }); + + describe('global car filter', () => { + let carAId: string; + let carBId: string; + let otherUser: User; + + const solutionArgs = [{ description: 'Solution', scopeImpact: 'Low', timelineImpact: 0, budgetImpact: 0 }]; + + // projPropChanges makes a CR a scope CR + const projPropChanges = { + name: 'Updated project', + descriptionBullets: [], + links: [], + budget: 100, + summary: 'Summary', + teamIds: [], + workPackageProposedChanges: [] + }; + + beforeEach(async () => { + // The reviewing user (user) cannot be the submitter of scope CRs they review so otherUser is used . + otherUser = await createTestUser(aquamanLeadership, orgId); + + const carA = await createTestCar(orgId, user.userId, 0); + carAId = carA.carId; + const carB = await createTestCar(orgId, user.userId, 1); + carBId = carB.carId; + + // Project under car A: WBS 0.1.0, project.carId = carAId, lead/manager = user + await createTestProject(user, orgId, undefined, carAId, 0, 1); + // Project under car B: WBS 0.2.0, project.carId = carBId, lead/manager = user + await createTestProject(user, orgId, undefined, carBId, 0, 2); + }); + + describe('getAllChangeRequests', () => { + it('respects the global car filter and returns only CRs for the selected car', async () => { + await ChangeRequestsService.createStandardChangeRequest( + user, + 0, + 1, + 0, + CR_Type.ISSUE, + 'CR on car A', + [{ type: Scope_CR_Why_Type.COMPETITION, explain: 'reason' }], + solutionArgs, + organization, + null, + null + ); + await ChangeRequestsService.createStandardChangeRequest( + user, + 0, + 2, + 0, + CR_Type.ISSUE, + 'CR on car B', + [{ type: Scope_CR_Why_Type.COMPETITION, explain: 'reason' }], + solutionArgs, + organization, + null, + null + ); + + const results = await ChangeRequestsService.getAllChangeRequests(organization, carAId); + + expect(results).toHaveLength(1); + expect(results[0].wbsNum?.projectNumber).toBe(1); // car A's project + }); + + it('returns all CRs when no car is selected', async () => { + await ChangeRequestsService.createStandardChangeRequest( + user, + 0, + 1, + 0, + CR_Type.ISSUE, + 'CR on car A', + [{ type: Scope_CR_Why_Type.COMPETITION, explain: 'reason' }], + solutionArgs, + organization, + null, + null + ); + await ChangeRequestsService.createStandardChangeRequest( + user, + 0, + 2, + 0, + CR_Type.ISSUE, + 'CR on car B', + [{ type: Scope_CR_Why_Type.COMPETITION, explain: 'reason' }], + solutionArgs, + organization, + null, + null + ); + + const results = await ChangeRequestsService.getAllChangeRequests(organization); + + expect(results).toHaveLength(2); + }); + }); + + describe('getToReviewChangeRequests', () => { + it('respects the global car filter and returns only to-review CRs for the selected car', async () => { + await ChangeRequestsService.createStandardChangeRequest( + otherUser, + 0, + 1, + 0, + CR_Type.DEFINITION_CHANGE, + 'Scope CR on car A', + [{ type: Scope_CR_Why_Type.COMPETITION, explain: 'reason' }], + [], + organization, + projPropChanges, + null + ); + await ChangeRequestsService.createStandardChangeRequest( + otherUser, + 0, + 2, + 0, + CR_Type.DEFINITION_CHANGE, + 'Scope CR on car B', + [{ type: Scope_CR_Why_Type.COMPETITION, explain: 'reason' }], + [], + organization, + projPropChanges, + null + ); + + const results = await ChangeRequestsService.getToReviewChangeRequests(user, organization, carAId); + + expect(results).toHaveLength(1); + expect(results[0].wbsNum?.projectNumber).toBe(1); // car A's project + }); + + it('returns all to-review CRs when no car is selected', async () => { + await ChangeRequestsService.createStandardChangeRequest( + otherUser, + 0, + 1, + 0, + CR_Type.DEFINITION_CHANGE, + 'Scope CR on car A', + [{ type: Scope_CR_Why_Type.COMPETITION, explain: 'reason' }], + [], + organization, + projPropChanges, + null + ); + await ChangeRequestsService.createStandardChangeRequest( + otherUser, + 0, + 2, + 0, + CR_Type.DEFINITION_CHANGE, + 'Scope CR on car B', + [{ type: Scope_CR_Why_Type.COMPETITION, explain: 'reason' }], + [], + organization, + projPropChanges, + null + ); + + const results = await ChangeRequestsService.getToReviewChangeRequests(user, organization); + + expect(results).toHaveLength(2); + }); + }); + + describe('getUnreviewedChangeRequests', () => { + it('respects the global car filter and returns only unreviewed CRs for the selected car', async () => { + await ChangeRequestsService.createStandardChangeRequest( + user, + 0, + 1, + 0, + CR_Type.ISSUE, + 'Unreviewed CR on car A', + [{ type: Scope_CR_Why_Type.COMPETITION, explain: 'reason' }], + solutionArgs, + organization, + null, + null + ); + await ChangeRequestsService.createStandardChangeRequest( + user, + 0, + 2, + 0, + CR_Type.ISSUE, + 'Unreviewed CR on car B', + [{ type: Scope_CR_Why_Type.COMPETITION, explain: 'reason' }], + solutionArgs, + organization, + null, + null + ); + + const results = await ChangeRequestsService.getUnreviewedChangeRequests(user, undefined, organization, carAId); + + expect(results).toHaveLength(1); + expect(results[0].wbsNum?.projectNumber).toBe(1); // car A's project + }); + + it('ignores the global car filter when a wbsNum is provided and returns CRs matching the wbsNum', async () => { + await ChangeRequestsService.createStandardChangeRequest( + user, + 0, + 1, + 0, + CR_Type.ISSUE, + 'Unreviewed CR on car A', + [{ type: Scope_CR_Why_Type.COMPETITION, explain: 'reason' }], + solutionArgs, + organization, + null, + null + ); + await ChangeRequestsService.createStandardChangeRequest( + user, + 0, + 2, + 0, + CR_Type.ISSUE, + 'Unreviewed CR on car B', + [{ type: Scope_CR_Why_Type.COMPETITION, explain: 'reason' }], + solutionArgs, + organization, + null, + null + ); + + // wbsNum scopes to car A's project; carId points to car B - car filter should be ignored + const wbsNum = { carNumber: 0, projectNumber: 1, workPackageNumber: 0 }; + const results = await ChangeRequestsService.getUnreviewedChangeRequests(user, wbsNum, organization, carBId); + + expect(results).toHaveLength(1); + expect(results[0].wbsNum?.projectNumber).toBe(1); // car A's project + }); + }); + + describe('getApprovedChangeRequests', () => { + it('respects the global car filter and returns only recent CRs for the selected car', async () => { + const crA = await ChangeRequestsService.createStandardChangeRequest( + user, + 0, + 1, + 0, + CR_Type.ISSUE, + 'Recent CR on car A', + [{ type: Scope_CR_Why_Type.COMPETITION, explain: 'reason' }], + solutionArgs, + organization, + null, + null + ); + const crB = await ChangeRequestsService.createStandardChangeRequest( + user, + 0, + 2, + 0, + CR_Type.ISSUE, + 'Recent CR on car B', + [{ type: Scope_CR_Why_Type.COMPETITION, explain: 'reason' }], + solutionArgs, + organization, + null, + null + ); + + // getApprovedChangeRequests requires dateReviewed >= fiveDaysAgo - review both CRs to satisfy this + await ChangeRequestsService.reviewChangeRequest(otherUser, crA.crId, '', false, organization, null); + await ChangeRequestsService.reviewChangeRequest(otherUser, crB.crId, '', false, organization, null); + + const results = await ChangeRequestsService.getApprovedChangeRequests(user, undefined, organization, carAId); + + expect(results).toHaveLength(1); + expect(results[0].wbsNum?.projectNumber).toBe(1); // car A's project + }, 15000); + + it('ignores the global car filter when a wbsNum is provided and returns CRs matching the wbsNum', async () => { + const crA = await ChangeRequestsService.createStandardChangeRequest( + user, + 0, + 1, + 0, + CR_Type.ISSUE, + 'Recent CR on car A', + [{ type: Scope_CR_Why_Type.COMPETITION, explain: 'reason' }], + solutionArgs, + organization, + null, + null + ); + const crB = await ChangeRequestsService.createStandardChangeRequest( + user, + 0, + 2, + 0, + CR_Type.ISSUE, + 'Recent CR on car B', + [{ type: Scope_CR_Why_Type.COMPETITION, explain: 'reason' }], + solutionArgs, + organization, + null, + null + ); + + // getApprovedChangeRequests requires dateReviewed >= fiveDaysAgo - review both CRs to satisfy this + await ChangeRequestsService.reviewChangeRequest(otherUser, crA.crId, '', false, organization, null); + await ChangeRequestsService.reviewChangeRequest(otherUser, crB.crId, '', false, organization, null); + + // wbsNum scopes to car A's project; carId points to car B - car filter should be ignored + const wbsNum = { carNumber: 0, projectNumber: 1, workPackageNumber: 0 }; + const results = await ChangeRequestsService.getApprovedChangeRequests(user, wbsNum, organization, carBId); + + expect(results).toHaveLength(1); + expect(results[0].wbsNum?.projectNumber).toBe(1); // car A's project + }, 15000); + }); + }); }); diff --git a/src/backend/tests/unit/part-review.test.ts b/src/backend/tests/unit/part-review.test.ts index 7ff46016f0..f201c0e2c7 100644 --- a/src/backend/tests/unit/part-review.test.ts +++ b/src/backend/tests/unit/part-review.test.ts @@ -978,7 +978,7 @@ describe('part review tests', () => { const team = await createTestTeam(batman.userId, division.teamTypeId, orgId); const car = await createTestCar(orgId, batman.userId); - const project = await createTestProject(batman, orgId, team.teamId, car.carId, 1); + const project = await createTestProject(batman, orgId, team.teamId, car.carId, 0, 1); const project1 = await prisma.project.findUnique({ where: { projectId: project.projectId }, include: { @@ -1002,7 +1002,7 @@ describe('part review tests', () => { const team = await createTestTeam(batman.userId, division.teamTypeId, orgId); const car = await createTestCar(orgId, batman.userId); - const project = await createTestProject(batman, orgId, team.teamId, car.carId, 1); + const project = await createTestProject(batman, orgId, team.teamId, car.carId, 0, 1); const project1 = await prisma.project.findUnique({ where: { projectId: project.projectId }, include: { @@ -1024,7 +1024,7 @@ describe('part review tests', () => { const car = await createTestCar(orgId, batman.userId); // Create a project with no parts - await createTestProject(batman, orgId, team1.teamId, car.carId, 4); + await createTestProject(batman, orgId, team1.teamId, car.carId, 0, 4); const proj1WbsNum = validateWBS('0.4.0'); @@ -1040,8 +1040,8 @@ describe('part review tests', () => { const team2 = await createTestTeam(superman.userId, division.teamTypeId, orgId); const car = await createTestCar(orgId, batman.userId); - const project1 = await createTestProject(batman, orgId, team1.teamId, car.carId, 1); - const project2 = await createTestProject(superman, orgId, team2.teamId, car.carId, 2); + const project1 = await createTestProject(batman, orgId, team1.teamId, car.carId, 0, 1); + const project2 = await createTestProject(superman, orgId, team2.teamId, car.carId, 0, 2); const proj1WbsNum = validateWBS('0.1.0'); const proj2WbsNum = validateWBS('0.2.0'); diff --git a/src/backend/tests/unit/reimbursement-requests.test.ts b/src/backend/tests/unit/reimbursement-requests.test.ts index 0717511bc0..7f10f8c8e4 100644 --- a/src/backend/tests/unit/reimbursement-requests.test.ts +++ b/src/backend/tests/unit/reimbursement-requests.test.ts @@ -1,7 +1,7 @@ import { alfred } from '../test-data/users.test-data.js'; import ReimbursementRequestService from '../../src/services/reimbursement-requests.services.js'; import { AccessDeniedException, DeletedException, HttpException, NotFoundException } from '../../src/utils/errors.utils.js'; -import { createTestReimbursementRequest, createTestUser, resetUsers } from '../test-utils.js'; +import { createTestCar, createTestReimbursementRequest, createTestUser, resetUsers } from '../test-utils.js'; import prisma from '../../src/prisma/prisma.js'; import { assert } from 'console'; import { addDaysToDate, IndexCode, ReimbursementRequest, AccountCode, OtherProductReason } from 'shared'; @@ -714,6 +714,152 @@ describe('Reimbursement Requests', () => { }); }); + describe('Car filtering for reimbursement request list endpoints', () => { + let car1RR: ReimbursementRequest; + + beforeEach(async () => { + await createTestCar(org.organizationId, createdUser.userId, 1); + car1RR = await ReimbursementRequestService.createReimbursementRequest( + createdUser, + createdVendor.vendorId, + createdIndexCode.indexCodeId, + [], + [ + { + name: 'BOLT', + reason: { carNumber: 1, projectNumber: 0, workPackageNumber: 0 }, + cost: 500, + refundSources: [{ indexCode: createdIndexCode, amount: 500 }] + } + ], + createdAccountCode.accountCodeId, + 500, + org + ); + }); + + describe('getUserReimbursementRequests', () => { + test('returns only requests whose products belong to the given car', async () => { + const car0Results = await ReimbursementRequestService.getUserReimbursementRequests(createdUser, org, 0); + expect(car0Results.map((r) => r.reimbursementRequestId)).toContain(reimbursementRequest.reimbursementRequestId); + expect(car0Results.map((r) => r.reimbursementRequestId)).not.toContain(car1RR.reimbursementRequestId); + + const car1Results = await ReimbursementRequestService.getUserReimbursementRequests(createdUser, org, 1); + expect(car1Results.map((r) => r.reimbursementRequestId)).toContain(car1RR.reimbursementRequestId); + expect(car1Results.map((r) => r.reimbursementRequestId)).not.toContain(reimbursementRequest.reimbursementRequestId); + }); + + test('returns all requests when no carNumber is provided', async () => { + const allResults = await ReimbursementRequestService.getUserReimbursementRequests(createdUser, org); + expect(allResults.map((r) => r.reimbursementRequestId)).toContain(reimbursementRequest.reimbursementRequestId); + expect(allResults.map((r) => r.reimbursementRequestId)).toContain(car1RR.reimbursementRequestId); + }); + + test('carNumber 0 filters to only car-0 requests and does not return all requests (Fergus regression)', async () => { + const fergusResults = await ReimbursementRequestService.getUserReimbursementRequests(createdUser, org, 0); + expect(fergusResults).toHaveLength(1); + expect(fergusResults[0].reimbursementRequestId).toBe(reimbursementRequest.reimbursementRequestId); + }); + }); + + describe('getAllReimbursementRequests', () => { + test('returns only requests whose products belong to the given car', async () => { + const financeHead = await prisma.user.findUniqueOrThrow({ where: { googleAuthId: 'financeHead' } }); + + const car0Results = await ReimbursementRequestService.getAllReimbursementRequests(financeHead, org, 0); + expect(car0Results.map((r) => r.reimbursementRequestId)).toContain(reimbursementRequest.reimbursementRequestId); + expect(car0Results.map((r) => r.reimbursementRequestId)).not.toContain(car1RR.reimbursementRequestId); + + const car1Results = await ReimbursementRequestService.getAllReimbursementRequests(financeHead, org, 1); + expect(car1Results.map((r) => r.reimbursementRequestId)).toContain(car1RR.reimbursementRequestId); + expect(car1Results.map((r) => r.reimbursementRequestId)).not.toContain(reimbursementRequest.reimbursementRequestId); + }); + + test('returns all requests when no carNumber is provided', async () => { + const financeHead = await prisma.user.findUniqueOrThrow({ where: { googleAuthId: 'financeHead' } }); + const allResults = await ReimbursementRequestService.getAllReimbursementRequests(financeHead, org); + expect(allResults.map((r) => r.reimbursementRequestId)).toContain(reimbursementRequest.reimbursementRequestId); + expect(allResults.map((r) => r.reimbursementRequestId)).toContain(car1RR.reimbursementRequestId); + }); + + test('carNumber 0 filters to only car-0 requests and does not return all requests (Fergus regression)', async () => { + const financeHead = await prisma.user.findUniqueOrThrow({ where: { googleAuthId: 'financeHead' } }); + const fergusResults = await ReimbursementRequestService.getAllReimbursementRequests(financeHead, org, 0); + expect(fergusResults).toHaveLength(1); + expect(fergusResults[0].reimbursementRequestId).toBe(reimbursementRequest.reimbursementRequestId); + }); + }); + + describe('getUserAssignedReimbursementRequests', () => { + test('returns only assigned requests whose products belong to the given car', async () => { + const financeHead = await prisma.user.findUniqueOrThrow({ where: { googleAuthId: 'financeHead' } }); + const financeMember = await prisma.user.findUniqueOrThrow({ where: { googleAuthId: 'financeMember' } }); + await ReimbursementRequestService.assignFinanceMember( + financeHead, + org, + reimbursementRequest.reimbursementRequestId, + financeMember.userId + ); + await ReimbursementRequestService.assignFinanceMember( + financeHead, + org, + car1RR.reimbursementRequestId, + financeMember.userId + ); + + const car0Results = await ReimbursementRequestService.getUserAssignedReimbursementRequests(financeMember, org, 0); + expect(car0Results.map((r) => r.reimbursementRequestId)).toContain(reimbursementRequest.reimbursementRequestId); + expect(car0Results.map((r) => r.reimbursementRequestId)).not.toContain(car1RR.reimbursementRequestId); + + const car1Results = await ReimbursementRequestService.getUserAssignedReimbursementRequests(financeMember, org, 1); + expect(car1Results.map((r) => r.reimbursementRequestId)).toContain(car1RR.reimbursementRequestId); + expect(car1Results.map((r) => r.reimbursementRequestId)).not.toContain(reimbursementRequest.reimbursementRequestId); + }); + + test('carNumber 0 filters to only car-0 assigned requests and does not return all (Fergus regression)', async () => { + const financeHead = await prisma.user.findUniqueOrThrow({ where: { googleAuthId: 'financeHead' } }); + const financeMember = await prisma.user.findUniqueOrThrow({ where: { googleAuthId: 'financeMember' } }); + await ReimbursementRequestService.assignFinanceMember( + financeHead, + org, + reimbursementRequest.reimbursementRequestId, + financeMember.userId + ); + await ReimbursementRequestService.assignFinanceMember( + financeHead, + org, + car1RR.reimbursementRequestId, + financeMember.userId + ); + + const fergusResults = await ReimbursementRequestService.getUserAssignedReimbursementRequests(financeMember, org, 0); + expect(fergusResults).toHaveLength(1); + expect(fergusResults[0].reimbursementRequestId).toBe(reimbursementRequest.reimbursementRequestId); + }); + }); + + describe('getUsersTeamsReimbursementRequests', () => { + test('returns only requests whose products belong to the given car', async () => { + const financeHead = await prisma.user.findUniqueOrThrow({ where: { googleAuthId: 'financeHead' } }); + + const car0Results = await ReimbursementRequestService.getUsersTeamsReimbursementRequests(financeHead, org, 0); + expect(car0Results.map((r) => r.reimbursementRequestId)).toContain(reimbursementRequest.reimbursementRequestId); + expect(car0Results.map((r) => r.reimbursementRequestId)).not.toContain(car1RR.reimbursementRequestId); + + const car1Results = await ReimbursementRequestService.getUsersTeamsReimbursementRequests(financeHead, org, 1); + expect(car1Results.map((r) => r.reimbursementRequestId)).toContain(car1RR.reimbursementRequestId); + expect(car1Results.map((r) => r.reimbursementRequestId)).not.toContain(reimbursementRequest.reimbursementRequestId); + }); + + test('carNumber 0 filters to only car-0 requests and does not return all requests (Fergus regression)', async () => { + const financeHead = await prisma.user.findUniqueOrThrow({ where: { googleAuthId: 'financeHead' } }); + const fergusResults = await ReimbursementRequestService.getUsersTeamsReimbursementRequests(financeHead, org, 0); + expect(fergusResults).toHaveLength(1); + expect(fergusResults[0].reimbursementRequestId).toBe(reimbursementRequest.reimbursementRequestId); + }); + }); + }); + describe('Editing an other reimbursement product reason', () => { test('Successfully editing a other reimbursement product reason', async () => { const reason = await ReimbursementRequestService.createOtherReasonReimbursementProduct( diff --git a/src/backend/tests/unit/work-packages.test.ts b/src/backend/tests/unit/work-packages.test.ts new file mode 100644 index 0000000000..542ef26eef --- /dev/null +++ b/src/backend/tests/unit/work-packages.test.ts @@ -0,0 +1,72 @@ +import { Organization, User } from '@prisma/client'; +import prisma from '../../src/prisma/prisma.js'; +import { + createTestCar, + createTestOrganization, + createTestProject, + createTestUser, + createTestWorkPackage, + resetUsers +} from '../test-utils.js'; +import { supermanAdmin } from '../test-data/users.test-data.js'; +import WorkPackagesService from '../../src/services/work-packages.services.js'; + +describe('WorkPackagesService', () => { + let organization: Organization; + let orgId: string; + let user: User; + + beforeEach(async () => { + organization = await createTestOrganization(); + orgId = organization.organizationId; + user = await createTestUser(supermanAdmin, orgId); + }); + + afterEach(async () => { + await resetUsers(); + }); + + describe('getManyWorkPackages', () => { + it('returns all work packages matching the requested WBS numbers', async () => { + const car1 = await createTestCar(orgId, user.userId, 1); + const proj1 = await createTestProject(user, orgId, undefined, car1.carId, 1, 1); + await createTestWorkPackage(user, orgId, proj1.projectId, 1, 1, 1); + await createTestWorkPackage(user, orgId, proj1.projectId, 1, 1, 2); + + const result = await WorkPackagesService.getManyWorkPackages( + [ + { carNumber: 1, projectNumber: 1, workPackageNumber: 1 }, + { carNumber: 1, projectNumber: 1, workPackageNumber: 2 } + ], + organization + ); + + expect(result).toHaveLength(2); + const wpNumbers = result.map((wp) => wp.wbsNum.workPackageNumber).sort((a, b) => a - b); + expect(wpNumbers).toEqual([1, 2]); + }); + }); + + describe('getBlockingWorkPackages', () => { + it('returns work packages that are blocked by the given work package', async () => { + const car1 = await createTestCar(orgId, user.userId, 1); + const proj1 = await createTestProject(user, orgId, undefined, car1.carId, 1, 1); + const wpA = await createTestWorkPackage(user, orgId, proj1.projectId, 1, 1, 1); + const wpB = await createTestWorkPackage(user, orgId, proj1.projectId, 1, 1, 2); + + // wpA blocks wpB + await prisma.work_Package.update({ + where: { workPackageId: wpB.workPackageId }, + data: { blockedBy: { connect: { wbsElementId: wpA.wbsElement.wbsElementId } } } + }); + + const result = await WorkPackagesService.getBlockingWorkPackages( + { carNumber: 1, projectNumber: 1, workPackageNumber: 1 }, + organization + ); + + expect(result).toHaveLength(1); + expect(result[0].wbsNum).toEqual({ carNumber: 1, projectNumber: 1, workPackageNumber: 2 }); + }); + }); +}); diff --git a/src/backend/tests/unmocked/cars.test.ts b/src/backend/tests/unmocked/cars.test.ts new file mode 100644 index 0000000000..99c93b7b5f --- /dev/null +++ b/src/backend/tests/unmocked/cars.test.ts @@ -0,0 +1,146 @@ +import { Organization, User } from '@prisma/client'; +import { createTestCar, createTestOrganization, createTestUser, resetUsers } from '../test-utils'; +import { supermanAdmin, member } from '../test-data/users.test-data'; +import CarsService from '../../src/services/car.services'; +import { AccessDeniedAdminOnlyException } from '../../src/utils/errors.utils'; +import prisma from '../../src/prisma/prisma'; + +describe('Cars Tests', () => { + let org: Organization; + let adminUser: User; + let nonAdminUser: User; + + beforeEach(async () => { + org = await createTestOrganization(); + adminUser = await createTestUser(supermanAdmin, org.organizationId); + nonAdminUser = await createTestUser(member, org.organizationId); + }); + + afterEach(async () => { + await resetUsers(); + }); + + describe('getAllCars', () => { + test('getAllCars returns empty array when no cars exist', async () => { + const cars = await CarsService.getAllCars(org); + expect(cars).toEqual([]); + }); + + test('getAllCars returns all cars for organization', async () => { + await createTestCar(org.organizationId, adminUser.userId, 0); + await createTestCar(org.organizationId, adminUser.userId, 1); + + const cars = await CarsService.getAllCars(org); + expect(cars).toHaveLength(2); + }); + + test('getAllCars only returns cars for specified organization', async () => { + // Create car in our org + await createTestCar(org.organizationId, adminUser.userId, 0); + + // Create car in different org + const uniqueId = `${Date.now()}-${Math.random()}`; + const orgCreator = await prisma.user.create({ + data: { + firstName: 'Org', + lastName: 'Creator', + email: `org-${uniqueId}@test.com`, + googleAuthId: `org-${uniqueId}` + } + }); + + const otherOrg = await prisma.organization.create({ + data: { + name: 'Other Org', + description: 'Other organization', + applicationLink: '', + userCreatedId: orgCreator.userId + } + }); + + const otherUser = await createTestUser( + { + ...supermanAdmin, + googleAuthId: `admin-${uniqueId}`, + email: `admin-${uniqueId}@test.com`, + emailId: `admin-${uniqueId}` + }, + otherOrg.organizationId + ); + + await createTestCar(otherOrg.organizationId, otherUser.userId, 0); + + const cars = await CarsService.getAllCars(org); + expect(cars).toHaveLength(1); + }); + }); + + describe('createCar', () => { + test('createCar successfully creates car with admin permissions', async () => { + const carName = 'Test Car'; + + const createdCar = await CarsService.createCar(org, adminUser, carName); + + expect(createdCar.name).toBe(carName); + expect(createdCar.wbsNum.carNumber).toBe(0); // First car should have car number 0 + expect(createdCar.wbsNum.projectNumber).toBe(0); + expect(createdCar.wbsNum.workPackageNumber).toBe(0); + }); + + test('createCar assigns correct car number based on existing cars', async () => { + // Create first car + await CarsService.createCar(org, adminUser, 'Car 1'); + + // Create second car + const secondCar = await CarsService.createCar(org, adminUser, 'Car 2'); + + expect(secondCar.wbsNum.carNumber).toBe(1); // Should be incremented + }); + + test('createCar throws AccessDeniedAdminOnlyException for non-admin user', async () => { + await expect(CarsService.createCar(org, nonAdminUser, 'Test Car')).rejects.toThrow(AccessDeniedAdminOnlyException); + }); + + test('createCar car numbers are organization-specific', async () => { + // Create car in first org + const firstCar = await CarsService.createCar(org, adminUser, 'First Org Car'); + + // Create different org and admin + const uniqueId = `${Date.now()}-${Math.random()}`; + const orgCreator = await prisma.user.create({ + data: { + firstName: 'Org', + lastName: 'Creator', + email: `org2-${uniqueId}@test.com`, + googleAuthId: `org2-${uniqueId}` + } + }); + + const otherOrg = await prisma.organization.create({ + data: { + name: 'Second Org', + description: 'Second organization', + applicationLink: '', + userCreatedId: orgCreator.userId + } + }); + + const otherAdmin = await createTestUser( + { + ...supermanAdmin, + googleAuthId: `admin2-${uniqueId}`, + email: `admin2-${uniqueId}@test.com`, + emailId: `admin2-${uniqueId}` + }, + otherOrg.organizationId + ); + + // Create car in second org + const secondCar = await CarsService.createCar(otherOrg, otherAdmin, 'Second Org Car'); + + // Both should start from car number 0 + expect(firstCar.wbsNum.carNumber).toBe(0); + expect(secondCar.wbsNum.carNumber).toBe(0); + }); + }); +}); diff --git a/src/backend/tests/unmocked/statistics.test.ts b/src/backend/tests/unmocked/statistics.test.ts index 0e6cf7fe4c..aca04c4bce 100644 --- a/src/backend/tests/unmocked/statistics.test.ts +++ b/src/backend/tests/unmocked/statistics.test.ts @@ -92,7 +92,7 @@ describe('Statistics Tests', () => { const team = await createTestTeam(user.userId, division.teamTypeId, orgId); const car = await createTestCar(orgId, user.userId); await createTestProject(user, orgId, team.teamId, car.carId); - await createTestProject(user, orgId, team.teamId, car.carId, 2); + await createTestProject(user, orgId, team.teamId, car.carId, 0, 2); const result = await StatisticsService.createGraph( user, @@ -131,7 +131,7 @@ describe('Statistics Tests', () => { const team = await createTestTeam(user.userId, division.teamTypeId, orgId); const car = await createTestCar(orgId, user.userId); await createTestProject(user, orgId, team.teamId, car.carId); - await createTestProject(user, orgId, team.teamId, car.carId, 2); + await createTestProject(user, orgId, team.teamId, car.carId, 0, 2); const result = await StatisticsService.createGraph( user, @@ -173,7 +173,7 @@ describe('Statistics Tests', () => { const team = await createTestTeam(user.userId, division.teamTypeId, orgId); const car = await createTestCar(orgId, user.userId); await createTestProject(user, orgId, team.teamId, car.carId); - await createTestProject(user, orgId, team.teamId, car.carId, 2, new Date()); + await createTestProject(user, orgId, team.teamId, car.carId, 0, 2, new Date()); const result = await StatisticsService.createGraph( user, @@ -215,7 +215,7 @@ describe('Statistics Tests', () => { const team = await createTestTeam(user.userId, division.teamTypeId, orgId); const car = await createTestCar(orgId, user.userId); await createTestProject(user, orgId, team.teamId, car.carId); - await createTestProject(user, orgId, team.teamId, car.carId, 2, new Date()); + await createTestProject(user, orgId, team.teamId, car.carId, 0, 2, new Date()); const result = await StatisticsService.createGraph( user, @@ -255,7 +255,7 @@ describe('Statistics Tests', () => { const team = await createTestTeam(user.userId, division.teamTypeId, orgId); const car = await createTestCar(orgId, user.userId); await createTestProject(user, orgId, team.teamId, car.carId); - await createTestProject(user, orgId, team.teamId, car.carId, 2, new Date()); + await createTestProject(user, orgId, team.teamId, car.carId, 0, 2, new Date()); const result = await StatisticsService.createGraph( user, diff --git a/src/frontend/src/apis/finance.api.ts b/src/frontend/src/apis/finance.api.ts index bcc112c199..ee9a724885 100644 --- a/src/frontend/src/apis/finance.api.ts +++ b/src/frontend/src/apis/finance.api.ts @@ -664,8 +664,9 @@ export const getReimbursementRequestProjectData = (payload: ReimbursementRequest export const getReimbursementRequestTeamData = (payload: ReimbursementRequestTeamDataPayload) => { return axios.get( - apiUrls.getReimbursementRequestTeamData(payload.teamId, payload.startDate, payload.endDate, payload.carNumber), + apiUrls.getReimbursementRequestTeamData(payload.teamId, payload.startDate, payload.endDate), { + overrideCarId: payload.overrideCarId, transformResponse: (data) => reimbursementRequestDataTransformer(JSON.parse(data)) } ); @@ -673,65 +674,58 @@ export const getReimbursementRequestTeamData = (payload: ReimbursementRequestTea export const getReimbursementRequestCategoryData = (payload: ReimbursementRequestCategoryDataPayload) => { return axios.get( - apiUrls.getReimbursementRequestCategoryData( - payload.otherReasonId, - payload.startDate, - payload.endDate, - payload.carNumber - ), + apiUrls.getReimbursementRequestCategoryData(payload.otherReasonId, payload.startDate, payload.endDate), { + overrideCarId: payload.overrideCarId, transformResponse: (data) => reimbursementRequestDataTransformer(JSON.parse(data)) } ); }; export const getAllReimbursementRequestData = (payload: ReimbursementRequestDataPayload) => { - return axios.get( - apiUrls.getAllReimbursementRequestData(payload.startDate, payload.endDate, payload.carNumber), - { - transformResponse: (data) => JSON.parse(data).map(reimbursementRequestDataTransformer) - } - ); + return axios.get(apiUrls.getAllReimbursementRequestData(payload.startDate, payload.endDate), { + overrideCarId: payload.overrideCarId, + transformResponse: (data) => JSON.parse(data).map(reimbursementRequestDataTransformer) + }); }; export const getReimbursementRequestTeamTypeData = (payload: ReimbursementRequestTeamTypeDataPayload) => { return axios.get( - apiUrls.getReimbursementRequestTeamTypeData(payload.teamTypeId, payload.startDate, payload.endDate, payload.carNumber), + apiUrls.getReimbursementRequestTeamTypeData(payload.teamTypeId, payload.startDate, payload.endDate), { + overrideCarId: payload.overrideCarId, transformResponse: (data) => reimbursementRequestDataTransformer(JSON.parse(data)) } ); }; export const getSpendingBarTeamData = (payload: SpendingBarTeamDataPayload) => { - return axios.get( - apiUrls.getSpendingBarTeamData(payload.teamId, payload.startDate, payload.endDate, payload.carNumber), - { - transformResponse: (data) => spendingBarDataTransformer(JSON.parse(data)) - } - ); + return axios.get(apiUrls.getSpendingBarTeamData(payload.teamId, payload.startDate, payload.endDate), { + overrideCarId: payload.overrideCarId, + transformResponse: (data) => spendingBarDataTransformer(JSON.parse(data)) + }); }; export const getSpendingBarTeamTypeData = (payload: SpendingBarTeamTypeDataPayload) => { return axios.get( - apiUrls.getSpendingBarTeamTypeData(payload.teamTypeId, payload.startDate, payload.endDate, payload.carNumber), + apiUrls.getSpendingBarTeamTypeData(payload.teamTypeId, payload.startDate, payload.endDate), { + overrideCarId: payload.overrideCarId, transformResponse: (data) => JSON.parse(data).map(spendingBarDataTransformer) } ); }; export const getSpendingBarCategoryData = (payload: SpendingBarDataPayload) => { - return axios.get( - apiUrls.getSpendingBarCategoryData(payload.startDate, payload.endDate, payload.carNumber), - { - transformResponse: (data) => spendingBarDataTransformer(JSON.parse(data)) - } - ); + return axios.get(apiUrls.getSpendingBarCategoryData(payload.startDate, payload.endDate), { + overrideCarId: payload.overrideCarId, + transformResponse: (data) => spendingBarDataTransformer(JSON.parse(data)) + }); }; export const getAllSpendingBarData = (payload: SpendingBarDataPayload) => { - return axios.get(apiUrls.getAllSpendingBarData(payload.startDate, payload.endDate, payload.carNumber), { + return axios.get(apiUrls.getAllSpendingBarData(payload.startDate, payload.endDate), { + overrideCarId: payload.overrideCarId, transformResponse: (data) => JSON.parse(data).map(spendingBarDataTransformer) }); }; diff --git a/src/frontend/src/apis/projects.api.ts b/src/frontend/src/apis/projects.api.ts index e5d3ce62a4..aad9b5a31a 100644 --- a/src/frontend/src/apis/projects.api.ts +++ b/src/frontend/src/apis/projects.api.ts @@ -27,11 +27,14 @@ import { import { CreateSingleProjectPayload, EditSingleProjectPayload } from '../utils/types'; /** - * Fetches all projects with querry args needed for Gantt chart + * Fetches all projects with query args needed for Gantt chart + * Note: Gantt supports multi-car local selection and handles its own frontend filtering, + * so we bypass the global car filter using overrideCarId: 'all-cars' */ export const getAllProjectsGantt = () => { return axios.get(apiUrls.allProjectsGantt(), { - transformResponse: (data) => JSON.parse(data).map(projectGanttTransformer) + transformResponse: (data) => JSON.parse(data).map(projectGanttTransformer), + overrideCarId: 'all-cars' }); }; diff --git a/src/frontend/src/app/AppAuthenticated.tsx b/src/frontend/src/app/AppAuthenticated.tsx index 56bbcd32c3..c67546853c 100644 --- a/src/frontend/src/app/AppAuthenticated.tsx +++ b/src/frontend/src/app/AppAuthenticated.tsx @@ -24,6 +24,7 @@ import Finance from '../pages/FinancePage/Finance'; import ErrorPage from '../pages/ErrorPage'; import { Role, isGuest } from 'shared'; import { useCurrentOrganization } from '../hooks/organizations.hooks'; +import { GlobalCarFilterProvider } from './AppGlobalCarFilterContext'; import Statistics from '../pages/StatisticsPage/Statistics'; import RetrospectiveGanttChartPage from '../pages/RetrospectivePage/Retrospective'; import Calendar from '../pages/CalendarPage/Calendar'; @@ -58,32 +59,36 @@ const AppAuthenticated: React.FC = ({ userId, userRole }) return ; } - return userSettingsData.slackId || isGuest(userRole) ? ( - - - - - - - - - - - - - - - - - - - - - - - - ) : ( - + return ( + + {userSettingsData.slackId || isGuest(userRole) ? ( + + + + + + + + + + + + + + + + + + + + + + + + ) : ( + + )} + ); }; diff --git a/src/frontend/src/app/AppGlobalCarFilterContext.tsx b/src/frontend/src/app/AppGlobalCarFilterContext.tsx new file mode 100644 index 0000000000..802dadfb65 --- /dev/null +++ b/src/frontend/src/app/AppGlobalCarFilterContext.tsx @@ -0,0 +1,102 @@ +/* + * This file is part of NER's FinishLine and licensed under GNU AGPLv3. + * See the LICENSE file in the repository root folder for details. + */ + +import React, { createContext, useContext, useState, useEffect, useLayoutEffect, ReactNode } from 'react'; +import { Car } from 'shared'; +import { useGetAllCars } from '../hooks/cars.hooks'; +import { setCurrentCarId } from '../utils/axios'; +import LoadingIndicator from '../components/LoadingIndicator'; + +interface GlobalCarFilterContextType { + selectedCar: Car | 'all-cars'; + allCars: Car[]; + setSelectedCar: (car: Car | 'all-cars') => void; + isLoading: boolean; + isInitialized: boolean; + error: Error | null; +} + +const GlobalCarFilterContext = createContext(undefined); + +interface GlobalCarFilterProviderProps { + children: ReactNode; +} + +export const GlobalCarFilterProvider: React.FC = ({ children }) => { + const [selectedCar, setSelectedCarState] = useState('all-cars'); + const [isInitialized, setIsInitialized] = useState(false); + + const { data: allCars = [], isLoading, error } = useGetAllCars(); + + // Guarantees the header is updated before React Query enqueues new fetches. + useLayoutEffect(() => { + setCurrentCarId(selectedCar === 'all-cars' ? null : selectedCar.id); + }, [selectedCar]); + + useEffect(() => { + if (!isLoading && !isInitialized) { + const savedCarId = localStorage.getItem('selectedCarId'); + + // Handle saved selection + if (savedCarId === 'all-cars') { + setSelectedCarState('all-cars'); + setIsInitialized(true); + return; + } else if (savedCarId) { + const savedCar = allCars.find((car) => car.id === savedCarId); + if (savedCar) { + setSelectedCarState(savedCar); + setIsInitialized(true); + return; + } + // Fall back to default if saved car id is invalid + localStorage.removeItem('selectedCarId'); + } + + // Default to most recent car if no car was previously saved (highest car number) + const mostRecentCar = + allCars.length > 0 ? allCars.reduce((a, b) => (a.wbsNum.carNumber > b.wbsNum.carNumber ? a : b)) : null; + if (mostRecentCar) { + setSelectedCarState(mostRecentCar); + localStorage.setItem('selectedCarId', mostRecentCar.id); + } else { + setSelectedCarState('all-cars'); + } + setIsInitialized(true); + } + }, [allCars, isLoading, isInitialized]); + + const setSelectedCar = (car: Car | 'all-cars') => { + setSelectedCarState(car); + if (car !== 'all-cars') { + localStorage.setItem('selectedCarId', car.id); + } else { + localStorage.setItem('selectedCarId', 'all-cars'); + } + }; + + const value: GlobalCarFilterContextType = { + selectedCar, + allCars, + setSelectedCar, + isLoading, + isInitialized, + error + }; + + return ( + + {isInitialized ? children : } + + ); +}; + +export const useGlobalCarFilter = (): GlobalCarFilterContextType => { + const context = useContext(GlobalCarFilterContext); + if (context === undefined) { + throw new Error('useGlobalCarFilter must be used within a GlobalCarFilterProvider'); + } + return context; +}; diff --git a/src/frontend/src/components/DrawerHeader.tsx b/src/frontend/src/components/DrawerHeader.tsx index 01947a5311..dc6931b576 100644 --- a/src/frontend/src/components/DrawerHeader.tsx +++ b/src/frontend/src/components/DrawerHeader.tsx @@ -7,12 +7,8 @@ import { styled } from '@mui/material'; const DrawerHeader = styled('div')(({ theme }) => ({ display: 'flex', - alignItems: 'center', - justifyContent: 'flex-end', - padding: theme.spacing(0, 1), - height: '68px', - // necessary for content to be below app bar - ...theme.mixins.toolbar + alignItems: 'flex-start', + padding: theme.spacing(0, 1) })); export default DrawerHeader; diff --git a/src/frontend/src/components/FinanceDashboardCarFilter.tsx b/src/frontend/src/components/FinanceDashboardCarFilter.tsx new file mode 100644 index 0000000000..26bfe54ebe --- /dev/null +++ b/src/frontend/src/components/FinanceDashboardCarFilter.tsx @@ -0,0 +1,184 @@ +/* + * This file is part of NER's FinishLine and licensed under GNU AGPLv3. + * See the LICENSE file in the repository root folder for details. + */ + +import React from 'react'; +import { Box, Tooltip, Typography, FormControl, FormLabel } from '@mui/material'; +import { HelpOutline as HelpIcon } from '@mui/icons-material'; +import { DatePicker } from '@mui/x-date-pickers'; +import NERAutocomplete from './NERAutocomplete'; +import type { FinanceDashboardCarFilter as FinanceDashboardCarFilterType } from '../hooks/finance-car-filter.hooks'; + +interface FinanceDashboardCarFilterProps { + filter: FinanceDashboardCarFilterType; + sx?: object; + size?: 'small' | 'medium'; + controlSx?: object; +} + +const ALL_CARS_ID = '__ALL_CARS__'; +const ALL_CARS_OPTION = { label: 'All Cars', id: ALL_CARS_ID, carNumber: -1 }; + +const inputStyle = { + '.MuiInputBase-root': { + height: '36px', + padding: '0 8px', + backgroundColor: '#ef4345', + color: 'white', + fontSize: '13px', + borderRadius: '4px', + '&:hover': { backgroundColor: '#ef4345' }, + '&.Mui-focused': { backgroundColor: '#ef4345', color: 'white' } + }, + '& .MuiInputBase-input': { + color: 'white', + paddingTop: '8px', + cursor: 'pointer', + '&:focus': { color: 'white' } + }, + '& .MuiOutlinedInput-notchedOutline': { + border: '1px solid #fff', + '&:hover': { borderColor: '#fff' }, + '&.Mui-focused': { borderColor: '#fff' } + }, + '& .MuiSvgIcon-root': { + color: 'white', + '&:hover': { color: 'white' }, + '&.Mui-focused': { color: 'white' } + } +}; + +const labelStyle = { display: 'flex', alignItems: 'center', gap: 0.5, mb: 0.5, color: 'white' }; + +const FinanceDashboardCarFilterComponent: React.FC = ({ + filter, + sx = {}, + size = 'small', + controlSx = {} +}) => { + const { + selectedCar, + allCars, + startDate, + endDate, + setSelectedCar, + clearLocalSelection, + setStartDate, + setEndDate, + isLoading + } = filter; + + const sortedCars = [...allCars].sort((a, b) => b.wbsNum.carNumber - a.wbsNum.carNumber); + + const carOptions = sortedCars.map((car) => ({ + label: car.wbsNum.carNumber === 0 ? car.name : `${car.name} (Car ${car.wbsNum.carNumber})`, + id: car.id, + carNumber: car.wbsNum.carNumber + })); + + const carAutocompleteOptions = [ALL_CARS_OPTION, ...carOptions]; + + const handleCarChange = (_event: React.SyntheticEvent, newValue: { label: string; id: string } | null) => { + if (newValue === null) { + // User cleared the input (X button), re-mirror global + clearLocalSelection(); + } else if (newValue.id === ALL_CARS_ID) { + // Explicit "All Cars" override, bypass global filter entirely + setSelectedCar('all-cars'); + } else { + const car = allCars.find((c) => c.id === newValue.id); + if (car) setSelectedCar(car); + } + }; + + const selectedCarOption = + selectedCar === 'all-cars' ? ALL_CARS_OPTION : (carOptions.find((option) => option.id === selectedCar.id) ?? null); + + if (isLoading) { + return ( + + Loading car data... + + ); + } + + return ( + + + + Car Filter + + + + + + + + + + Start Date + + + + + (endDate ? date > endDate : false)} + slotProps={{ + textField: { size, sx: { width: 180, ...inputStyle, ...controlSx } }, + field: { clearable: true } + }} + onChange={(newValue: Date | null) => setStartDate(newValue ?? undefined)} + /> + + + + + End Date + + + + + (startDate ? date < startDate : false)} + slotProps={{ + textField: { size, sx: { width: 180, ...inputStyle, ...controlSx } }, + field: { clearable: true } + }} + onChange={(newValue: Date | null) => setEndDate(newValue ?? undefined)} + /> + + + ); +}; + +export default FinanceDashboardCarFilterComponent; diff --git a/src/frontend/src/components/GlobalCarFilterChips.tsx b/src/frontend/src/components/GlobalCarFilterChips.tsx new file mode 100644 index 0000000000..f70ece985f --- /dev/null +++ b/src/frontend/src/components/GlobalCarFilterChips.tsx @@ -0,0 +1,80 @@ +/* + * This file is part of NER's FinishLine and licensed under GNU AGPLv3. + * See the LICENSE file in the repository root folder for details. + */ + +import { Box, Chip, Typography } from '@mui/material'; +import { useGlobalCarFilter } from '../app/AppGlobalCarFilterContext'; + +interface GlobalCarFilterChipsProps { + sx?: object; +} + +const GlobalCarFilterChips: React.FC = ({ sx = {} }) => { + const { selectedCar, allCars, setSelectedCar, isLoading, error } = useGlobalCarFilter(); + + if (isLoading || error) return null; + + if (allCars.length === 0) { + return ( + + + No cars available + + + ); + } + + const sortedCars = [...allCars].sort((a, b) => b.wbsNum.carNumber - a.wbsNum.carNumber); + + return ( + + setSelectedCar('all-cars')} + variant="outlined" + sx={{ + borderColor: 'white', + color: 'white', + backgroundColor: 'transparent', + fontWeight: selectedCar === 'all-cars' ? 'bold' : 'normal', + borderWidth: selectedCar === 'all-cars' ? 2 : 1, + '&:hover': { backgroundColor: 'rgba(255,255,255,0.1)' }, + whiteSpace: 'nowrap' + }} + /> + {sortedCars.map((car) => { + const isSelected = selectedCar !== 'all-cars' && selectedCar.id === car.id; + return ( + setSelectedCar(car)} + variant="outlined" + sx={{ + borderColor: 'white', + color: 'white', + backgroundColor: 'transparent', + fontWeight: isSelected ? 'bold' : 'normal', + borderWidth: isSelected ? 2 : 1, + '&:hover': { backgroundColor: 'rgba(255,255,255,0.1)' }, + whiteSpace: 'nowrap' + }} + /> + ); + })} + + ); +}; + +export default GlobalCarFilterChips; diff --git a/src/frontend/src/components/GlobalCarFilterDropdown.tsx b/src/frontend/src/components/GlobalCarFilterDropdown.tsx new file mode 100644 index 0000000000..4a0c9e6aa8 --- /dev/null +++ b/src/frontend/src/components/GlobalCarFilterDropdown.tsx @@ -0,0 +1,23 @@ +/* + * This file is part of NER's FinishLine and licensed under GNU AGPLv3. + * See the LICENSE file in the repository root folder for details. + */ + +import { Box } from '@mui/material'; +import GlobalCarFilterHeader from './GlobalCarFilterHeader'; +import GlobalCarFilterChips from './GlobalCarFilterChips'; + +interface GlobalCarFilterDropdownProps { + sx?: object; +} + +const GlobalCarFilterDropdown: React.FC = ({ sx = {} }) => { + return ( + + + + + ); +}; + +export default GlobalCarFilterDropdown; diff --git a/src/frontend/src/components/GlobalCarFilterHeader.tsx b/src/frontend/src/components/GlobalCarFilterHeader.tsx new file mode 100644 index 0000000000..f85acadff1 --- /dev/null +++ b/src/frontend/src/components/GlobalCarFilterHeader.tsx @@ -0,0 +1,49 @@ +/* + * This file is part of NER's FinishLine and licensed under GNU AGPLv3. + * See the LICENSE file in the repository root folder for details. + */ + +import { Box, Typography } from '@mui/material'; +import { DirectionsCar as CarIcon } from '@mui/icons-material'; +import { useGlobalCarFilter } from '../app/AppGlobalCarFilterContext'; +import LoadingIndicator from './LoadingIndicator'; + +interface GlobalCarFilterHeaderProps { + sx?: object; +} + +const GlobalCarFilterHeader: React.FC = ({ sx = {} }) => { + const { selectedCar, allCars, isLoading, error } = useGlobalCarFilter(); + + if (isLoading) return ; + + if (error) { + return ( + + + {error.message} + + + ); + } + + if (allCars.length === 0) return null; + + const currentCarLabel = selectedCar === 'all-cars' ? 'All Cars' : selectedCar.name; + + return ( + + + + + Working with: + + + {currentCarLabel} + + + + ); +}; + +export default GlobalCarFilterHeader; diff --git a/src/frontend/src/hooks/change-requests.hooks.ts b/src/frontend/src/hooks/change-requests.hooks.ts index 0febdf9fc5..8f45ad0c7d 100644 --- a/src/frontend/src/hooks/change-requests.hooks.ts +++ b/src/frontend/src/hooks/change-requests.hooks.ts @@ -4,6 +4,7 @@ */ import { useMutation, useQuery, useQueryClient } from 'react-query'; +import { useGlobalCarFilter } from '../app/AppGlobalCarFilterContext'; import { ChangeRequest, ChangeRequestReason, @@ -37,10 +38,14 @@ import { * Custom React Hook to supply all change requests. */ export const useAllChangeRequests = () => { - return useQuery(['change requests'], async () => { - const { data } = await getAllChangeRequests(); - return data; - }); + const { selectedCar } = useGlobalCarFilter(); + return useQuery( + ['change requests', selectedCar === 'all-cars' ? 'all-cars' : selectedCar.id], + async () => { + const { data } = await getAllChangeRequests(); + return data; + } + ); }; export const useAllGuestChangeRequests = () => { @@ -51,24 +56,36 @@ export const useAllGuestChangeRequests = () => { }; export const useGetToReviewChangeRequests = () => { - return useQuery(['change requests', 'to-review'], async () => { - const { data } = await getToReviewChangeRequests(); - return data; - }); + const { selectedCar } = useGlobalCarFilter(); + return useQuery( + ['change requests', 'to-review', selectedCar === 'all-cars' ? 'all-cars' : selectedCar.id], + async () => { + const { data } = await getToReviewChangeRequests(); + return data; + } + ); }; export const useGetUnreviewedChangeRequests = (wbsNum?: WbsNumber) => { - return useQuery(['change requests', 'unreviewed'], async () => { - const { data } = await getUnreviewedChangeRequests(wbsNum); - return data; - }); + const { selectedCar } = useGlobalCarFilter(); + return useQuery( + ['change requests', 'unreviewed', selectedCar === 'all-cars' ? 'all-cars' : selectedCar.id, wbsNum], + async () => { + const { data } = await getUnreviewedChangeRequests(wbsNum); + return data; + } + ); }; export const useGetApprovedChangeRequests = (wbsNum?: WbsNumber) => { - return useQuery(['change requests', 'approved'], async () => { - const { data } = await getApprovedChangeRequests(wbsNum); - return data; - }); + const { selectedCar } = useGlobalCarFilter(); + return useQuery( + ['change requests', 'approved', selectedCar === 'all-cars' ? 'all-cars' : selectedCar.id, wbsNum], + async () => { + const { data } = await getApprovedChangeRequests(wbsNum); + return data; + } + ); }; /** diff --git a/src/frontend/src/hooks/finance-car-filter.hooks.ts b/src/frontend/src/hooks/finance-car-filter.hooks.ts new file mode 100644 index 0000000000..7cd034cedc --- /dev/null +++ b/src/frontend/src/hooks/finance-car-filter.hooks.ts @@ -0,0 +1,102 @@ +/* + * This file is part of NER's FinishLine and licensed under GNU AGPLv3. + * See the LICENSE file in the repository root folder for details. + */ + +import { useEffect, useState } from 'react'; +import { Car } from 'shared'; +import { useGlobalCarFilter } from '../app/AppGlobalCarFilterContext'; + +export interface FinanceDashboardCarFilter { + selectedCar: Car | 'all-cars'; + allCars: Car[]; + startDate: Date | undefined; + endDate: Date | undefined; + setSelectedCar: (car: Car | 'all-cars') => void; + clearLocalSelection: () => void; + setStartDate: (date: Date | undefined) => void; + setEndDate: (date: Date | undefined) => void; + isLoading: boolean; + error: Error | null; +} + +/** + * Hook for Finance Dashboard car filtering with automatic date population. + * Uses local state only; does not mutate the global car selection. + * + * selectedCar is the resolved car (local override if set, otherwise global) and can be used + * directly as overrideCarId using selectedCar === 'all-cars' ? 'all-cars' : selectedCar.id, + * keeping query keys reactive to both. + * + * When a specific car is selected, dates auto-populate: + * - Start date: When the car was initialized (car.dateCreated) + * - End date: Today (if current car) or start date of the next car (if previous car) + */ +export const useFinanceDashboardCarFilter = (initialStartDate?: Date, initialEndDate?: Date): FinanceDashboardCarFilter => { + const { selectedCar: globalSelectedCar, allCars, isLoading, error } = useGlobalCarFilter(); + + // undefined = not set (mirror global), 'all-cars' = explicitly set to "All Cars", Car = explicitly set to specific car + const [localSelectedCar, setLocalSelectedCar] = useState(undefined); + const [startDate, setStartDate] = useState(initialStartDate); + const [endDate, setEndDate] = useState(initialEndDate); + + const setSelectedCar = (car: Car | 'all-cars') => { + setLocalSelectedCar(car); + }; + + const clearLocalSelection = () => { + setLocalSelectedCar(undefined); + }; + + // Resolved car: local override if set, otherwise mirrors the global car. + const selectedCar = localSelectedCar !== undefined ? localSelectedCar : globalSelectedCar; + + // Auto-populate dates from the resolved car. + useEffect(() => { + if (selectedCar === 'all-cars') { + setStartDate(undefined); + setEndDate(undefined); + } else if (allCars.length > 0) { + setStartDate(new Date(selectedCar.dateCreated)); + const isCurrentCar = isCarCurrent(selectedCar, allCars); + if (isCurrentCar) { + setEndDate(new Date()); + } else { + const nextCar = findNextCar(selectedCar, allCars); + setEndDate(nextCar ? new Date(nextCar.dateCreated) : new Date()); + } + } + }, [selectedCar, allCars]); + + return { + selectedCar, + allCars, + startDate, + endDate, + setSelectedCar, + clearLocalSelection, + setStartDate, + setEndDate, + isLoading, + error + }; +}; + +/** + * Determines if the given car is the current/most recent car + */ +const isCarCurrent = (car: Car, allCars: Car[]): boolean => { + const maxCarNumber = Math.max(...allCars.map((c) => c.wbsNum.carNumber)); + return car.wbsNum.carNumber === maxCarNumber; +}; + +/** + * Finds the next car in chronological order (by car number) + */ +const findNextCar = (car: Car, allCars: Car[]): Car | null => { + const sortedCars = allCars + .filter((c) => c.wbsNum.carNumber > car.wbsNum.carNumber) + .sort((a, b) => a.wbsNum.carNumber - b.wbsNum.carNumber); + + return sortedCars[0] || null; +}; diff --git a/src/frontend/src/hooks/finance.hooks.ts b/src/frontend/src/hooks/finance.hooks.ts index 6c7baacc79..0a225f2061 100644 --- a/src/frontend/src/hooks/finance.hooks.ts +++ b/src/frontend/src/hooks/finance.hooks.ts @@ -102,6 +102,7 @@ import { ProspectiveSponsor } from 'shared'; import { fullNamePipe } from '../utils/pipes'; +import { useGlobalCarFilter } from '../app/AppGlobalCarFilterContext'; /** * Helper function to handle file upload errors with file name context @@ -326,47 +327,47 @@ export interface ReimbursementRequestTeamDataPayload { teamId: string; startDate?: Date; endDate?: Date; - carNumber?: number; + overrideCarId?: string | 'all-cars'; } export interface ReimbursementRequestDataPayload { startDate?: Date; endDate?: Date; - carNumber?: number; + overrideCarId?: string | 'all-cars'; } export interface ReimbursementRequestCategoryDataPayload { otherReasonId: string; startDate?: Date; endDate?: Date; - carNumber?: number; + overrideCarId?: string | 'all-cars'; } export interface ReimbursementRequestTeamTypeDataPayload { teamTypeId: string; startDate?: Date; endDate?: Date; - carNumber?: number; + overrideCarId?: string | 'all-cars'; } export interface SpendingBarTeamDataPayload { teamId: string; startDate?: Date; endDate?: Date; - carNumber?: number; + overrideCarId?: string | 'all-cars'; } export interface SpendingBarTeamTypeDataPayload { teamTypeId: string; startDate?: Date; endDate?: Date; - carNumber?: number; + overrideCarId?: string | 'all-cars'; } export interface SpendingBarDataPayload { startDate?: Date; endDate?: Date; - carNumber?: number; + overrideCarId?: string | 'all-cars'; } /** @@ -465,30 +466,42 @@ export const useGetAllAccountCodes = () => { * Custom React Hook to get the reimbursement requests created by the current user */ export const useCurrentUserReimbursementRequests = () => { - return useQuery(['reimbursement-requests', 'user'], async () => { - const { data } = await getCurrentUserReimbursementRequests(); - return data; - }); + const { selectedCar } = useGlobalCarFilter(); + return useQuery( + ['reimbursement-requests', 'user', selectedCar === 'all-cars' ? 'all-cars' : selectedCar.id], + async () => { + const { data } = await getCurrentUserReimbursementRequests(); + return data; + } + ); }; /** * Custom React Hook to get the reimbursement requests assigned to the current user */ export const useCurrentUserAssignedReimbursementRequests = () => { - return useQuery(['reimbursement-requests', 'assignee'], async () => { - const { data } = await getCurrentUserAssignedReimbursementRequests(); - return data; - }); + const { selectedCar } = useGlobalCarFilter(); + return useQuery( + ['reimbursement-requests', 'assignee', selectedCar === 'all-cars' ? 'all-cars' : selectedCar.id], + async () => { + const { data } = await getCurrentUserAssignedReimbursementRequests(); + return data; + } + ); }; /** * Custom React Hook to get the reimbursement requests for the current user's teams */ export const useCurrentUsersTeamsReimbursementRequests = () => { - return useQuery(['reimbursement-requests', 'user'], async () => { - const { data } = await getCurrentUsersTeamsReimbursementRequests(); - return data; - }); + const { selectedCar } = useGlobalCarFilter(); + return useQuery( + ['reimbursement-requests', 'teams', selectedCar === 'all-cars' ? 'all-cars' : selectedCar.id], + async () => { + const { data } = await getCurrentUsersTeamsReimbursementRequests(); + return data; + } + ); }; /** @@ -537,10 +550,14 @@ export const useSetTaxExemptStatus = () => { * Custom React Hook to get all the reimbursement requests */ export const useAllReimbursementRequests = () => { - return useQuery(['reimbursement-requests'], async () => { - const { data } = await getAllReimbursementRequests(); - return data; - }); + const { selectedCar } = useGlobalCarFilter(); + return useQuery( + ['reimbursement-requests', selectedCar === 'all-cars' ? 'all-cars' : selectedCar.id], + async () => { + const { data } = await getAllReimbursementRequests(); + return data; + } + ); }; /** @@ -782,10 +799,14 @@ export const useDownloadCSVFileOfReimbursementRequests = () => { * @returns the list of Reimbursement Reqeusts that are pending Advisor Approval */ export const useGetPendingAdvisorList = () => { - return useQuery(['reimbursement-requests', 'pending-advisors'], async () => { - const { data } = await getPendingAdvisorList(); - return data; - }); + const { selectedCar } = useGlobalCarFilter(); + return useQuery( + ['reimbursement-requests', 'pending-advisors', selectedCar === 'all-cars' ? 'all-cars' : selectedCar.id], + async () => { + const { data } = await getPendingAdvisorList(); + return data; + } + ); }; /** @@ -1160,7 +1181,7 @@ export const useGetReimbursementRequestTeamData = (reimbursementRequestData: Rei 'reimbursement-request-team-data', reimbursementRequestData.endDate, reimbursementRequestData.startDate, - reimbursementRequestData.carNumber, + reimbursementRequestData.overrideCarId, reimbursementRequestData.teamId ], async () => { @@ -1175,7 +1196,7 @@ export const useGetReimbursementRequestTeamTypeData = (reimbursementRequestData: 'reimbursement-request-team-type-data', reimbursementRequestData.endDate, reimbursementRequestData.startDate, - reimbursementRequestData.carNumber, + reimbursementRequestData.overrideCarId, reimbursementRequestData.teamTypeId ], async () => { @@ -1204,7 +1225,7 @@ export const useGetReimbursementRequestCategoryData = (reimbursementRequestData: 'reimbursement-request-category-data', reimbursementRequestData.endDate, reimbursementRequestData.startDate, - reimbursementRequestData.carNumber, + reimbursementRequestData.overrideCarId, reimbursementRequestData.otherReasonId ], async () => { @@ -1219,7 +1240,7 @@ export const useGetAllReimbursementRequestData = (reimbursementRequestData: Reim 'reimbursement-request-data', reimbursementRequestData.endDate, reimbursementRequestData.startDate, - reimbursementRequestData.carNumber + reimbursementRequestData.overrideCarId ], async () => { const { data } = await getAllReimbursementRequestData(reimbursementRequestData); @@ -1233,7 +1254,7 @@ export const useGetSpendingBarTeamData = (spendingBarData: SpendingBarTeamDataPa 'spending-bar-team-data', spendingBarData.endDate, spendingBarData.startDate, - spendingBarData.carNumber, + spendingBarData.overrideCarId, spendingBarData.teamId ], async () => { @@ -1248,7 +1269,7 @@ export const useGetSpendingBarTeamTypeData = (spendingBarData: SpendingBarTeamTy 'spending-bar-team-type-data', spendingBarData.endDate, spendingBarData.startDate, - spendingBarData.carNumber, + spendingBarData.overrideCarId, spendingBarData.teamTypeId ], async () => { @@ -1259,7 +1280,7 @@ export const useGetSpendingBarTeamTypeData = (spendingBarData: SpendingBarTeamTy export const useGetSpendingBarCategoryData = (spendingBarData: SpendingBarDataPayload) => useQuery( - ['spending-bar-category-data', spendingBarData.endDate, spendingBarData.startDate, spendingBarData.carNumber], + ['spending-bar-category-data', spendingBarData.endDate, spendingBarData.startDate, spendingBarData.overrideCarId], async () => { const { data } = await getSpendingBarCategoryData(spendingBarData); return data; @@ -1268,7 +1289,7 @@ export const useGetSpendingBarCategoryData = (spendingBarData: SpendingBarDataPa export const useGetAllSpendingBarData = (spendingBarData: SpendingBarDataPayload) => useQuery( - ['spending-bar-data', spendingBarData.endDate, spendingBarData.startDate, spendingBarData.carNumber], + ['spending-bar-data', spendingBarData.endDate, spendingBarData.startDate, spendingBarData.overrideCarId], async () => { const { data } = await getAllSpendingBarData(spendingBarData); return data; diff --git a/src/frontend/src/hooks/projects.hooks.ts b/src/frontend/src/hooks/projects.hooks.ts index a95e17313c..baccb799cb 100644 --- a/src/frontend/src/hooks/projects.hooks.ts +++ b/src/frontend/src/hooks/projects.hooks.ts @@ -39,12 +39,13 @@ import { } from '../apis/projects.api'; import { CreateSingleProjectPayload, EditSingleProjectPayload } from '../utils/types'; import { useCurrentUser } from './users.hooks'; +import { useGlobalCarFilter } from '../app/AppGlobalCarFilterContext'; /** * Custom React Hook to supply all projects with Gantt querry args */ export const useAllProjectsGantt = () => { - return useQuery(['projects'], async () => { + return useQuery(['projects', 'gantt-all'], async () => { const { data } = await getAllProjectsGantt(); return data; }); @@ -54,40 +55,56 @@ export const useAllProjectsGantt = () => { * Custom React Hook to supply all projects */ export const useAllProjects = () => { - return useQuery(['projects', 'previews'], async () => { - const { data } = await getAllProjects(); - return data; - }); + const { selectedCar } = useGlobalCarFilter(); + return useQuery( + ['projects', 'previews', selectedCar === 'all-cars' ? 'all-cars' : selectedCar.id], + async () => { + const { data } = await getAllProjects(); + return data; + } + ); }; /** * Custom React Hook to supply all of the projects that are on the users teams */ export const useGetUsersTeamsProjects = () => { - return useQuery(['projects', 'teams'], async () => { - const { data } = await getUsersTeamsProjects(); - return data; - }); + const { selectedCar } = useGlobalCarFilter(); + return useQuery( + ['projects', 'teams', selectedCar === 'all-cars' ? 'all-cars' : selectedCar.id], + async () => { + const { data } = await getUsersTeamsProjects(); + return data; + } + ); }; /** * Custom React Hook to supply all of the projects that the user is the manager or lead of */ export const useGetUsersLeadingProjects = () => { - return useQuery(['projects', 'leading'], async () => { - const { data } = await getUsersLeadingProjects(); - return data; - }); + const { selectedCar } = useGlobalCarFilter(); + return useQuery( + ['projects', 'leading', selectedCar === 'all-cars' ? 'all-cars' : selectedCar.id], + async () => { + const { data } = await getUsersLeadingProjects(); + return data; + } + ); }; /** * Custom React Hook to supply all of the projects for a given team */ export const useGetTeamsProjects = (teamId: string) => { - return useQuery(['projects', 'teams'], async () => { - const { data } = await getTeamsProjects(teamId); - return data; - }); + const { selectedCar } = useGlobalCarFilter(); + return useQuery( + ['projects', 'teams', teamId, selectedCar === 'all-cars' ? 'all-cars' : selectedCar.id], + async () => { + const { data } = await getTeamsProjects(teamId); + return data; + } + ); }; /** diff --git a/src/frontend/src/hooks/users.hooks.ts b/src/frontend/src/hooks/users.hooks.ts index a7b18e9a4f..c890c23671 100644 --- a/src/frontend/src/hooks/users.hooks.ts +++ b/src/frontend/src/hooks/users.hooks.ts @@ -42,6 +42,7 @@ import { import { useAuth } from './auth.hooks'; import { useContext } from 'react'; import { UserContext } from '../app/AppContextUser'; +import { useGlobalCarFilter } from '../app/AppGlobalCarFilterContext'; /** * Custom React Hook to supply the current user @@ -188,10 +189,14 @@ export const useUserScheduleSettings = (id: string) => { * @param id User ID of the requested user's settings. */ export const useUsersFavoriteProjects = (id: string) => { - return useQuery(['users', id, 'favorite projects'], async () => { - const { data } = await getUsersFavoriteProjects(id); - return data; - }); + const { selectedCar } = useGlobalCarFilter(); + return useQuery( + ['users', id, 'favorite projects', selectedCar === 'all-cars' ? 'all-cars' : selectedCar.id], + async () => { + const { data } = await getUsersFavoriteProjects(id); + return data; + } + ); }; /** diff --git a/src/frontend/src/hooks/work-packages.hooks.ts b/src/frontend/src/hooks/work-packages.hooks.ts index acf0fa7d92..4c34ebc0b2 100644 --- a/src/frontend/src/hooks/work-packages.hooks.ts +++ b/src/frontend/src/hooks/work-packages.hooks.ts @@ -19,25 +19,34 @@ import { WorkPackageEditArgs, getHomePageWorkPackages } from '../apis/work-packages.api'; +import { useGlobalCarFilter } from '../app/AppGlobalCarFilterContext'; /** * Custom React Hook to supply all work packages. */ export const useAllWorkPackages = (queryParams?: { [field: string]: string }) => { - return useQuery(['work packages', queryParams], async () => { - const { data } = await getAllWorkPackages(queryParams); - return data; - }); + const { selectedCar } = useGlobalCarFilter(); + return useQuery( + ['work packages', queryParams, selectedCar === 'all-cars' ? 'all-cars' : selectedCar.id], + async () => { + const { data } = await getAllWorkPackages(queryParams); + return data; + } + ); }; /** * Custom React Hook to supply all work packages in preview format (minimal data). */ export const useAllWorkPackagesPreview = (status?: string) => { - return useQuery(['work packages', 'preview', status], async () => { - const { data } = await getAllWorkPackagesPreview(status); - return data; - }); + const { selectedCar } = useGlobalCarFilter(); + return useQuery( + ['work packages', 'preview', status, selectedCar === 'all-cars' ? 'all-cars' : selectedCar.id], + async () => { + const { data } = await getAllWorkPackagesPreview(status); + return data; + } + ); }; /** @@ -128,8 +137,12 @@ export const useGetBlockingWorkPackages = (wbsNum: WbsNumber) => { * Custom React Hook to get many work packages */ export const useGetManyWorkPackages = (wbsNums: WbsNumber[]) => { - return useQuery(['work packages', 'blocking', wbsNums], async () => { - const { data } = await getManyWorkPackages(wbsNums); + const { selectedCar } = useGlobalCarFilter(); + const filteredWbsNums = + selectedCar === 'all-cars' ? wbsNums : wbsNums.filter((wbsNum) => wbsNum.carNumber === selectedCar.wbsNum.carNumber); + const carKey = selectedCar === 'all-cars' ? 'all-cars' : selectedCar.id; + return useQuery(['work packages', 'many', filteredWbsNums, carKey], async () => { + const { data } = await getManyWorkPackages(filteredWbsNums); return data; }); }; @@ -145,8 +158,12 @@ export const useSlackUpcomingDeadlines = () => { }; export const useHomeScreenWorkPackages = (selection: WorkPackageSelection) => { - return useQuery(['teams', 'work-packages', selection], async () => { - const { data } = await getHomePageWorkPackages(selection); - return data; - }); + const { selectedCar } = useGlobalCarFilter(); + return useQuery( + ['teams', 'work-packages', selection, selectedCar === 'all-cars' ? 'all-cars' : selectedCar.id], + async () => { + const { data } = await getHomePageWorkPackages(selection); + return data; + } + ); }; diff --git a/src/frontend/src/layouts/Sidebar/NavPageLink.tsx b/src/frontend/src/layouts/Sidebar/NavPageLink.tsx index 9fe391bb53..6dcd5cbe8e 100644 --- a/src/frontend/src/layouts/Sidebar/NavPageLink.tsx +++ b/src/frontend/src/layouts/Sidebar/NavPageLink.tsx @@ -94,7 +94,7 @@ const NavPageLink: React.FC = ({ {subItems && ( {subItems.map((subItem) => ( - + ))} )} diff --git a/src/frontend/src/layouts/Sidebar/Sidebar.tsx b/src/frontend/src/layouts/Sidebar/Sidebar.tsx index 1ccc3c286b..e953360a33 100644 --- a/src/frontend/src/layouts/Sidebar/Sidebar.tsx +++ b/src/frontend/src/layouts/Sidebar/Sidebar.tsx @@ -37,6 +37,8 @@ import QueryStatsIcon from '@mui/icons-material/QueryStats'; import CurrencyExchangeIcon from '@mui/icons-material/CurrencyExchange'; import ShoppingCartIcon from '@mui/icons-material/ShoppingCart'; import { useState } from 'react'; +import GlobalCarFilterHeader from '../../components/GlobalCarFilterHeader'; +import GlobalCarFilterChips from '../../components/GlobalCarFilterChips'; import { CalendarIcon } from '@mui/x-date-pickers'; interface SidebarProps { @@ -218,7 +220,15 @@ const Sidebar = ({ drawerOpen, setDrawerOpen, moveContent, setMoveContent }: Sid }} > - handleMoveContent()}>{moveContent ? : } + + + + handleMoveContent()} sx={{ p: 0.5 }}> + {moveContent ? : } + + + + {linkItems.map((linkItem) => ( handleOpenSubmenu(linkItem.name)} onSubmenuCollapse={() => handleCloseSubmenu()} /> ))} + diff --git a/src/frontend/src/pages/CalendarPage/Components/EventModal.tsx b/src/frontend/src/pages/CalendarPage/Components/EventModal.tsx index f68ecbbec3..18b2ceaece 100644 --- a/src/frontend/src/pages/CalendarPage/Components/EventModal.tsx +++ b/src/frontend/src/pages/CalendarPage/Components/EventModal.tsx @@ -31,7 +31,7 @@ import { getDay } from 'shared'; import { useToast } from '../../../hooks/toasts.hooks'; -import { useAllUsers, useCurrentUser } from '../../../hooks/users.hooks'; +import { useAllMembers, useCurrentUser } from '../../../hooks/users.hooks'; import { useAllWorkPackagesPreview } from '../../../hooks/work-packages.hooks'; import { useAllTeamPreviews } from '../../../hooks/teams.hooks'; import { userToAutocompleteOption } from '../../../utils/teams.utils'; @@ -252,7 +252,7 @@ const EventModal: React.FC = ({ ); // Lazy load all data needed for the form so users can start filling out instantly - const { isLoading: usersLoading, isError: usersError, error: usersErrorMsg, data: users } = useAllUsers(); + const { isLoading: usersLoading, isError: usersError, error: usersErrorMsg, data: users } = useAllMembers(); const { isLoading: shopsLoading, isError: shopsError, error: shopsErrorMsg, data: shops } = useAllShops(); const { isError: machineryError, error: machineryErrorMsg, data: machinery } = useAllMachines(); const { diff --git a/src/frontend/src/pages/CalendarPage/FilterModal.tsx b/src/frontend/src/pages/CalendarPage/FilterModal.tsx index 0b6e88b474..849d744ca4 100644 --- a/src/frontend/src/pages/CalendarPage/FilterModal.tsx +++ b/src/frontend/src/pages/CalendarPage/FilterModal.tsx @@ -2,7 +2,7 @@ import React, { useState } from 'react'; import { Autocomplete, Box, Button, Checkbox, TextField, Typography } from '@mui/material'; import NERModal from '../../components/NERModal'; import PeopleIcon from '@mui/icons-material/People'; -import { useAllUsers, useCurrentUser } from '../../hooks/users.hooks'; +import { useAllMembers, useCurrentUser } from '../../hooks/users.hooks'; import { useAllTeams } from '../../hooks/teams.hooks'; import ErrorPage from '../ErrorPage'; @@ -39,7 +39,7 @@ const FilterModal: React.FC = ({ const MemberDropdown = () => { const memberIds = filterValues?.memberIds ?? []; - const { data: allUsers } = useAllUsers(); + const { data: allUsers } = useAllMembers(); return ( diff --git a/src/frontend/src/pages/ChangeRequestsPage/ChangeRequestsView.tsx b/src/frontend/src/pages/ChangeRequestsPage/ChangeRequestsView.tsx index 6105a215bf..90b688626e 100644 --- a/src/frontend/src/pages/ChangeRequestsPage/ChangeRequestsView.tsx +++ b/src/frontend/src/pages/ChangeRequestsPage/ChangeRequestsView.tsx @@ -5,6 +5,7 @@ import { routes } from '../../utils/routes'; import { isGuest } from 'shared'; import { Add } from '@mui/icons-material'; import { useCurrentUser } from '../../hooks/users.hooks'; +import { useGlobalCarFilter } from '../../app/AppGlobalCarFilterContext'; import ChangeRequestsOverview from './ChangeRequestsOverview'; import ChangeRequestsTable from './ChangeRequestsTable'; import PageLayout from '../../components/PageLayout'; @@ -14,6 +15,7 @@ import GuestChangeRequestsPage from './GuestChangeRequestsPage'; const ChangeRequestsView: React.FC = () => { const history = useHistory(); const user = useCurrentUser(); + const { selectedCar } = useGlobalCarFilter(); // Default to the "overview" tab const [tabIndex, setTabIndex] = useState(0); @@ -34,7 +36,9 @@ const ChangeRequestsView: React.FC = () => { return ( = ({ startDate, endDate, carNumber }) => { +const AdminFinanceDashboard: React.FC = ({ startDate, endDate }) => { const user = useCurrentUser(); const [anchorEl, setAnchorEl] = useState(null); const [tabIndex, setTabIndex] = useState(0); const [showPendingAdvisorListModal, setShowPendingAdvisorListModal] = useState(false); const [showTotalAmountSpent, setShowTotalAmountSpent] = useState(false); - const [startDateState, setStartDateState] = useState(startDate); - const [endDateState, setEndDateState] = useState(endDate); - const [carNumberState, setCarNumberState] = useState(carNumber); + + const filter = useFinanceDashboardCarFilter(startDate, endDate); const { data: allTeamTypes, @@ -59,16 +58,8 @@ const AdminFinanceDashboard: React.FC = ({ startDate error: allPendingAdvisorListError } = useGetPendingAdvisorList(); - const { data: allCars, isLoading: allCarsIsLoading, isError: allCarsIsError, error: allCarsError } = useGetAllCars(); - - useEffect(() => { - if (carNumberState === undefined && allCars && allCars.length > 0) { - setCarNumberState(allCars[allCars.length - 1].wbsNum.carNumber); - } - }, [allCars, carNumberState]); - - if (allCarsIsError) { - return ; + if (filter.error) { + return ; } if (allTeamTypesIsError) { @@ -90,16 +81,19 @@ const AdminFinanceDashboard: React.FC = ({ startDate allReimbursementRequestsIsLoading || !allPendingAdvisorList || allPendingAdvisorListIsLoading || - !allCars || - allCarsIsLoading + filter.isLoading ) { return ; } - const carAutocompleteOptions = allCars.map((car) => ({ - label: car.name, - id: car.wbsNum.carNumber.toString() + const ALL_CARS_ID = '__ALL_CARS__'; + const { selectedCar, allCars } = filter; + const sortedCars = [...allCars].sort((a, b) => b.wbsNum.carNumber - a.wbsNum.carNumber); + const carOptions = sortedCars.map((car) => ({ + label: car.wbsNum.carNumber === 0 ? car.name : `${car.name} (Car ${car.wbsNum.carNumber})`, + id: car.id })); + const carAutocompleteOptions = [{ label: 'All Cars', id: ALL_CARS_ID }, ...carOptions]; const tabs = []; @@ -209,56 +203,94 @@ const AdminFinanceDashboard: React.FC = ({ startDate ml: 'auto' }} > - setCarNumberState(newValue ? Number(newValue.id) : undefined)} - options={carAutocompleteOptions} - size="small" - placeholder="Select A Car" - value={ - carNumberState !== undefined ? carAutocompleteOptions.find((car) => car.id === carNumberState.toString()) : null - } - sx={datePickerStyle} - /> - (endDateState ? date > endDateState : false)} - slotProps={{ - textField: { - size: 'small', - sx: datePickerStyle - }, - field: { clearable: true } - }} - onChange={(newValue: Date | null) => setStartDateState(newValue ?? undefined)} - /> + + { + if (newValue === null) { + // Cleared (X button) — re-mirror global + filter.clearLocalSelection(); + } else if (newValue.id === ALL_CARS_ID) { + // Explicit "All Cars" override + filter.setSelectedCar('all-cars'); + } else { + const car = allCars.find((c) => c.id === newValue.id); + if (car) filter.setSelectedCar(car); + } + }} + options={carAutocompleteOptions} + size="small" + placeholder="Select A Car" + value={ + selectedCar === 'all-cars' + ? { label: 'All Cars', id: ALL_CARS_ID } + : (carOptions.find((car) => car.id === selectedCar.id) ?? null) + } + sx={datePickerStyle} + /> + + + + + + (filter.endDate ? date > filter.endDate : false)} + slotProps={{ + textField: { + size: 'small', + sx: datePickerStyle + }, + field: { clearable: true } + }} + onChange={(newValue: Date | null) => filter.setStartDate(newValue ?? undefined)} + /> + + + + - - (startDateState ? date < startDateState : false)} - slotProps={{ - textField: { - size: 'small', - sx: datePickerStyle - }, - field: { clearable: true } - }} - onChange={(newValue: Date | null) => setEndDateState(newValue ?? undefined)} - /> + + (filter.startDate ? date < filter.startDate : false)} + slotProps={{ + textField: { + size: 'small', + sx: datePickerStyle + }, + field: { clearable: true } + }} + onChange={(newValue: Date | null) => filter.setEndDate(newValue ?? undefined)} + /> + + + + } variant="contained" id="project-actions-dropdown" onClick={handleClick} + sx={{ flexShrink: 0 }} > Actions @@ -268,14 +300,14 @@ const AdminFinanceDashboard: React.FC = ({ startDate handleDropdownClose(); setShowPendingAdvisorListModal(true); }} - disabled={!isFinance && !isAdmin} + disabled={!isFinance && !isAdmin(user.role)} > Pending Advisor List - setShowTotalAmountSpent(true)} disabled={!isFinance && !isAdmin}> + setShowTotalAmountSpent(true)} disabled={!isFinance && !isAdmin(user.role)}> @@ -319,16 +351,24 @@ const AdminFinanceDashboard: React.FC = ({ startDate /> )} {tabIndex === 0 ? ( - + ) : tabIndex === tabs.length - 1 ? ( - + ) : ( selectedTab && ( ) )} diff --git a/src/frontend/src/pages/FinancePage/FinanceDashboard/FinanceDashboardAllView.tsx b/src/frontend/src/pages/FinancePage/FinanceDashboard/FinanceDashboardAllView.tsx index 03ffa2de61..845f43a60d 100644 --- a/src/frontend/src/pages/FinancePage/FinanceDashboard/FinanceDashboardAllView.tsx +++ b/src/frontend/src/pages/FinancePage/FinanceDashboard/FinanceDashboardAllView.tsx @@ -9,11 +9,11 @@ import AdminBalance from './AdminBalance'; interface FinanceDashboardAllViewProps { startDate?: Date; endDate?: Date; - carNumber?: number; + overrideCarId?: string | 'all-cars'; } -const FinanceDashboardAllView: React.FC = ({ startDate, endDate, carNumber }) => { - const payload = { startDate, endDate, carNumber }; +const FinanceDashboardAllView: React.FC = ({ startDate, endDate, overrideCarId }) => { + const payload = { startDate, endDate, overrideCarId }; // this hook returns the all data then budget data then cash data const { data: allRRData, diff --git a/src/frontend/src/pages/FinancePage/FinanceDashboard/FinanceDashboardCategoriesView.tsx b/src/frontend/src/pages/FinancePage/FinanceDashboard/FinanceDashboardCategoriesView.tsx index 8b76dc0d87..102ba8dcf9 100644 --- a/src/frontend/src/pages/FinancePage/FinanceDashboard/FinanceDashboardCategoriesView.tsx +++ b/src/frontend/src/pages/FinancePage/FinanceDashboard/FinanceDashboardCategoriesView.tsx @@ -8,24 +8,28 @@ import AdminBalance from './AdminBalance'; interface FinanceDashboardCategoryViewProps { startDate?: Date; endDate?: Date; - carNumber?: number; + overrideCarId?: string | 'all-cars'; } -const FinanceDashboardCategoriesView: React.FC = ({ startDate, endDate, carNumber }) => { +const FinanceDashboardCategoriesView: React.FC = ({ + startDate, + endDate, + overrideCarId +}) => { // this hook returns the all data then budget data then cash data const { data: rrData, isLoading: rrDataIsLoading, isError: rrDataIsError, error: rrDataError - } = useGetAllReimbursementRequestData({ startDate, endDate, carNumber }); + } = useGetAllReimbursementRequestData({ startDate, endDate, overrideCarId }); const { data: spendingBarData, isLoading: spendingBarDataIsLoading, isError: spendingBarDataIsError, error: spendingBarDataError - } = useGetSpendingBarCategoryData({ startDate, endDate, carNumber }); + } = useGetSpendingBarCategoryData({ startDate, endDate, overrideCarId }); if (rrDataIsError) { return ; diff --git a/src/frontend/src/pages/FinancePage/FinanceDashboard/FinanceDashboardTeamTypeView.tsx b/src/frontend/src/pages/FinancePage/FinanceDashboard/FinanceDashboardTeamTypeView.tsx index 2c4003aa22..53fa376062 100644 --- a/src/frontend/src/pages/FinancePage/FinanceDashboard/FinanceDashboardTeamTypeView.tsx +++ b/src/frontend/src/pages/FinancePage/FinanceDashboard/FinanceDashboardTeamTypeView.tsx @@ -9,27 +9,27 @@ interface FinanceDashboardTeamTypeViewProps { teamTypeId: string; startDate?: Date; endDate?: Date; - carNumber?: number; + overrideCarId?: string | 'all-cars'; } const FinanceDashboardTeamView: React.FC = ({ teamTypeId, startDate, endDate, - carNumber + overrideCarId }) => { const { data: rrData, isLoading: rrDataIsLoading, isError: rrDataIsError, error: rrDataError - } = useGetReimbursementRequestTeamTypeData({ teamTypeId, startDate, endDate, carNumber }); + } = useGetReimbursementRequestTeamTypeData({ teamTypeId, startDate, endDate, overrideCarId }); const { data: spendingBarData, isLoading: spendingBarDataIsLoading, isError: spendingBarDataIsError, error: spendingBarDataError - } = useGetSpendingBarTeamTypeData({ teamTypeId, startDate, endDate, carNumber }); + } = useGetSpendingBarTeamTypeData({ teamTypeId, startDate, endDate, overrideCarId }); if (rrDataIsError) { return ; diff --git a/src/frontend/src/pages/FinancePage/FinanceDashboard/FinanceDashboardTeamView.tsx b/src/frontend/src/pages/FinancePage/FinanceDashboard/FinanceDashboardTeamView.tsx index da41535866..4e8c302315 100644 --- a/src/frontend/src/pages/FinancePage/FinanceDashboard/FinanceDashboardTeamView.tsx +++ b/src/frontend/src/pages/FinancePage/FinanceDashboard/FinanceDashboardTeamView.tsx @@ -9,23 +9,28 @@ interface FinanceDashboardTeamViewProps { teamId: string; startDate?: Date; endDate?: Date; - carNumber?: number; + overrideCarId?: string | 'all-cars'; } -const FinanceDashboardTeamView: React.FC = ({ teamId, startDate, endDate, carNumber }) => { +const FinanceDashboardTeamView: React.FC = ({ + teamId, + startDate, + endDate, + overrideCarId +}) => { const { data: rrData, isLoading: rrDataIsLoading, isError: rrDataIsError, error: rrDataError - } = useGetReimbursementRequestTeamData({ teamId, startDate, endDate, carNumber }); + } = useGetReimbursementRequestTeamData({ teamId, startDate, endDate, overrideCarId }); const { data: spendingBarData, isLoading: spendingBarDataIsLoading, isError: spendingBarDataIsError, error: spendingBarDataError - } = useGetSpendingBarTeamData({ teamId, startDate, endDate, carNumber }); + } = useGetSpendingBarTeamData({ teamId, startDate, endDate, overrideCarId }); if (rrDataIsError) { return ; diff --git a/src/frontend/src/pages/FinancePage/FinanceDashboard/GeneralFinanceDashboard.tsx b/src/frontend/src/pages/FinancePage/FinanceDashboard/GeneralFinanceDashboard.tsx index feb70ea017..cb67562351 100644 --- a/src/frontend/src/pages/FinancePage/FinanceDashboard/GeneralFinanceDashboard.tsx +++ b/src/frontend/src/pages/FinancePage/FinanceDashboard/GeneralFinanceDashboard.tsx @@ -5,23 +5,20 @@ import PageLayout from '../../../components/PageLayout'; import { Box } from '@mui/system'; import FullPageTabs from '../../../components/FullPageTabs'; import { routes } from '../../../utils/routes'; -import { DatePicker } from '@mui/x-date-pickers'; import { useGetUsersTeams } from '../../../hooks/teams.hooks'; import FinanceDashboardTeamView from './FinanceDashboardTeamView'; -import { useGetAllCars } from '../../../hooks/cars.hooks'; -import NERAutocomplete from '../../../components/NERAutocomplete'; +import FinanceDashboardCarFilter from '../../../components/FinanceDashboardCarFilter'; +import { useFinanceDashboardCarFilter } from '../../../hooks/finance-car-filter.hooks'; interface GeneralFinanceDashboardProps { startDate?: Date; endDate?: Date; - carNumber?: number; } -const GeneralFinanceDashboard: React.FC = ({ startDate, endDate, carNumber }) => { +const GeneralFinanceDashboard: React.FC = ({ startDate, endDate }) => { const [tabIndex, setTabIndex] = useState(0); - const [startDateState, setStartDateState] = useState(startDate); - const [endDateState, setEndDateState] = useState(endDate); - const [carNumberState, setCarNumberState] = useState(carNumber); + + const filter = useFinanceDashboardCarFilter(startDate, endDate); const { data: allTeams, @@ -30,159 +27,34 @@ const GeneralFinanceDashboard: React.FC = ({ start error: allTeamsError } = useGetUsersTeams(); - const { data: allCars, isLoading: allCarsIsLoading, isError: allCarsIsError, error: allCarsError } = useGetAllCars(); - - if (allCarsIsError) { - return ; - } - if (allTeamsIsError) { return ; } - if (!allTeams || allTeamsIsLoading || !allCars || allCarsIsLoading) { + if (!allTeams || allTeamsIsLoading || filter.isLoading) { return ; } - const carAutocompleteOptions = allCars.map((car) => { - return { - label: car.name, - id: car.id, - number: car.wbsNum.carNumber - }; - }); - - const datePickerStyle = { - width: 180, - height: 36, - color: 'white', - fontSize: '13px', - textTransform: 'none', - fontWeight: 400, - borderRadius: '4px', - boxShadow: 'none', - - '.MuiInputBase-root': { - height: '36px', - padding: '0 8px', - backgroundColor: '#ef4345', - color: 'white', - fontSize: '13px', - borderRadius: '4px', - '&:hover': { - backgroundColor: '#ef4345' - }, - '&.Mui-focused': { - backgroundColor: '#ef4345', - color: 'white' - } - }, - - '.MuiInputLabel-root': { - color: 'white', - fontSize: '14px', - transform: 'translate(15px, 7px) scale(1)', - '&.Mui-focused': { - color: 'white' - } - }, - - '.MuiInputLabel-shrink': { - transform: 'translate(14px, -6px) scale(0.75)', - color: 'white' - }, - - '& .MuiInputBase-input': { - color: 'white', - paddingTop: '8px', - cursor: 'pointer', - '&:focus': { - color: 'white' - } - }, - - '& .MuiOutlinedInput-notchedOutline': { - border: '1px solid #fff', - '&:hover': { - borderColor: '#fff' - }, - '&.Mui-focused': { - borderColor: '#fff' - } - }, - - '& .MuiSvgIcon-root': { - color: 'white', - '&:hover': { - color: 'white' - }, - '&.Mui-focused': { - color: 'white' - } - } - }; + if (filter.error) { + return ; + } - const dates = ( + const filterComponent = ( - setCarNumberState(newValue ? Number(newValue.id) : undefined)} - options={carAutocompleteOptions} - size="small" - placeholder="Select A Car" - value={ - carNumberState !== undefined ? carAutocompleteOptions.find((car) => car.id === carNumberState.toString()) : null - } - sx={datePickerStyle} - /> - (endDateState ? date > endDateState : false)} - slotProps={{ - textField: { - size: 'small', - sx: datePickerStyle - }, - field: { clearable: true } - }} - onChange={(newValue: Date | null) => setStartDateState(newValue ?? undefined)} - /> - - - - - - - (startDateState ? date < startDateState : false)} - slotProps={{ - textField: { - size: 'small', - sx: datePickerStyle - }, - field: { clearable: true } - }} - onChange={(newValue: Date | null) => setEndDateState(newValue ?? undefined)} - /> + ); if (allTeams.length === 0) { return ( - + ); @@ -190,13 +62,13 @@ const GeneralFinanceDashboard: React.FC = ({ start if (allTeams.length === 1) { return ( - + ); @@ -214,7 +86,7 @@ const GeneralFinanceDashboard: React.FC = ({ start return ( = ({ start {selectedTab && ( )} diff --git a/src/frontend/src/pages/GanttPage/ProjectGanttChart/ProjectGanttChartPage.tsx b/src/frontend/src/pages/GanttPage/ProjectGanttChart/ProjectGanttChartPage.tsx index 0306d2deec..2457c0736c 100644 --- a/src/frontend/src/pages/GanttPage/ProjectGanttChart/ProjectGanttChartPage.tsx +++ b/src/frontend/src/pages/GanttPage/ProjectGanttChart/ProjectGanttChartPage.tsx @@ -43,8 +43,8 @@ import { WorkPackageStage } from 'shared'; import { useAllTeams } from '../../../hooks/teams.hooks'; -import { useGetAllCars } from '../../../hooks/cars.hooks'; import { useAllTeamTypes } from '../../../hooks/team-types.hooks'; +import { useGlobalCarFilter } from '../../../app/AppGlobalCarFilterContext'; import AddGanttProjectModal from './AddGanttProjectModal'; import AddGanttWorkPackageModal from './AddGanttWorkPackageModal'; import AddGanttSelectionModal from './AddGanttSelectionModal'; @@ -78,7 +78,7 @@ const ProjectGanttChartPage: FC = () => { error: teamTypesError } = useAllTeamTypes(); - const { isLoading: carsIsLoading, isError: carsIsError, data: cars, error: carsError } = useGetAllCars(); + const { selectedCar, allCars, isLoading: carFilterLoading } = useGlobalCarFilter(); const { isLoading: teamsIsLoading, isError: teamsIsError, data: teams, error: teamsError } = useAllTeams(); const [searchText, setSearchText] = useState(''); const [addedProjects, setAddedProjects] = useState([]); @@ -98,6 +98,13 @@ const ProjectGanttChartPage: FC = () => { /******************** Filters ***************************/ const { filters, setFilters } = useGanttFilters('project-gantt'); + // Local car filter state — resets to global selection whenever global car filter changes + const [showCars, setShowCars] = useState([]); + useEffect(() => { + if (carFilterLoading) return; + setShowCars(selectedCar === 'all-cars' ? allCars.map((car) => car.wbsNum.carNumber) : [selectedCar.wbsNum.carNumber]); + }, [carFilterLoading, selectedCar, allCars]); + useEffect(() => { const requestRefresh = ( projects: ProjectGantt[], @@ -110,6 +117,7 @@ const ProjectGanttChartPage: FC = () => { let allProjects: ProjectGantt[] = JSON.parse(JSON.stringify(projects.concat(addedProjects))).map( projectGanttTransformer ); + allProjects = allProjects.map((project) => { const editedProject = editedProjects.find((proj) => proj.id === project.id); return editedProject ? editedProject : project; @@ -128,37 +136,34 @@ const ProjectGanttChartPage: FC = () => { }; if (projects && teams) { - requestRefresh(projects, teams, editedProjects, addedProjects, filters, searchText); + requestRefresh(projects, teams, editedProjects, addedProjects, { ...filters, showCars }, searchText); } - }, [teams, projects, addedProjects, setAllProjects, setCollections, editedProjects, filters, searchText, history]); + }, [ + teams, + projects, + addedProjects, + setAllProjects, + setCollections, + editedProjects, + filters, + showCars, + searchText, + history + ]); const handleSetGanttFilters = (newFilters: GanttFilters) => { setFilters(newFilters); }; - if ( - projectsIsLoading || - teamTypesIsLoading || - teamsIsLoading || - !teams || - !projects || - !teamTypes || - carsIsLoading || - !cars - ) + if (projectsIsLoading || teamTypesIsLoading || teamsIsLoading || carFilterLoading || !teams || !projects || !teamTypes) return ; if (projectsIsError) return ; if (teamTypesIsError) return ; if (teamsIsError) return ; - if (carsIsError) return ; const carFilterHandler = (car: number) => { return (event: ChangeEvent) => { - handleSetGanttFilters( - event.target.checked - ? { ...filters, showCars: Array.from(new Set([...filters.showCars, car])) } - : { ...filters, showCars: filters.showCars.filter((c) => c !== car) } - ); + setShowCars((prev) => (event.target.checked ? Array.from(new Set([...prev, car])) : prev.filter((c) => c !== car))); }; }; @@ -213,18 +218,21 @@ const ProjectGanttChartPage: FC = () => { filterLabel: string; handler: (event: ChangeEvent) => void; defaultChecked: boolean; - }[] = cars.map((car) => { - const carNum = car.wbsNum.carNumber; - return { - filterLabel: carNum === 0 ? 'None' : `Car ${carNum}`, - handler: carFilterHandler(carNum), - defaultChecked: filters.showCars.includes(carNum) - }; - }); + }[] = [...allCars] + .sort((a, b) => b.wbsNum.carNumber - a.wbsNum.carNumber) + .map((car) => { + const carNum = car.wbsNum.carNumber; + return { + filterLabel: car.name, + handler: carFilterHandler(carNum), + defaultChecked: showCars.includes(carNum) + }; + }); const resetHandler = () => { history.push(routes.GANTT); localStorage.removeItem('ganttURL'); + setShowCars(selectedCar === 'all-cars' ? allCars.map((car) => car.wbsNum.carNumber) : [selectedCar.wbsNum.carNumber]); }; /* **************************************************** */ @@ -441,7 +449,7 @@ const ProjectGanttChartPage: FC = () => { toast.error('No Team Selected'); } }} - cars={cars} + cars={allCars} /> ); }; diff --git a/src/frontend/src/pages/ProjectDetailPage/ProjectForm/ProjectCreateContainer.tsx b/src/frontend/src/pages/ProjectDetailPage/ProjectForm/ProjectCreateContainer.tsx index 9cad6c1605..929370923c 100644 --- a/src/frontend/src/pages/ProjectDetailPage/ProjectForm/ProjectCreateContainer.tsx +++ b/src/frontend/src/pages/ProjectDetailPage/ProjectForm/ProjectCreateContainer.tsx @@ -23,7 +23,7 @@ import { ProjectCreateChangeRequestFormInput } from './ProjectEditContainer'; import { dateToMidnightUTC, ProjectProposedChangesCreateArgs, WbsNumber, WorkPackageStage } from 'shared'; import { CreateStandardChangeRequestPayload, useCreateStandardChangeRequest } from '../../../hooks/change-requests.hooks'; import { useCreateSingleWorkPackage } from '../../../hooks/work-packages.hooks'; -import { useGetAllCars } from '../../../hooks/cars.hooks'; +import { useGlobalCarFilter } from '../../../app/AppGlobalCarFilterContext'; import { ChangeRequestReason } from 'shared'; import { yupResolver } from '@hookform/resolvers/yup'; import { ChangeRequestType } from 'shared'; @@ -35,8 +35,7 @@ const ProjectCreateContainer: React.FC = () => { const [managerId, setManagerId] = useState(); const [leadId, setLeadId] = useState(); - const [carNumber, setCarNumber] = useState(); - const { data: cars, isLoading: carsIsLoading, isError: carsIsError, error: carsError } = useGetAllCars(); + const { selectedCar, isLoading: carFilterIsLoading } = useGlobalCarFilter(); const { mutateAsync: createProjectMutateAsync, isLoading: createProjectIsLoading } = useCreateSingleProject(); const { mutateAsync: mutateCRAsync, isLoading: isCRHookLoading } = useCreateStandardChangeRequest(); @@ -47,7 +46,7 @@ const ProjectCreateContainer: React.FC = () => { budget: 0, summary: '', teamIds: [], - carNumber, + carNumber: selectedCar === 'all-cars' ? undefined : selectedCar.wbsNum.carNumber, links: [], crId: query.get('crId') || undefined, descriptionBullets: [], @@ -133,14 +132,11 @@ const ProjectCreateContainer: React.FC = () => { createWpIsLoading || !allLinkTypes || allLinkTypesIsLoading || - carsIsLoading || - !cars + carFilterIsLoading ) return ; if (allLinkTypesIsError) return ; - if (carsIsError) return ; - const requiredLinkTypeNames = getRequiredLinkTypeNames(allLinkTypes); const onSubmitChangeRequest = async (data: ProjectCreateChangeRequestFormInput) => { @@ -280,7 +276,6 @@ const ProjectCreateContainer: React.FC = () => { leadId={leadId} managerId={managerId} onSubmitChangeRequest={onSubmitChangeRequest} - setCarNumber={setCarNumber} changeRequestFormReturn={changeRequestFormMethods} /> ); diff --git a/src/frontend/src/pages/ProjectDetailPage/ProjectForm/ProjectEditContainer.tsx b/src/frontend/src/pages/ProjectDetailPage/ProjectForm/ProjectEditContainer.tsx index b1034e9ff8..65c1c43731 100644 --- a/src/frontend/src/pages/ProjectDetailPage/ProjectForm/ProjectEditContainer.tsx +++ b/src/frontend/src/pages/ProjectDetailPage/ProjectForm/ProjectEditContainer.tsx @@ -45,7 +45,6 @@ const ProjectEditContainer: React.FC = ({ project, ex const { name, budget, summary, workPackages } = project; const [managerId, setManagerId] = useState(project.manager?.userId.toString()); const [leadId, setLeadId] = useState(project.lead?.userId.toString()); - const [carNumber, setCarNumber] = useState(project.wbsNum.carNumber); const descriptionBullets = bulletsToObject(project.descriptionBullets); const { mutateAsync, isLoading } = useEditSingleProject(project.wbsNum); @@ -141,7 +140,7 @@ const ProjectEditContainer: React.FC = ({ project, ex summary, // teamId and carNumber aren't used for projectEdit teamIds: [], - carNumber, + carNumber: project.wbsNum.carNumber, links, crId: query.get('crId') || '', descriptionBullets, @@ -290,7 +289,6 @@ const ProjectEditContainer: React.FC = ({ project, ex leadId={leadId} 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 dfcfc9fd2b..8829498789 100644 --- a/src/frontend/src/pages/ProjectDetailPage/ProjectForm/ProjectForm.tsx +++ b/src/frontend/src/pages/ProjectDetailPage/ProjectForm/ProjectForm.tsx @@ -56,8 +56,6 @@ interface ProjectFormContainerProps { leadId?: string; managerId?: string; onSubmitChangeRequest?: (data: ProjectCreateChangeRequestFormInput) => void; - setCarNumber: (carNumber: number) => void; - carNumber?: number; changeRequestFormReturn: ChangeRequestFormReturn; onlyLeadershipChanged?: boolean; } @@ -73,7 +71,6 @@ const ProjectFormContainer: React.FC = ({ leadId, managerId, onSubmitChangeRequest, - setCarNumber, changeRequestFormReturn, onlyLeadershipChanged }) => { @@ -307,7 +304,6 @@ const ProjectFormContainer: React.FC = ({ leadId={leadId} managerId={managerId} project={project} - setCarNumber={setCarNumber} /> diff --git a/src/frontend/src/pages/ProjectDetailPage/ProjectForm/ProjectFormDetails.tsx b/src/frontend/src/pages/ProjectDetailPage/ProjectForm/ProjectFormDetails.tsx index 4aa5f88d2b..c5c5e63691 100644 --- a/src/frontend/src/pages/ProjectDetailPage/ProjectForm/ProjectFormDetails.tsx +++ b/src/frontend/src/pages/ProjectDetailPage/ProjectForm/ProjectFormDetails.tsx @@ -1,5 +1,5 @@ import { Project, User } from 'shared'; -import { Box, FormControl, FormLabel, Grid, MenuItem, Select, Typography } from '@mui/material'; +import { Box, FormControl, FormHelperText, FormLabel, Grid, MenuItem, TextField, Typography } from '@mui/material'; import ReactHookTextField from '../../../components/ReactHookTextField'; import { fullNamePipe } from '../../../utils/pipes'; import NERAutocomplete from '../../../components/NERAutocomplete'; @@ -8,9 +8,8 @@ import { Control, Controller, FieldErrorsImpl } from 'react-hook-form'; import { AttachMoney } from '@mui/icons-material'; import TeamDropdown from '../../../components/TeamsDropdown'; import ChangeRequestDropdown from '../../../components/ChangeRequestDropdown'; -import { useGetAllCars } from '../../../hooks/cars.hooks'; +import { useGlobalCarFilter } from '../../../app/AppGlobalCarFilterContext'; import LoadingIndicator from '../../../components/LoadingIndicator'; -import ErrorPage from '../../ErrorPage'; interface ProjectEditDetailsProps { users: User[]; @@ -22,7 +21,6 @@ interface ProjectEditDetailsProps { setManagerId: (id?: string) => void; setLeadId: (id?: string) => void; setcrId?: (crId?: number) => void; - setCarNumber: (carNumber: number) => void; } const userToAutocompleteOption = (user?: User): { label: string; id: string } => { @@ -38,18 +36,15 @@ const ProjectFormDetails: React.FC = ({ managerId, leadId, setLeadId, - setManagerId, - setCarNumber + setManagerId }) => { - const { data: cars, isLoading, isError, error } = useGetAllCars(); + const { selectedCar, allCars, isLoading: carFilterIsLoading } = useGlobalCarFilter(); - if (isLoading || !cars) { + if (carFilterIsLoading) { return ; } - if (isError) { - return ; - } + const sortedCars = [...allCars].sort((a, b) => b.wbsNum.carNumber - a.wbsNum.carNumber); return ( @@ -68,43 +63,33 @@ const ProjectFormDetails: React.FC = ({ /> + {!project && selectedCar === 'all-cars' && ( + + + Car + ( + + {sortedCars.map((car) => ( + + {car.name} + + ))} + + )} + /> + {errors.carNumber?.message} + + + )} {!project && ( - <> - - - Car - ( - - )} - > - - - - - - - - + + + + + )} diff --git a/src/frontend/src/pages/ProjectDetailPage/ProjectViewContainer/TaskList/TaskFormModal.tsx b/src/frontend/src/pages/ProjectDetailPage/ProjectViewContainer/TaskList/TaskFormModal.tsx index a995fb6434..762123f7db 100644 --- a/src/frontend/src/pages/ProjectDetailPage/ProjectViewContainer/TaskList/TaskFormModal.tsx +++ b/src/frontend/src/pages/ProjectDetailPage/ProjectViewContainer/TaskList/TaskFormModal.tsx @@ -3,7 +3,7 @@ import { Autocomplete, FormControl, FormHelperText, FormLabel, Grid, MenuItem, T import { DatePicker } from '@mui/x-date-pickers'; import { Controller, useForm } from 'react-hook-form'; import { countWords, isGuest, isUnderWordCount, Task, TaskPriority, TaskStatus, TeamPreview } from 'shared'; -import { useAllUsers, useCurrentUser } from '../../../../hooks/users.hooks'; +import { useAllMembers, useCurrentUser } from '../../../../hooks/users.hooks'; import * as yup from 'yup'; import { taskUserToAutocompleteOption } from '../../../../utils/task.utils'; import NERFormModal from '../../../../components/NERFormModal'; @@ -71,7 +71,7 @@ const TaskFormModal: React.FC = ({ task, status, onSubmit, m const user = useCurrentUser(); - const { data: users, isLoading, isError, error } = useAllUsers(); + const { data: users, isLoading, isError, error } = useAllMembers(); const { handleSubmit, diff --git a/src/frontend/src/pages/ProjectsPage/ProjectsTable.tsx b/src/frontend/src/pages/ProjectsPage/ProjectsTable.tsx index b4f73f9c21..88d2dbcde4 100644 --- a/src/frontend/src/pages/ProjectsPage/ProjectsTable.tsx +++ b/src/frontend/src/pages/ProjectsPage/ProjectsTable.tsx @@ -20,6 +20,7 @@ import { getProjectTeamsName } from '../ProjectDetailPage/ProjectViewContainer/P */ const ProjectsTable: React.FC = () => { const { isLoading, data, error } = useAllProjects(); + if (!localStorage.getItem('projectsTableRowCount')) localStorage.setItem('projectsTableRowCount', '30'); const [pageSize, setPageSize] = useState(localStorage.getItem('projectsTableRowCount')); const [windowSize, setWindowSize] = useState(window.innerWidth); diff --git a/src/frontend/src/tests/app/AppContext.test.tsx b/src/frontend/src/tests/app/AppContext.test.tsx index f3ffdc7a2f..9232cfe0b8 100644 --- a/src/frontend/src/tests/app/AppContext.test.tsx +++ b/src/frontend/src/tests/app/AppContext.test.tsx @@ -33,6 +33,15 @@ vi.mock('../../app/AppContextTheme', () => { }; }); +vi.mock('../../app/AppGlobalCarFilterContext', () => { + return { + __esModule: true, + GlobalCarFilterProvider: (props: { children: React.ReactNode }) => { + return
{props.children}
; + } + }; +}); + // Sets up the component under test with the desired values and renders it const renderComponent = () => { render( diff --git a/src/frontend/src/tests/app/AppContextQuery.test.tsx b/src/frontend/src/tests/app/AppContextQuery.test.tsx index 415c35ebcb..6c74de915f 100644 --- a/src/frontend/src/tests/app/AppContextQuery.test.tsx +++ b/src/frontend/src/tests/app/AppContextQuery.test.tsx @@ -7,6 +7,16 @@ import { render, screen } from '@testing-library/react'; // avoid circular depen import { useAllChangeRequests } from '../../hooks/change-requests.hooks'; import AppContextQuery from '../../app/AppContextQuery'; +vi.mock('../../app/AppGlobalCarFilterContext', () => ({ + useGlobalCarFilter: () => ({ + selectedCar: 'all-cars', + allCars: [], + setSelectedCar: vi.fn(), + isLoading: false, + error: null + }) +})); + describe('app context', () => { it('renders simple text as children', () => { render(hello); diff --git a/src/frontend/src/tests/hooks/ChangeRequests.hooks.test.tsx b/src/frontend/src/tests/hooks/ChangeRequests.hooks.test.tsx index 00cd71bc2d..33762e979a 100644 --- a/src/frontend/src/tests/hooks/ChangeRequests.hooks.test.tsx +++ b/src/frontend/src/tests/hooks/ChangeRequests.hooks.test.tsx @@ -13,6 +13,15 @@ import { getAllChangeRequests, getSingleChangeRequest } from '../../apis/change- import { useAllChangeRequests, useSingleChangeRequest } from '../../hooks/change-requests.hooks'; vi.mock('../../apis/change-requests.api'); +vi.mock('../../app/AppGlobalCarFilterContext', () => ({ + useGlobalCarFilter: () => ({ + selectedCar: 'all-cars', + allCars: [], + setSelectedCar: vi.fn(), + isLoading: false, + error: null + }) +})); describe('change request hooks', () => { it('handles getting a list of change requests', async () => { diff --git a/src/frontend/src/tests/hooks/GlobalCarFilterContext.test.tsx b/src/frontend/src/tests/hooks/GlobalCarFilterContext.test.tsx new file mode 100644 index 0000000000..5162f79b99 --- /dev/null +++ b/src/frontend/src/tests/hooks/GlobalCarFilterContext.test.tsx @@ -0,0 +1,233 @@ +/* + * This file is part of NER's FinishLine and licensed under GNU AGPLv3. + * See the LICENSE file in the repository root folder for details. + */ + +import { renderHook, render, screen, act, waitFor } from '@testing-library/react'; +import { QueryClient, QueryClientProvider } from 'react-query'; +import { GlobalCarFilterProvider, useGlobalCarFilter } from '../../app/AppGlobalCarFilterContext'; +import * as carsHooks from '../../hooks/cars.hooks'; +import { exampleAllCars } from '../test-support/test-data/cars.stub'; + +// Mock the hooks +vi.mock('../../hooks/cars.hooks'); +const mockUseGetAllCars = vi.mocked(carsHooks.useGetAllCars); + +// Create wrapper with providers +const createWrapper = () => { + const queryClient = new QueryClient({ + defaultOptions: { + queries: { retry: false }, + mutations: { retry: false } + } + }); + + return ({ children }: { children: React.ReactNode }) => ( + + {children} + + ); +}; + +describe('useGlobalCarFilter', () => { + beforeEach(() => { + localStorage.clear(); + vi.clearAllMocks(); + }); + + it('should default to the most recent car when no saved car id in local storage', async () => { + mockUseGetAllCars.mockReturnValue({ + data: exampleAllCars, + isLoading: false, + error: null + } as any); + + const { result } = renderHook(() => useGlobalCarFilter(), { + wrapper: createWrapper() + }); + + await waitFor(() => { + expect(result.current.isLoading).toBe(false); + }); + + const mostRecentCar = exampleAllCars.reduce((a, b) => (a.wbsNum.carNumber > b.wbsNum.carNumber ? a : b)); + expect(result.current.selectedCar).toEqual(mostRecentCar); + expect(localStorage.getItem('selectedCarId')).toBe(mostRecentCar.id); + }); + + it('should restore car from local storage by id', async () => { + localStorage.setItem('selectedCarId', exampleAllCars[0].id); + + mockUseGetAllCars.mockReturnValue({ + data: exampleAllCars, + isLoading: false, + error: null + } as any); + + const { result } = renderHook(() => useGlobalCarFilter(), { + wrapper: createWrapper() + }); + + await waitFor(() => { + expect(result.current.selectedCar).toEqual(exampleAllCars[0]); + }); + }); + + it('should restore "all-cars" from local storage', async () => { + localStorage.setItem('selectedCarId', 'all-cars'); + + mockUseGetAllCars.mockReturnValue({ + data: exampleAllCars, + isLoading: false, + error: null + } as any); + + const { result } = renderHook(() => useGlobalCarFilter(), { + wrapper: createWrapper() + }); + + await waitFor(() => { + expect(result.current.isLoading).toBe(false); + }); + + expect(result.current.selectedCar).toBe('all-cars'); + }); + + it('should default to most recent car when saved car id does not match any car', async () => { + localStorage.setItem('selectedCarId', 'nonexistent-id'); + + mockUseGetAllCars.mockReturnValue({ + data: exampleAllCars, + isLoading: false, + error: null + } as any); + + const { result } = renderHook(() => useGlobalCarFilter(), { + wrapper: createWrapper() + }); + + await waitFor(() => { + expect(result.current.isLoading).toBe(false); + }); + + const mostRecentCar = exampleAllCars.reduce((a, b) => (a.wbsNum.carNumber > b.wbsNum.carNumber ? a : b)); + expect(result.current.selectedCar).toEqual(mostRecentCar); + }); + + it('should persist car id to local storage when selecting a car', async () => { + mockUseGetAllCars.mockReturnValue({ + data: exampleAllCars, + isLoading: false, + error: null + } as any); + + const { result } = renderHook(() => useGlobalCarFilter(), { + wrapper: createWrapper() + }); + + await waitFor(() => { + expect(result.current.isLoading).toBe(false); + }); + + act(() => { + result.current.setSelectedCar(exampleAllCars[1]); + }); + + expect(localStorage.getItem('selectedCarId')).toBe(exampleAllCars[1].id); + expect(result.current.selectedCar).toEqual(exampleAllCars[1]); + }); + + it('should store "all-cars" in local storage when selecting all cars', async () => { + localStorage.setItem('selectedCarId', exampleAllCars[0].id); + + mockUseGetAllCars.mockReturnValue({ + data: exampleAllCars, + isLoading: false, + error: null + } as any); + + const { result } = renderHook(() => useGlobalCarFilter(), { + wrapper: createWrapper() + }); + + await waitFor(() => { + expect(result.current.selectedCar).toEqual(exampleAllCars[0]); + }); + + act(() => { + result.current.setSelectedCar('all-cars'); + }); + + expect(localStorage.getItem('selectedCarId')).toBe('all-cars'); + expect(result.current.selectedCar).toBe('all-cars'); + }); + + it('should render a loading indicator while cars are being fetched', () => { + mockUseGetAllCars.mockReturnValue({ + data: undefined, + isLoading: true, + error: null + } as any); + + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false }, mutations: { retry: false } } + }); + + render( + + +
+ + + ); + + expect(screen.getByTestId('loader')).toBeInTheDocument(); + expect(screen.queryByTestId('children')).toBeNull(); + }); + + it('should handle error state', () => { + const error = new Error('Failed to load cars'); + + mockUseGetAllCars.mockReturnValue({ + data: undefined, + isLoading: false, + error + } as any); + + const { result } = renderHook(() => useGlobalCarFilter(), { + wrapper: createWrapper() + }); + + expect(result.current.error).toBe(error); + expect(result.current.isLoading).toBe(false); + }); + + it('should update local storage when switching between cars', async () => { + mockUseGetAllCars.mockReturnValue({ + data: exampleAllCars, + isLoading: false, + error: null + } as any); + + const { result } = renderHook(() => useGlobalCarFilter(), { + wrapper: createWrapper() + }); + + await waitFor(() => { + expect(result.current.isLoading).toBe(false); + }); + + act(() => { + result.current.setSelectedCar(exampleAllCars[0]); + }); + + expect(localStorage.getItem('selectedCarId')).toBe(exampleAllCars[0].id); + + act(() => { + result.current.setSelectedCar(exampleAllCars[2]); + }); + + expect(localStorage.getItem('selectedCarId')).toBe(exampleAllCars[2].id); + expect(result.current.selectedCar).toEqual(exampleAllCars[2]); + }); +}); diff --git a/src/frontend/src/tests/hooks/Projects.hooks.test.tsx b/src/frontend/src/tests/hooks/Projects.hooks.test.tsx index 9553ea10d3..04485ff008 100644 --- a/src/frontend/src/tests/hooks/Projects.hooks.test.tsx +++ b/src/frontend/src/tests/hooks/Projects.hooks.test.tsx @@ -6,13 +6,15 @@ import { renderHook, waitFor } from '@testing-library/react'; import { AxiosResponse } from 'axios'; import { Project } from 'shared'; -import wrapper from '../../app/AppContextQuery'; +import AppContextQuery from '../../app/AppContextQuery'; import { mockPromiseAxiosResponse } from '../test-support/test-data/test-utils.stub'; import { exampleAllProjects, exampleProject1 } from '../test-support/test-data/projects.stub'; import { exampleWbsProject1 } from '../test-support/test-data/wbs-numbers.stub'; import { getAllProjectsGantt, getSingleProject } from '../../apis/projects.api'; import { useAllProjectsGantt, useSingleProject } from '../../hooks/projects.hooks'; +const wrapper = ({ children }: { children: React.ReactNode }) => {children}; + vi.mock('../../apis/projects.api'); describe('project hooks', () => { diff --git a/src/frontend/src/tests/hooks/WorkPackages.hooks.test.tsx b/src/frontend/src/tests/hooks/WorkPackages.hooks.test.tsx index 1086a96e20..54cc5b466a 100644 --- a/src/frontend/src/tests/hooks/WorkPackages.hooks.test.tsx +++ b/src/frontend/src/tests/hooks/WorkPackages.hooks.test.tsx @@ -6,14 +6,28 @@ import { renderHook, waitFor } from '@testing-library/react'; import { AxiosResponse } from 'axios'; import { WorkPackage } from 'shared'; -import wrapper from '../../app/AppContextQuery'; +import AppContextQuery from '../../app/AppContextQuery'; +import { GlobalCarFilterProvider } from '../../app/AppGlobalCarFilterContext'; import { mockPromiseAxiosResponse } from '../test-support/test-data/test-utils.stub'; import { exampleAllWorkPackages, exampleResearchWorkPackage } from '../test-support/test-data/work-packages.stub'; import { exampleWbsWorkPackage1 } from '../test-support/test-data/wbs-numbers.stub'; import { getAllWorkPackages, getSingleWorkPackage } from '../../apis/work-packages.api'; import { useAllWorkPackages, useSingleWorkPackage } from '../../hooks/work-packages.hooks'; +import * as carsHooks from '../../hooks/cars.hooks'; +import { exampleAllCars } from '../test-support/test-data/cars.stub'; + +const wrapper = ({ children }: { children: React.ReactNode }) => ( + + {children} + +); vi.mock('../../apis/work-packages.api'); +vi.mock('../../hooks/cars.hooks'); + +beforeEach(() => { + vi.mocked(carsHooks.useGetAllCars).mockReturnValue({ data: exampleAllCars, isLoading: false, error: null } as any); +}); describe('work package hooks', () => { it('handles getting a list of work packages', async () => { diff --git a/src/frontend/src/tests/layouts/Sidebar/Sidebar.test.tsx b/src/frontend/src/tests/layouts/Sidebar/Sidebar.test.tsx index fb1231df40..ddeb70d78c 100644 --- a/src/frontend/src/tests/layouts/Sidebar/Sidebar.test.tsx +++ b/src/frontend/src/tests/layouts/Sidebar/Sidebar.test.tsx @@ -11,6 +11,16 @@ import Sidebar from '../../../layouts/Sidebar/Sidebar'; import { ToastContext, ToastInputs } from '../../../components/Toast/ToastProvider'; import { exampleAuthenticatedAdminUser } from '../../test-support/test-data/authenticated-user.stub'; +vi.mock('../../../app/AppGlobalCarFilterContext', () => ({ + useGlobalCarFilter: () => ({ + selectedCar: 'all-cars', + allCars: [], + setSelectedCar: vi.fn(), + isLoading: false, + error: null + }) +})); + const addToast = (message: ToastInputs) => { console.log(message); }; diff --git a/src/frontend/src/tests/pages/ChangeRequestDetailPage/ChangeRequestDetailsView.test.tsx b/src/frontend/src/tests/pages/ChangeRequestDetailPage/ChangeRequestDetailsView.test.tsx index 292bfed62e..3547c9babc 100644 --- a/src/frontend/src/tests/pages/ChangeRequestDetailPage/ChangeRequestDetailsView.test.tsx +++ b/src/frontend/src/tests/pages/ChangeRequestDetailPage/ChangeRequestDetailsView.test.tsx @@ -24,6 +24,15 @@ import { exampleAuthenticatedAdminUser } from '../../test-support/test-data/auth vi.mock('../../../hooks/projects.hooks'); vi.mock('../../../hooks/users.hooks'); +vi.mock('../../../app/AppGlobalCarFilterContext', () => ({ + useGlobalCarFilter: () => ({ + selectedCar: 'all-cars', + allCars: [], + setSelectedCar: vi.fn(), + isLoading: false, + error: null + }) +})); const mockedUseSingleProject = useSingleProject as jest.Mock>; const mockSingleProjectHook = (isLoading: boolean, isError: boolean, data?: Project, error?: Error) => { diff --git a/src/frontend/src/tests/pages/HomePage/Home.test.tsx b/src/frontend/src/tests/pages/HomePage/Home.test.tsx index e337d2d16e..940067d840 100644 --- a/src/frontend/src/tests/pages/HomePage/Home.test.tsx +++ b/src/frontend/src/tests/pages/HomePage/Home.test.tsx @@ -13,6 +13,16 @@ import { mockAuth } from '../../test-support/test-data/test-utils.stub'; import { mockUseSingleUserSettings } from '../../test-support/mock-hooks'; import { exampleAuthenticatedAdminUser } from '../../test-support/test-data/authenticated-user.stub'; +vi.mock('../../../app/AppGlobalCarFilterContext', () => ({ + useGlobalCarFilter: () => ({ + selectedCar: 'all-cars', + allCars: [], + setSelectedCar: vi.fn(), + isLoading: false, + error: null + }) +})); + vi.mock('../../../pages/HomePage/components/UsefulLinks', () => { return { __esModule: true, diff --git a/src/frontend/src/tests/test-support/test-data/cars.stub.ts b/src/frontend/src/tests/test-support/test-data/cars.stub.ts new file mode 100644 index 0000000000..db7d813004 --- /dev/null +++ b/src/frontend/src/tests/test-support/test-data/cars.stub.ts @@ -0,0 +1,66 @@ +/* + * 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 { Car, WbsElementStatus } from 'shared'; + +export const exampleCar1: Car = { + wbsElementId: 'wbs-element-1', + id: 'car-1', + name: 'Car 2023', + wbsNum: { + carNumber: 23, + projectNumber: 0, + workPackageNumber: 0 + }, + dateCreated: new Date('2023-01-01'), + deleted: false, + status: WbsElementStatus.Active, + links: [], + changes: [], + descriptionBullets: [] +}; + +export const exampleCar2: Car = { + wbsElementId: 'wbs-element-2', + id: 'car-2', + name: 'Car 2024', + wbsNum: { + carNumber: 24, + projectNumber: 0, + workPackageNumber: 0 + }, + dateCreated: new Date('2024-01-01'), + deleted: false, + status: WbsElementStatus.Active, + links: [], + changes: [], + descriptionBullets: [] +}; + +export const exampleCar3: Car = { + wbsElementId: 'wbs-element-3', + id: 'car-3', + name: 'Car 2025', + wbsNum: { + carNumber: 25, + projectNumber: 0, + workPackageNumber: 0 + }, + dateCreated: new Date('2025-01-01'), + deleted: false, + status: WbsElementStatus.Active, + links: [], + changes: [], + descriptionBullets: [] +}; + +export const exampleAllCars: Car[] = [exampleCar1, exampleCar2, exampleCar3]; + +export const exampleCurrentCar: Car = exampleCar3; // Latest car by car number + +// Additional test data for global car filter +export const exampleEmptyCarArray: Car[] = []; + +export const exampleSingleCar: Car[] = [exampleCar3]; diff --git a/src/frontend/src/utils/axios.ts b/src/frontend/src/utils/axios.ts index 85eb74d43a..410d6843b7 100644 --- a/src/frontend/src/utils/axios.ts +++ b/src/frontend/src/utils/axios.ts @@ -1,9 +1,23 @@ import axiosStatic from 'axios'; +declare module 'axios' { + interface AxiosRequestConfig { + overrideCarId?: string | 'all-cars'; + } +} + const axios = axiosStatic.create({ withCredentials: import.meta.env.MODE !== 'development' ? true : undefined }); +// holds the validated car UUID in memory, set by GlobalCarFilterProvider after login. +// Storing only in memory prevents stale UUIDs from being sent +// before the car list has been loaded and validated post-login. +let currentCarId: string | null = null; +export const setCurrentCarId = (id: string | null) => { + currentCarId = id; +}; + // This allows us to get good server errors // All express responses must be: res.status(404).json({ message: "You are not authorized to do that." }) axios.interceptors.response.use( @@ -37,6 +51,11 @@ axios.interceptors.request.use( if (import.meta.env.MODE === 'development') request.headers!['Authorization'] = localStorage.getItem('devUserId') || ''; const organizationId = localStorage.getItem('organizationId'); request.headers!['organizationId'] = organizationId ?? ''; + if (request.overrideCarId !== undefined) { + if (request.overrideCarId !== 'all-cars') request.headers!['carId'] = request.overrideCarId; + } else if (currentCarId) { + request.headers!['carId'] = currentCarId; + } return request; }, (error) => { diff --git a/src/frontend/src/utils/urls.ts b/src/frontend/src/utils/urls.ts index 281f2a9d19..4ccc856fc2 100644 --- a/src/frontend/src/utils/urls.ts +++ b/src/frontend/src/utils/urls.ts @@ -226,90 +226,72 @@ const financeEditOtherReimbursementProductReason = (id: String) => const getReimbursementRequestProjectData = (projectId: string, startDate?: Date, endDate?: Date): string => { const url = new URL(`${financeRoutesEndpoints()}/reimbursement-request-project-data/${projectId}`); const params = new URLSearchParams(); - if (startDate) params.set('startDate', startDate.toISOString()); - if (endDate) params.set('endDate', endDate.toISOString()); + if (startDate) params.set('startDate', new Date(startDate).toISOString()); + if (endDate) params.set('endDate', new Date(endDate).toISOString()); const queryString = params.toString(); return queryString ? `${url.toString()}?${queryString}` : url.toString(); }; -const getReimbursementRequestTeamData = (teamId: string, startDate?: Date, endDate?: Date, carNumber?: number): string => { +const getReimbursementRequestTeamData = (teamId: string, startDate?: Date, endDate?: Date): string => { const url = new URL(`${financeRoutesEndpoints()}/reimbursement-request-team-data/${teamId}`); const params = new URLSearchParams(); - if (startDate) params.set('startDate', startDate.toISOString()); - if (endDate) params.set('endDate', endDate.toISOString()); - if (carNumber !== undefined) params.set('carNumber', carNumber.toString()); + if (startDate) params.set('startDate', new Date(startDate).toISOString()); + if (endDate) params.set('endDate', new Date(endDate).toISOString()); const queryString = params.toString(); return queryString ? `${url.toString()}?${queryString}` : url.toString(); }; -const getReimbursementRequestCategoryData = ( - otherReasonId: string, - startDate?: Date, - endDate?: Date, - carNumber?: number -): string => { +const getReimbursementRequestCategoryData = (otherReasonId: string, startDate?: Date, endDate?: Date): string => { const url = new URL(`${financeRoutesEndpoints()}/reimbursement-request-category-data/${otherReasonId}`); const params = new URLSearchParams(); - if (startDate) params.set('startDate', startDate.toISOString()); - if (endDate) params.set('endDate', endDate.toISOString()); - if (carNumber !== undefined) params.set('carNumber', carNumber.toString()); + if (startDate) params.set('startDate', new Date(startDate).toISOString()); + if (endDate) params.set('endDate', new Date(endDate).toISOString()); const queryString = params.toString(); return queryString ? `${url.toString()}?${queryString}` : url.toString(); }; -const getAllReimbursementRequestData = (startDate?: Date, endDate?: Date, carNumber?: number): string => { +const getAllReimbursementRequestData = (startDate?: Date, endDate?: Date): string => { const url = new URL(`${financeRoutesEndpoints()}/reimbursement-request-data`); const params = new URLSearchParams(); - if (startDate) params.set('startDate', startDate.toISOString()); - if (endDate) params.set('endDate', endDate.toISOString()); - if (carNumber !== undefined) params.set('carNumber', carNumber.toString()); + if (startDate) params.set('startDate', new Date(startDate).toISOString()); + if (endDate) params.set('endDate', new Date(endDate).toISOString()); const queryString = params.toString(); return queryString ? `${url.toString()}?${queryString}` : url.toString(); }; -const getReimbursementRequestTeamTypeData = ( - teamTypeId: string, - startDate?: Date, - endDate?: Date, - carNumber?: number -): string => { +const getReimbursementRequestTeamTypeData = (teamTypeId: string, startDate?: Date, endDate?: Date): string => { const url = new URL(`${financeRoutesEndpoints()}/reimbursement-request-team-type-data/${teamTypeId}`); const params = new URLSearchParams(); - if (startDate) params.set('startDate', startDate.toISOString()); - if (endDate) params.set('endDate', endDate.toISOString()); - if (carNumber !== undefined) params.set('carNumber', carNumber.toString()); + if (startDate) params.set('startDate', new Date(startDate).toISOString()); + if (endDate) params.set('endDate', new Date(endDate).toISOString()); const queryString = params.toString(); return queryString ? `${url.toString()}?${queryString}` : url.toString(); }; -const getSpendingBarTeamData = (teamId: string, startDate?: Date, endDate?: Date, carNumber?: number): string => { +const getSpendingBarTeamData = (teamId: string, startDate?: Date, endDate?: Date): string => { const url = new URL(`${financeRoutesEndpoints()}/spending-bar-team-data/${teamId}`); const params = new URLSearchParams(); - if (startDate) params.set('startDate', startDate.toISOString()); - if (endDate) params.set('endDate', endDate.toISOString()); - if (carNumber !== undefined) params.set('carNumber', carNumber.toString()); + if (startDate) params.set('startDate', new Date(startDate).toISOString()); + if (endDate) params.set('endDate', new Date(endDate).toISOString()); const queryString = params.toString(); return queryString ? `${url.toString()}?${queryString}` : url.toString(); }; -const getSpendingBarTeamTypeData = (teamTypeId: string, startDate?: Date, endDate?: Date, carNumber?: number): string => { +const getSpendingBarTeamTypeData = (teamTypeId: string, startDate?: Date, endDate?: Date): string => { const url = new URL(`${financeRoutesEndpoints()}/spending-bar-team-type-data/${teamTypeId}`); const params = new URLSearchParams(); - if (startDate) params.set('startDate', startDate.toISOString()); - if (endDate) params.set('endDate', endDate.toISOString()); - if (carNumber !== undefined) params.set('carNumber', carNumber.toString()); + if (startDate) params.set('startDate', new Date(startDate).toISOString()); + if (endDate) params.set('endDate', new Date(endDate).toISOString()); const queryString = params.toString(); return queryString ? `${url.toString()}?${queryString}` : url.toString(); }; -const getSpendingBarCategoryData = (startDate?: Date, endDate?: Date, carNumber?: number): string => { +const getSpendingBarCategoryData = (startDate?: Date, endDate?: Date): string => { const url = new URL(`${financeRoutesEndpoints()}/spending-bar-category-data`); const params = new URLSearchParams(); - if (startDate) params.set('startDate', startDate.toISOString()); - if (endDate) params.set('endDate', endDate.toISOString()); - if (carNumber !== undefined) params.set('carNumber', carNumber.toString()); + if (startDate) params.set('startDate', new Date(startDate).toISOString()); + if (endDate) params.set('endDate', new Date(endDate).toISOString()); const queryString = params.toString(); return queryString ? `${url.toString()}?${queryString}` : url.toString(); }; -const getAllSpendingBarData = (startDate?: Date, endDate?: Date, carNumber?: number): string => { +const getAllSpendingBarData = (startDate?: Date, endDate?: Date): string => { const url = new URL(`${financeRoutesEndpoints()}/spending-bar-data`); const params = new URLSearchParams(); - if (startDate) params.set('startDate', startDate.toISOString()); - if (endDate) params.set('endDate', endDate.toISOString()); - if (carNumber !== undefined) params.set('carNumber', carNumber.toString()); + if (startDate) params.set('startDate', new Date(startDate).toISOString()); + if (endDate) params.set('endDate', new Date(endDate).toISOString()); const queryString = params.toString(); return queryString ? `${url.toString()}?${queryString}` : url.toString(); }; @@ -454,8 +436,8 @@ const deleteGraphCollection = (id: string) => `${graphCollectionById(id)}/delete /************** Retrospective Endpoints ***************/ const retrospectiveTimelines = (startDate?: Date, endDate?: Date) => `${API_URL}/retrospective/timelines?` + - (startDate ? `start=${encodeURIComponent(startDate.toISOString())}` : '') + - (endDate ? `end=${encodeURIComponent(endDate.toISOString())}` : ''); + (startDate ? `start=${encodeURIComponent(new Date(startDate).toISOString())}` : '') + + (endDate ? `end=${encodeURIComponent(new Date(endDate).toISOString())}` : ''); const retrospectiveBudgets = () => `${API_URL}/retrospective/budgets`; /**************** Calendar Endpoints ****************/ diff --git a/system-tests/cypress.config.js b/system-tests/cypress.config.js index cbe2689ab4..d349927eaf 100644 --- a/system-tests/cypress.config.js +++ b/system-tests/cypress.config.js @@ -11,6 +11,7 @@ module.exports = { defaultCommandTimeout: 10000 }, env: { - base_url: 'http://localhost:3000' + base_url: 'http://localhost:3000', + backend_url: 'http://localhost:3001' } }; diff --git a/system-tests/cypress/e2e/projects/projects-overview.cy.js b/system-tests/cypress/e2e/projects/projects-overview.cy.js index 53188cddfc..37222ee1c1 100644 --- a/system-tests/cypress/e2e/projects/projects-overview.cy.js +++ b/system-tests/cypress/e2e/projects/projects-overview.cy.js @@ -36,7 +36,7 @@ describe('Projects Overview', () => { // Fill in Project Name cy.get('[placeholder="Enter project name..."]').type(projectName); - // Car is pre-selected (Miles), keep default + // Car is pre-selected (NER-25), keep default // Select a Team // Target the Teams label (not the sidebar link) and find its sibling combobox diff --git a/system-tests/cypress/support/commands.js b/system-tests/cypress/support/commands.js index f419378d93..b20407b964 100644 --- a/system-tests/cypress/support/commands.js +++ b/system-tests/cypress/support/commands.js @@ -18,6 +18,22 @@ Cypress.Commands.add('login', (username = 'Thomas Emrax', redirect = '/home') => cy.contains(username).click(); cy.get(LOGIN_ICON).click(); cy.waitForLoading(); + // Login is complete, devUserId and organizationId are now in localStorage. + // Make an authenticated request directly to the API to resolve NER-25's car ID + // (UUID changes each seed), then persist it before the redirect so + // GlobalCarFilterProvider restores it on first mount. + cy.window().then((win) => { + const devUserId = win.localStorage.getItem('devUserId'); + const organizationId = win.localStorage.getItem('organizationId'); + cy.request({ + method: 'GET', + url: `${Cypress.env('backend_url')}/cars`, + headers: { Authorization: devUserId || '', organizationId: organizationId || '' } + }).then(({ body }) => { + const ner25 = body.find((car) => car.name === 'NER-25'); + if (ner25) win.localStorage.setItem('selectedCarId', ner25.id); + }); + }); cy.visit(Cypress.env('base_url') + redirect); cy.waitForLoading(); }); diff --git a/system-tests/cypress/utils/change-request.utils.cy.js b/system-tests/cypress/utils/change-request.utils.cy.js index c96ab7850b..9653350a8a 100644 --- a/system-tests/cypress/utils/change-request.utils.cy.js +++ b/system-tests/cypress/utils/change-request.utils.cy.js @@ -32,7 +32,7 @@ const createProposedSolution = ({ }; export const createChangeRequest = ({ - wbsTitle = '0.1.0 - Impact Attenuator', + wbsTitle = '25.1.0 - Impact Attenuator', what = 'test what', type = 'ISSUE', whys = [