diff --git a/src/backend/src/controllers/cars.controllers.ts b/src/backend/src/controllers/cars.controllers.ts index aa1865355a..b50d09d4c3 100644 --- a/src/backend/src/controllers/cars.controllers.ts +++ b/src/backend/src/controllers/cars.controllers.ts @@ -22,4 +22,14 @@ export default class CarsController { next(error); } } + + static async getCurrentCar(req: Request, res: Response, next: NextFunction) { + try { + const car = await CarsService.getCurrentCar(req.organization); + + res.status(200).json(car); + } catch (error: unknown) { + next(error); + } + } } diff --git a/src/backend/src/prisma/seed.ts b/src/backend/src/prisma/seed.ts index 95f880bf30..0d3a9bd831 100644 --- a/src/backend/src/prisma/seed.ts +++ b/src/backend/src/prisma/seed.ts @@ -293,6 +293,40 @@ const performSeed: () => Promise = async () => { } }); + const car24 = 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 + } + }); + /** * Make an initial change request for car 1 using the wbs of the genesis project */ diff --git a/src/backend/src/routes/cars.routes.ts b/src/backend/src/routes/cars.routes.ts index a0e4e21c6d..5af165b126 100644 --- a/src/backend/src/routes/cars.routes.ts +++ b/src/backend/src/routes/cars.routes.ts @@ -5,6 +5,8 @@ const carsRouter = express.Router(); carsRouter.get('/', CarsController.getAllCars); +carsRouter.get('/current', CarsController.getCurrentCar); + carsRouter.post('/create', CarsController.createCar); export default carsRouter; diff --git a/src/backend/src/services/car.services.ts b/src/backend/src/services/car.services.ts index 023507c60b..78237af201 100644 --- a/src/backend/src/services/car.services.ts +++ b/src/backend/src/services/car.services.ts @@ -53,4 +53,26 @@ export default class CarsService { return carTransformer(car); } + + static async getCurrentCar(organization: Organization) { + const car = await prisma.car.findFirst({ + where: { + wbsElement: { + organizationId: organization.organizationId + } + }, + orderBy: { + wbsElement: { + carNumber: 'desc' + } + }, + ...getCarQueryArgs(organization.organizationId) + }); + + if (!car) { + return null; + } + + return carTransformer(car); + } } diff --git a/src/backend/tests/unmocked/cars.test.ts b/src/backend/tests/unmocked/cars.test.ts new file mode 100644 index 0000000000..0f9d90c689 --- /dev/null +++ b/src/backend/tests/unmocked/cars.test.ts @@ -0,0 +1,344 @@ +import { Organization, User } from '@prisma/client'; +import { createTestOrganization, createTestUser, resetUsers, createTestCar } 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 () => { + // Create test cars manually with unique car numbers + await prisma.car.create({ + data: { + wbsElement: { + create: { + carNumber: 0, + projectNumber: 0, + workPackageNumber: 0, + name: 'Car 1', + organizationId: org.organizationId, + leadId: adminUser.userId, + managerId: adminUser.userId + } + } + } + }); + + await prisma.car.create({ + data: { + wbsElement: { + create: { + carNumber: 1, + projectNumber: 0, + workPackageNumber: 0, + name: 'Car 2', + organizationId: org.organizationId, + leadId: adminUser.userId, + managerId: adminUser.userId + } + } + } + }); + + 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 prisma.car.create({ + data: { + wbsElement: { + create: { + carNumber: 0, + projectNumber: 0, + workPackageNumber: 0, + name: 'Our Car', + organizationId: org.organizationId, + leadId: adminUser.userId, + managerId: adminUser.userId + } + } + } + }); + + // 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 prisma.car.create({ + data: { + wbsElement: { + create: { + carNumber: 0, + projectNumber: 0, + workPackageNumber: 0, + name: 'Other Car', + organizationId: otherOrg.organizationId, + leadId: otherUser.userId, + managerId: otherUser.userId + } + } + } + }); + + const cars = await CarsService.getAllCars(org); + expect(cars).toHaveLength(1); + }); + }); + + describe('getCurrentCar', () => { + test('getCurrentCar returns null when no cars exist', async () => { + const currentCar = await CarsService.getCurrentCar(org); + expect(currentCar).toBeNull(); + }); + + test('getCurrentCar returns the only car when one exists', async () => { + const testCar = await createTestCar(org.organizationId, adminUser.userId); + + const currentCar = await CarsService.getCurrentCar(org); + expect(currentCar).not.toBeNull(); + expect(currentCar!.id).toBe(testCar.carId); + }); + + test('getCurrentCar returns car with highest car number', async () => { + // Create multiple cars with different car numbers + await prisma.car.create({ + data: { + wbsElement: { + create: { + carNumber: 1, + projectNumber: 0, + workPackageNumber: 0, + name: 'Car 1', + organizationId: org.organizationId, + leadId: adminUser.userId, + managerId: adminUser.userId + } + } + } + }); + + await prisma.car.create({ + data: { + wbsElement: { + create: { + carNumber: 3, + projectNumber: 0, + workPackageNumber: 0, + name: 'Car 3', + organizationId: org.organizationId, + leadId: adminUser.userId, + managerId: adminUser.userId + } + } + } + }); + + await prisma.car.create({ + data: { + wbsElement: { + create: { + carNumber: 2, + projectNumber: 0, + workPackageNumber: 0, + name: 'Car 2', + organizationId: org.organizationId, + leadId: adminUser.userId, + managerId: adminUser.userId + } + } + } + }); + + const currentCar = await CarsService.getCurrentCar(org); + expect(currentCar).not.toBeNull(); + expect(currentCar!.wbsNum.carNumber).toBe(3); + }); + + test('getCurrentCar only considers cars from specified organization', async () => { + // Create car in our org with car number 1 + await prisma.car.create({ + data: { + wbsElement: { + create: { + carNumber: 1, + projectNumber: 0, + workPackageNumber: 0, + name: 'Our Car', + organizationId: org.organizationId, + leadId: adminUser.userId, + managerId: adminUser.userId + } + } + } + }); + + // Create car in different org with higher car number + 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 prisma.car.create({ + data: { + wbsElement: { + create: { + carNumber: 5, + projectNumber: 0, + workPackageNumber: 0, + name: 'Other Car', + organizationId: otherOrg.organizationId, + leadId: otherUser.userId, + managerId: otherUser.userId + } + } + } + }); + + const currentCar = await CarsService.getCurrentCar(org); + expect(currentCar).not.toBeNull(); + expect(currentCar!.wbsNum.carNumber).toBe(1); + expect(currentCar!.name).toBe('Our Car'); + }); + }); + + 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/frontend/src/apis/cars.api.ts b/src/frontend/src/apis/cars.api.ts index b1869c606a..28efd2ab2f 100644 --- a/src/frontend/src/apis/cars.api.ts +++ b/src/frontend/src/apis/cars.api.ts @@ -7,6 +7,10 @@ export const getAllCars = async () => { return await axios.get(apiUrls.cars()); }; +export const getCurrentCar = async () => { + return await axios.get(apiUrls.carsCurrent()); +}; + export const createCar = async (payload: CreateCarPayload) => { return await axios.post(apiUrls.carsCreate(), payload); }; diff --git a/src/frontend/src/app/AppContext.tsx b/src/frontend/src/app/AppContext.tsx index 98343cfcb2..d0b63b9e05 100644 --- a/src/frontend/src/app/AppContext.tsx +++ b/src/frontend/src/app/AppContext.tsx @@ -8,6 +8,7 @@ import AppContextQuery from './AppContextQuery'; import AppContextTheme from './AppContextTheme'; import AppContextOrganization from './AppOrganizationContext'; import { HomePageProvider } from './HomePageContext'; +import { GlobalCarFilterProvider } from './AppGlobalCarFilterContext'; const AppContext: React.FC = (props) => { return ( @@ -15,7 +16,9 @@ const AppContext: React.FC = (props) => { - {props.children} + + {props.children} + diff --git a/src/frontend/src/app/AppGlobalCarFilterContext.tsx b/src/frontend/src/app/AppGlobalCarFilterContext.tsx new file mode 100644 index 0000000000..693bf8a5ca --- /dev/null +++ b/src/frontend/src/app/AppGlobalCarFilterContext.tsx @@ -0,0 +1,87 @@ +/* + * 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, ReactNode } from 'react'; +import { Car } from 'shared'; +import { useGetCurrentCar, useGetAllCars } from '../hooks/cars.hooks'; + +interface GlobalCarFilterContextType { + selectedCar: Car | null; + allCars: Car[]; + setSelectedCar: (car: Car | null) => void; + isLoading: boolean; + error: Error | null; +} + +const GlobalCarFilterContext = createContext(undefined); + +interface GlobalCarFilterProviderProps { + children: ReactNode; +} + +export const GlobalCarFilterProvider: React.FC = ({ children }) => { + const [selectedCar, setSelectedCarState] = useState(null); + const [hasBeenManuallyCleared, setHasBeenManuallyCleared] = useState(false); + + const { data: currentCar, isLoading: currentCarLoading, error: currentCarError } = useGetCurrentCar(); + const { data: allCars = [], isLoading: allCarsLoading, error: allCarsError } = useGetAllCars(); + + const isLoading = currentCarLoading || allCarsLoading; + const error = currentCarError || allCarsError; + + useEffect(() => { + if (!isLoading && allCars.length > 0 && !hasBeenManuallyCleared) { + const savedCarId = sessionStorage.getItem('selectedCarId'); + + if (savedCarId) { + const savedCar = allCars.find((car) => car.id === savedCarId); + if (savedCar) { + setSelectedCarState(savedCar); + return; + } + } + + if (currentCar) { + setSelectedCarState(currentCar); + } else if (allCars.length > 0) { + const mostRecentCar = allCars.reduce((latest, car) => + car.wbsNum.carNumber > latest.wbsNum.carNumber ? car : latest + ); + setSelectedCarState(mostRecentCar); + } + } + }, [currentCar, allCars, isLoading, hasBeenManuallyCleared]); + + const setSelectedCar = (car: Car | null) => { + if (car === null) { + setHasBeenManuallyCleared(true); + } + setSelectedCarState(car); + + if (car) { + sessionStorage.setItem('selectedCarId', car.id); + } else { + sessionStorage.removeItem('selectedCarId'); + } + }; + + const value: GlobalCarFilterContextType = { + selectedCar, + allCars, + setSelectedCar, + isLoading, + error + }; + + return {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/FinanceDashboardCarFilter.tsx b/src/frontend/src/components/FinanceDashboardCarFilter.tsx new file mode 100644 index 0000000000..097d6846bd --- /dev/null +++ b/src/frontend/src/components/FinanceDashboardCarFilter.tsx @@ -0,0 +1,154 @@ +/* + * 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, Typography, Tooltip, 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 FinanceDashboardCarFilterComponent: React.FC = ({ + filter, + sx = {}, + size = 'small', + controlSx = {} +}) => { + const { selectedCar, allCars, startDate, endDate, setSelectedCar, setStartDate, setEndDate, isLoading } = filter; + + const sortedCars = [...allCars].sort((a, b) => b.wbsNum.carNumber - a.wbsNum.carNumber); + + const carAutocompleteOptions = sortedCars.map((car) => ({ + label: car.wbsNum.carNumber === 0 ? car.name : `${car.name} (Car ${car.wbsNum.carNumber})`, + id: car.id, + carNumber: car.wbsNum.carNumber + })); + + const handleCarChange = (_event: any, newValue: any) => { + if (newValue) { + const car = allCars.find((c) => c.id === newValue.id); + setSelectedCar(car || null); + } else { + setSelectedCar(null); + } + }; + + const selectedCarOption = selectedCar ? carAutocompleteOptions.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: { minWidth: 150, ...controlSx } + }, + field: { clearable: true } + }} + onChange={(newValue: Date | null) => setStartDate(newValue ?? undefined)} + /> + + + + + End Date + + + + + (startDate ? date < startDate : false)} + slotProps={{ + textField: { + size, + sx: { minWidth: 150, ...controlSx } + }, + field: { clearable: true } + }} + onChange={(newValue: Date | null) => setEndDate(newValue ?? undefined)} + /> + + + {selectedCar && ( + + + Filtering by: {selectedCar.name} + + {startDate && endDate && ( + + {new Date(startDate).toLocaleDateString()} - {new Date(endDate).toLocaleDateString()} + + )} + + )} + + ); +}; + +export default FinanceDashboardCarFilterComponent; diff --git a/src/frontend/src/components/GlobalCarFilterDropdown.tsx b/src/frontend/src/components/GlobalCarFilterDropdown.tsx new file mode 100644 index 0000000000..069174fa29 --- /dev/null +++ b/src/frontend/src/components/GlobalCarFilterDropdown.tsx @@ -0,0 +1,150 @@ +/* + * This file is part of NER's FinishLine and licensed under GNU AGPLv3. + * See the LICENSE file in the repository root folder for details. + */ + +import React, { useState } from 'react'; +import { Box, Typography, Chip, Collapse, IconButton } from '@mui/material'; +import { ExpandMore as ExpandMoreIcon, DirectionsCar as CarIcon } from '@mui/icons-material'; +import { useGlobalCarFilter } from '../app/AppGlobalCarFilterContext'; +import LoadingIndicator from './LoadingIndicator'; + +interface GlobalCarFilterDropdownProps { + compact?: boolean; + sx?: object; +} + +const GlobalCarFilterDropdown: React.FC = ({ compact = false, sx = {} }) => { + const { selectedCar, allCars, setSelectedCar, isLoading, error } = useGlobalCarFilter(); + const [expanded, setExpanded] = useState(false); + + const handleToggle = () => { + setExpanded(!expanded); + }; + + const handleCarSelect = (car: any) => { + setSelectedCar(car); + setExpanded(false); + }; + + if (isLoading) { + return ; + } + + if (error) { + return ( + + + {error.message} + + + ); + } + + if (allCars.length === 0) { + return ( + + + No cars available + + + ); + } + + const sortedCars = [...allCars].sort((a, b) => b.wbsNum.carNumber - a.wbsNum.carNumber); + + const currentCarLabel = selectedCar ? selectedCar.name : 'Select Car'; + + if (compact) { + return ( + + + + + + Working with: + + + {currentCarLabel} + + + + + + + + + {sortedCars.map((car) => { + const carLabel = car.name; + const isSelected = selectedCar ? car.id === selectedCar.id : false; + return ( + handleCarSelect(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' + }} + /> + ); + })} + + + + ); + } + + // Non-compact mode (not used in current implementation) + return ( + + + Working with: + + + + + {currentCarLabel} + + + + ); +}; + +export default GlobalCarFilterDropdown; diff --git a/src/frontend/src/hooks/cars.hooks.ts b/src/frontend/src/hooks/cars.hooks.ts index 53b0f9c02f..053cfa1f6a 100644 --- a/src/frontend/src/hooks/cars.hooks.ts +++ b/src/frontend/src/hooks/cars.hooks.ts @@ -1,6 +1,6 @@ import { useMutation, useQuery, useQueryClient } from 'react-query'; import { Car } from 'shared'; -import { createCar, getAllCars } from '../apis/cars.api'; +import { createCar, getAllCars, getCurrentCar } from '../apis/cars.api'; export interface CreateCarPayload { name: string; @@ -16,6 +16,16 @@ export const useGetAllCars = () => { }); }; +/** + * Custom React Hook to get the current car (most recent car by car number). + */ +export const useGetCurrentCar = () => { + return useQuery(['cars', 'current'], async () => { + const { data } = await getCurrentCar(); + return data; + }); +}; + //TODO Move this logic to backend export const useGetCarsByIds = (ids: Set) => { return useQuery(['cars'], async () => { 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..8b90abf705 --- /dev/null +++ b/src/frontend/src/hooks/finance-car-filter.hooks.ts @@ -0,0 +1,100 @@ +/* + * 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 | null; + allCars: Car[]; + startDate: Date | undefined; + endDate: Date | undefined; + carNumber: number | undefined; + setSelectedCar: (car: Car | null) => 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 + * When a car is selected, it populates: + * - Start date: When the car was initialized (car.dateCreated) + * - End date: Today (if current car) or end date of that car (if previous car) + */ +export const useFinanceDashboardCarFilter = ( + initialStartDate?: Date, + initialEndDate?: Date, + initialCarNumber?: number +): FinanceDashboardCarFilter => { + const { selectedCar, allCars, setSelectedCar: setGlobalSelectedCar, isLoading, error } = useGlobalCarFilter(); + + const [startDate, setStartDate] = useState(initialStartDate); + const [endDate, setEndDate] = useState(initialEndDate); + + useEffect(() => { + if (initialCarNumber !== undefined && allCars.length > 0 && !selectedCar) { + const initialCar = allCars.find((car) => car.wbsNum.carNumber === initialCarNumber); + if (initialCar) { + setGlobalSelectedCar(initialCar); + } + } + }, [initialCarNumber, allCars, selectedCar, setGlobalSelectedCar]); + useEffect(() => { + if (selectedCar && allCars.length > 0) { + setStartDate(selectedCar.dateCreated); + + const isCurrentCar = isCarCurrent(selectedCar, allCars); + if (isCurrentCar) { + setEndDate(new Date()); + } else { + const nextCar = findNextCar(selectedCar, allCars); + if (nextCar) { + setEndDate(nextCar.dateCreated); + } else { + setEndDate(new Date()); + } + } + } + }, [selectedCar, allCars]); + + const setSelectedCar = (car: Car | null) => { + setGlobalSelectedCar(car); + }; + + return { + selectedCar, + allCars, + startDate, + endDate, + carNumber: selectedCar?.wbsNum.carNumber, + setSelectedCar, + 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/page-car-filter.hooks.ts b/src/frontend/src/hooks/page-car-filter.hooks.ts new file mode 100644 index 0000000000..0947da4068 --- /dev/null +++ b/src/frontend/src/hooks/page-car-filter.hooks.ts @@ -0,0 +1,94 @@ +/* + * 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 PageCarFilter { + /** The currently selected car for this page (can be different from global) */ + selectedCar: Car | null; + /** All available cars */ + allCars: Car[]; + /** Whether this page is using the global filter or a local override */ + usingGlobalFilter: boolean; + /** Set the car for this page only (creates local override) */ + setLocalCar: (car: Car | null) => void; + /** Reset to use the global filter */ + resetToGlobalFilter: () => void; + /** Loading and error states */ + isLoading: boolean; + error: Error | null; +} + +/** + * Hook for pages that want to support both global car filtering and page-specific overrides + * + * Behavior: + * - By default, uses the global car filter + * - When user changes filter on the page, creates a local override + * - When user navigates away and returns, reverts to global filter + * + * Usage: + * const carFilter = usePageCarFilter('gantt-page'); + */ +export const usePageCarFilter = (pageKey: string): PageCarFilter => { + const { selectedCar: globalCar, allCars, isLoading, error } = useGlobalCarFilter(); + + const [localCar, setLocalCar] = useState(null); + const [hasLocalOverride, setHasLocalOverride] = useState(false); + + // Session key for storing page-specific overrides + const sessionKey = `page-car-filter-${pageKey}`; + + // Initialize from session storage on mount + useEffect(() => { + const savedLocalCarId = sessionStorage.getItem(sessionKey); + if (savedLocalCarId && allCars.length > 0) { + const savedCar = allCars.find((car) => car.id === savedLocalCarId); + if (savedCar) { + setLocalCar(savedCar); + setHasLocalOverride(true); + } + } + }, [sessionKey, allCars]); + + // Clean up session storage when component unmounts (user navigates away) + useEffect(() => { + return () => { + sessionStorage.removeItem(sessionKey); + setHasLocalOverride(false); + setLocalCar(null); + }; + }, [sessionKey]); + + const setLocalCarHandler = (car: Car | null) => { + setLocalCar(car); + setHasLocalOverride(true); + + // Save to session storage + if (car) { + sessionStorage.setItem(sessionKey, car.id); + } else { + sessionStorage.removeItem(sessionKey); + } + }; + + const resetToGlobalFilter = () => { + setLocalCar(null); + setHasLocalOverride(false); + sessionStorage.removeItem(sessionKey); + }; + + return { + selectedCar: hasLocalOverride ? localCar : globalCar, + allCars, + usingGlobalFilter: !hasLocalOverride, + setLocalCar: setLocalCarHandler, + resetToGlobalFilter, + isLoading, + error + }; +}; 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 6fd7b11f4d..e70e776172 100644 --- a/src/frontend/src/layouts/Sidebar/Sidebar.tsx +++ b/src/frontend/src/layouts/Sidebar/Sidebar.tsx @@ -28,6 +28,7 @@ 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 GlobalCarFilterDropdown from '../../components/GlobalCarFilterDropdown'; interface SidebarProps { drawerOpen: boolean; @@ -156,6 +157,10 @@ const Sidebar = ({ drawerOpen, setDrawerOpen, moveContent, setMoveContent }: Sid handleMoveContent()}>{moveContent ? : } + + + + {linkItems.map((linkItem) => ( handleOpenSubmenu(linkItem.name)} onSubmenuCollapse={() => handleCloseSubmenu()} /> ))} + diff --git a/src/frontend/src/pages/FinancePage/FinanceDashboard/AdminFinanceDashboard.tsx b/src/frontend/src/pages/FinancePage/FinanceDashboard/AdminFinanceDashboard.tsx index 52fdce6039..151a4fa90f 100644 --- a/src/frontend/src/pages/FinancePage/FinanceDashboard/AdminFinanceDashboard.tsx +++ b/src/frontend/src/pages/FinancePage/FinanceDashboard/AdminFinanceDashboard.tsx @@ -13,15 +13,17 @@ import { useAllReimbursementRequests, useGetPendingAdvisorList } from '../../../ import { useCurrentUser } from '../../../hooks/users.hooks'; import { NERButton } from '../../../components/NERButton'; import { ArrowDropDownIcon } from '@mui/x-date-pickers/icons'; -import { ListItemIcon, Menu, MenuItem } from '@mui/material'; +import { ListItemIcon, Menu, MenuItem, Tooltip } from '@mui/material'; import PendingAdvisorModal from '../FinanceComponents/PendingAdvisorListModal'; import TotalAmountSpentModal from '../FinanceComponents/TotalAmountSpentModal'; import { DatePicker } from '@mui/x-date-pickers'; import ListAltIcon from '@mui/icons-material/ListAlt'; import WorkIcon from '@mui/icons-material/Work'; +import { HelpOutline as HelpIcon } from '@mui/icons-material'; import { isAdmin } from 'shared'; import { useGetAllCars } from '../../../hooks/cars.hooks'; import NERAutocomplete from '../../../components/NERAutocomplete'; +import { useGlobalCarFilter } from '../../../app/AppGlobalCarFilterContext'; interface AdminFinanceDashboardProps { startDate?: Date; @@ -31,6 +33,7 @@ interface AdminFinanceDashboardProps { const AdminFinanceDashboard: React.FC = ({ startDate, endDate, carNumber }) => { const user = useCurrentUser(); + const { selectedCar } = useGlobalCarFilter(); const [anchorEl, setAnchorEl] = useState(null); const [tabIndex, setTabIndex] = useState(0); @@ -61,11 +64,19 @@ const AdminFinanceDashboard: React.FC = ({ startDate const { data: allCars, isLoading: allCarsIsLoading, isError: allCarsIsError, error: allCarsError } = useGetAllCars(); + // Sync with global car filter from sidebar useEffect(() => { - if (carNumberState === undefined && allCars && allCars.length > 0) { + if (selectedCar) { + setCarNumberState(selectedCar.wbsNum.carNumber); + } + }, [selectedCar]); + + // Set default car if none selected + useEffect(() => { + if (carNumberState === undefined && allCars && allCars.length > 0 && !selectedCar) { setCarNumberState(allCars[allCars.length - 1].wbsNum.carNumber); } - }, [allCars, carNumberState]); + }, [allCars, carNumberState, selectedCar]); if (allCarsIsError) { return ; @@ -209,56 +220,81 @@ 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)} - /> + + 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)} - /> + + (startDateState ? date < startDateState : false)} + slotProps={{ + textField: { + size: 'small', + sx: datePickerStyle + }, + field: { clearable: true } + }} + onChange={(newValue: Date | null) => setEndDateState(newValue ?? undefined)} + /> + + + + } variant="contained" id="project-actions-dropdown" onClick={handleClick} + sx={{ flexShrink: 0 }} > Actions diff --git a/src/frontend/src/pages/FinancePage/FinanceDashboard/GeneralFinanceDashboard.tsx b/src/frontend/src/pages/FinancePage/FinanceDashboard/GeneralFinanceDashboard.tsx index feb70ea017..1a00d5451d 100644 --- a/src/frontend/src/pages/FinancePage/FinanceDashboard/GeneralFinanceDashboard.tsx +++ b/src/frontend/src/pages/FinancePage/FinanceDashboard/GeneralFinanceDashboard.tsx @@ -5,11 +5,10 @@ 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; @@ -19,9 +18,8 @@ interface GeneralFinanceDashboardProps { const GeneralFinanceDashboard: React.FC = ({ startDate, endDate, carNumber }) => { 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, carNumber); const { data: allTeams, @@ -30,159 +28,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 +63,13 @@ const GeneralFinanceDashboard: React.FC = ({ start if (allTeams.length === 1) { return ( - + ); @@ -214,7 +87,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 fe48f3f9b4..a168530061 100644 --- a/src/frontend/src/pages/GanttPage/ProjectGanttChart/ProjectGanttChartPage.tsx +++ b/src/frontend/src/pages/GanttPage/ProjectGanttChart/ProjectGanttChartPage.tsx @@ -55,6 +55,7 @@ import { v4 as uuidv4 } from 'uuid'; import { projectWbsPipe } from '../../../utils/pipes'; import { projectGanttTransformer } from '../../../apis/transformers/projects.transformers'; import { useCurrentUser } from '../../../hooks/users.hooks'; +import { useGlobalCarFilter } from '../../../app/AppGlobalCarFilterContext'; const getElementId = (element: WbsElementPreview | Task) => { return (element as WbsElementPreview).id ?? (element as Task).taskId; @@ -63,6 +64,7 @@ const getElementId = (element: WbsElementPreview | Task) => { const ProjectGanttChartPage: FC = () => { const history = useHistory(); const toast = useToast(); + const { selectedCar } = useGlobalCarFilter(); const { isLoading: projectsIsLoading, @@ -111,6 +113,12 @@ const ProjectGanttChartPage: FC = () => { let allProjects: ProjectGantt[] = JSON.parse(JSON.stringify(projects.concat(addedProjects))).map( projectGanttTransformer ); + + // Filter by selected car from global filter + if (selectedCar) { + allProjects = allProjects.filter((project) => project.wbsNum.carNumber === selectedCar.wbsNum.carNumber); + } + allProjects = allProjects.map((project) => { const editedProject = editedProjects.find((proj) => proj.id === project.id); return editedProject ? editedProject : project; @@ -131,7 +139,18 @@ const ProjectGanttChartPage: FC = () => { if (projects && teams) { requestRefresh(projects, teams, editedProjects, addedProjects, filters, searchText); } - }, [teams, projects, addedProjects, setAllProjects, setCollections, editedProjects, filters, searchText, history]); + }, [ + teams, + projects, + addedProjects, + setAllProjects, + setCollections, + editedProjects, + filters, + searchText, + history, + selectedCar + ]); const handleSetGanttFilters = (newFilters: GanttFilters) => { setFilters(newFilters); diff --git a/src/frontend/src/pages/ProjectsPage/ProjectsOverview.tsx b/src/frontend/src/pages/ProjectsPage/ProjectsOverview.tsx index 2539a7d3d8..9cc762df7d 100644 --- a/src/frontend/src/pages/ProjectsPage/ProjectsOverview.tsx +++ b/src/frontend/src/pages/ProjectsPage/ProjectsOverview.tsx @@ -10,12 +10,14 @@ import { useCurrentUser, useUsersFavoriteProjects } from '../../hooks/users.hook import ProjectsOverviewCards from './ProjectsOverviewCards'; import { useGetUsersLeadingProjects, useGetUsersTeamsProjects } from '../../hooks/projects.hooks'; import { WbsElementStatus } from 'shared'; +import { useGlobalCarFilter } from '../../app/AppGlobalCarFilterContext'; /** * Cards of all projects this user has favorited */ const ProjectsOverview: React.FC = () => { const user = useCurrentUser(); + const { selectedCar } = useGlobalCarFilter(); const { isLoading, data: favoriteProjects, isError, error } = useUsersFavoriteProjects(user.userId); const { @@ -48,18 +50,28 @@ const ProjectsOverview: React.FC = () => { const favoriteProjectsSet: Set = new Set(favoriteProjects.map((project) => project.id)); + const carFilteredFavorites = selectedCar + ? favoriteProjects.filter((project) => project.wbsNum.carNumber === selectedCar.wbsNum.carNumber) + : favoriteProjects; + const carFilteredTeams = selectedCar + ? teamsProjects.filter((project) => project.wbsNum.carNumber === selectedCar.wbsNum.carNumber) + : teamsProjects; + const carFilteredLeading = selectedCar + ? leadingProjects.filter((project) => project.wbsNum.carNumber === selectedCar.wbsNum.carNumber) + : leadingProjects; + // Keeps only favorite team/leading projects (even when completed) or incomplete projects - const filteredTeamsProjects = teamsProjects.filter( + const filteredTeamsProjects = carFilteredTeams.filter( (project) => project.status !== WbsElementStatus.Complete || favoriteProjectsSet.has(project.id) ); - const filteredLeadingProjects = leadingProjects.filter( + const filteredLeadingProjects = carFilteredLeading.filter( (project) => project.status !== WbsElementStatus.Complete || favoriteProjectsSet.has(project.id) ); return ( { const { isLoading, data, error } = useAllProjects(); + const { selectedCar } = useGlobalCarFilter(); + + const filteredData = + selectedCar && data ? data.filter((project) => project.wbsNum.carNumber === selectedCar.wbsNum.carNumber) : data; + if (!localStorage.getItem('projectsTableRowCount')) localStorage.setItem('projectsTableRowCount', '30'); const [pageSize, setPageSize] = useState(localStorage.getItem('projectsTableRowCount')); const [windowSize, setWindowSize] = useState(window.innerWidth); @@ -180,7 +186,7 @@ const ProjectsTable: React.FC = () => { error={error} rows={ // flatten some complex data to allow MUI to sort/filter yet preserve the original data being available to the front-end - data?.map((v) => ({ + filteredData?.map((v) => ({ ...v, carNumber: v.wbsNum.carNumber, lead: fullNamePipe(v.lead), 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/hooks/GlobalCarFilterContext.test.tsx b/src/frontend/src/tests/hooks/GlobalCarFilterContext.test.tsx new file mode 100644 index 0000000000..11fc26acfd --- /dev/null +++ b/src/frontend/src/tests/hooks/GlobalCarFilterContext.test.tsx @@ -0,0 +1,215 @@ +/* + * This file is part of NER's FinishLine and licensed under GNU AGPLv3. + * See the LICENSE file in the repository root folder for details. + */ + +import { renderHook, waitFor } from '@testing-library/react'; +import { QueryClient, QueryClientProvider } from 'react-query'; +import { GlobalCarFilterProvider, useGlobalCarFilter } from '../../app/AppGlobalCarFilterContext'; +import * as carsHooks from '../../hooks/cars.hooks'; +import { exampleAllCars, exampleCurrentCar } from '../test-support/test-data/cars.stub'; + +// Mock the hooks +vi.mock('../../hooks/cars.hooks'); +const mockUseGetCurrentCar = vi.mocked(carsHooks.useGetCurrentCar); +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(() => { + // Clear session storage + sessionStorage.clear(); + + // Reset mocks + vi.clearAllMocks(); + }); + + it('should initialize with current car when available', async () => { + mockUseGetCurrentCar.mockReturnValue({ + data: exampleCurrentCar, + isLoading: false, + error: null + } as any); + + mockUseGetAllCars.mockReturnValue({ + data: exampleAllCars, + isLoading: false, + error: null + } as any); + + const { result } = renderHook(() => useGlobalCarFilter(), { + wrapper: createWrapper() + }); + + await waitFor(() => { + expect(result.current.selectedCar).toEqual(exampleCurrentCar); + }); + + expect(result.current.allCars).toEqual(exampleAllCars); + expect(result.current.isLoading).toBe(false); + expect(result.current.error).toBeNull(); + }); + + it('should initialize with most recent car when no current car', async () => { + mockUseGetCurrentCar.mockReturnValue({ + data: null, + isLoading: false, + error: null + } as any); + + mockUseGetAllCars.mockReturnValue({ + data: exampleAllCars, + isLoading: false, + error: null + } as any); + + const { result } = renderHook(() => useGlobalCarFilter(), { + wrapper: createWrapper() + }); + + await waitFor(() => { + expect(result.current.selectedCar).toEqual(exampleAllCars[2]); // Car 2025 has highest car number + }); + }); + + it('should restore car from session storage', async () => { + // Set session storage + sessionStorage.setItem('selectedCarId', exampleAllCars[0].id); + + mockUseGetCurrentCar.mockReturnValue({ + data: exampleCurrentCar, + isLoading: false, + error: null + } as any); + + 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]); // Car 2023 + }); + }); + + it('should persist car selection to session storage', async () => { + mockUseGetCurrentCar.mockReturnValue({ + data: exampleCurrentCar, + isLoading: false, + error: null + } as any); + + mockUseGetAllCars.mockReturnValue({ + data: exampleAllCars, + isLoading: false, + error: null + } as any); + + const { result } = renderHook(() => useGlobalCarFilter(), { + wrapper: createWrapper() + }); + + await waitFor(() => { + expect(result.current.selectedCar).toBeTruthy(); + }); + + // Change selection + result.current.setSelectedCar(exampleAllCars[1]); + + expect(sessionStorage.getItem('selectedCarId')).toBe(exampleAllCars[1].id); + }); + + it('should handle loading state', () => { + mockUseGetCurrentCar.mockReturnValue({ + data: undefined, + isLoading: true, + error: null + } as any); + + mockUseGetAllCars.mockReturnValue({ + data: undefined, + isLoading: true, + error: null + } as any); + + const { result } = renderHook(() => useGlobalCarFilter(), { + wrapper: createWrapper() + }); + + expect(result.current.isLoading).toBe(true); + expect(result.current.selectedCar).toBeNull(); + }); + + it('should handle error state', () => { + const error = new Error('Failed to load cars'); + + mockUseGetCurrentCar.mockReturnValue({ + data: undefined, + isLoading: false, + error + } as any); + + mockUseGetAllCars.mockReturnValue({ + data: undefined, + isLoading: false, + error: null + } as any); + + const { result } = renderHook(() => useGlobalCarFilter(), { + wrapper: createWrapper() + }); + + expect(result.current.error).toBe(error); + expect(result.current.isLoading).toBe(false); + }); + + it('should clear session storage when setting car to null', async () => { + mockUseGetCurrentCar.mockReturnValue({ + data: exampleCurrentCar, + isLoading: false, + error: null + } as any); + + mockUseGetAllCars.mockReturnValue({ + data: exampleAllCars, + isLoading: false, + error: null + } as any); + + const { result } = renderHook(() => useGlobalCarFilter(), { + wrapper: createWrapper() + }); + + await waitFor(() => { + expect(result.current.selectedCar).toBeTruthy(); + }); + + // Clear selection + result.current.setSelectedCar(null); + + await waitFor(() => { + expect(sessionStorage.getItem('selectedCarId')).toBeNull(); + }); + expect(result.current.selectedCar).toBeNull(); + }); +}); 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/urls.ts b/src/frontend/src/utils/urls.ts index f8367bc87f..e9d40a2399 100644 --- a/src/frontend/src/utils/urls.ts +++ b/src/frontend/src/utils/urls.ts @@ -250,8 +250,8 @@ const getReimbursementRequestCategoryData = ( const getAllReimbursementRequestData = (startDate?: Date, endDate?: Date, carNumber?: number): 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 (startDate) params.set('startDate', new Date(startDate).toISOString()); + if (endDate) params.set('endDate', new Date(endDate).toISOString()); if (carNumber !== undefined) params.set('carNumber', carNumber.toString()); const queryString = params.toString(); return queryString ? `${url.toString()}?${queryString}` : url.toString(); @@ -264,8 +264,8 @@ const getReimbursementRequestTeamTypeData = ( ): 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 (startDate) params.set('startDate', new Date(startDate).toISOString()); + if (endDate) params.set('endDate', new Date(endDate).toISOString()); if (carNumber !== undefined) params.set('carNumber', carNumber.toString()); const queryString = params.toString(); return queryString ? `${url.toString()}?${queryString}` : url.toString(); @@ -273,8 +273,8 @@ const getReimbursementRequestTeamTypeData = ( const getSpendingBarTeamData = (teamId: string, startDate?: Date, endDate?: Date, carNumber?: number): 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 (startDate) params.set('startDate', new Date(startDate).toISOString()); + if (endDate) params.set('endDate', new Date(endDate).toISOString()); if (carNumber !== undefined) params.set('carNumber', carNumber.toString()); const queryString = params.toString(); return queryString ? `${url.toString()}?${queryString}` : url.toString(); @@ -282,8 +282,8 @@ const getSpendingBarTeamData = (teamId: string, startDate?: Date, endDate?: Date const getSpendingBarTeamTypeData = (teamTypeId: string, startDate?: Date, endDate?: Date, carNumber?: number): 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 (startDate) params.set('startDate', new Date(startDate).toISOString()); + if (endDate) params.set('endDate', new Date(endDate).toISOString()); if (carNumber !== undefined) params.set('carNumber', carNumber.toString()); const queryString = params.toString(); return queryString ? `${url.toString()}?${queryString}` : url.toString(); @@ -291,8 +291,8 @@ const getSpendingBarTeamTypeData = (teamTypeId: string, startDate?: Date, endDat const getSpendingBarCategoryData = (startDate?: Date, endDate?: Date, carNumber?: number): 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 (startDate) params.set('startDate', new Date(startDate).toISOString()); + if (endDate) params.set('endDate', new Date(endDate).toISOString()); if (carNumber !== undefined) params.set('carNumber', carNumber.toString()); const queryString = params.toString(); return queryString ? `${url.toString()}?${queryString}` : url.toString(); @@ -300,8 +300,8 @@ const getSpendingBarCategoryData = (startDate?: Date, endDate?: Date, carNumber? const getAllSpendingBarData = (startDate?: Date, endDate?: Date, carNumber?: number): 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 (startDate) params.set('startDate', new Date(startDate).toISOString()); + if (endDate) params.set('endDate', new Date(endDate).toISOString()); if (carNumber !== undefined) params.set('carNumber', carNumber.toString()); const queryString = params.toString(); return queryString ? `${url.toString()}?${queryString}` : url.toString(); @@ -381,6 +381,7 @@ const organizationsSetFinanceDelegates = () => `${organizationsFinanceDelegates( /******************* Car Endpoints ********************/ const cars = () => `${API_URL}/cars`; +const carsCurrent = () => `${cars()}/current`; const carsCreate = () => `${cars()}/create`; /************** Recruitment Endpoints ***************/ @@ -693,6 +694,7 @@ export const apiUrls = { organizationsSetFinanceDelegates, cars, + carsCurrent, carsCreate, recruitment,