From df63b0a40da33d80f61ed3fb03e127f40a0d328f Mon Sep 17 00:00:00 2001 From: harish Date: Wed, 15 Oct 2025 15:54:12 -0400 Subject: [PATCH 01/11] backend setup --- .../src/controllers/cars.controllers.ts | 10 + src/backend/src/routes/cars.routes.ts | 2 + src/backend/src/services/car.services.ts | 22 + .../integration/cars.integration.test.ts | 940 ++++++++++++++++++ src/backend/tests/unmocked/cars.test.ts | 207 ++++ src/frontend/src/apis/cars.api.ts | 4 + src/frontend/src/hooks/cars.hooks.ts | 12 +- .../tests/test-support/test-data/cars.stub.ts | 61 ++ src/frontend/src/utils/urls.ts | 2 + 9 files changed, 1259 insertions(+), 1 deletion(-) create mode 100644 src/backend/tests/integration/cars.integration.test.ts create mode 100644 src/backend/tests/unmocked/cars.test.ts create mode 100644 src/frontend/src/tests/test-support/test-data/cars.stub.ts 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/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/integration/cars.integration.test.ts b/src/backend/tests/integration/cars.integration.test.ts new file mode 100644 index 0000000000..d64fef3e2d --- /dev/null +++ b/src/backend/tests/integration/cars.integration.test.ts @@ -0,0 +1,940 @@ +/* + * 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 { Organization, User } from '@prisma/client'; +import { createTestOrganization, createTestUser, resetUsers, createTestCar } from '../test-utils'; +import { supermanAdmin, batmanAppAdmin } 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 Service Integration Tests', () => { + let org: Organization; + let adminUser: User; + let nonAdminUser: User; + + beforeEach(async () => { + org = await createTestOrganization(); + adminUser = await createTestUser(supermanAdmin, org.organizationId); + nonAdminUser = await createTestUser(batmanAppAdmin, org.organizationId); + }); + + afterEach(async () => { + await resetUsers(); + }); + + describe('getAllCars Integration', () => { + it('returns cars with proper transformation and relations', async () => { + // Create test cars with complex data + const car1 = await createTestCar(org.organizationId, adminUser.userId); + const car2 = await createTestCar(org.organizationId, adminUser.userId); + + const cars = await CarsService.getAllCars(org); + + expect(cars).toHaveLength(2); + expect(cars[0]).toHaveProperty('id'); + expect(cars[0]).toHaveProperty('name'); + expect(cars[0]).toHaveProperty('wbsNum'); + expect(cars[0]).toHaveProperty('dateCreated'); + expect(cars[0]).toHaveProperty('lead'); + expect(cars[0]).toHaveProperty('manager'); + }); + + it('handles database errors gracefully', async () => { + // Create a mock organization that doesn't exist + const fakeOrg = { organizationId: 'non-existent-org' } as Organization; + + const cars = await CarsService.getAllCars(fakeOrg); + expect(cars).toEqual([]); + }); + }); + + describe('getCurrentCar Integration', () => { + it('correctly identifies current car with database ordering', async () => { + // Create cars with specific ordering scenarios + await prisma.car.create({ + data: { + wbsElement: { + create: { + carNumber: 5, + projectNumber: 0, + workPackageNumber: 0, + name: 'Middle Car', + organizationId: org.organizationId, + leadId: adminUser.userId, + managerId: adminUser.userId + } + } + } + }); + + await prisma.car.create({ + data: { + wbsElement: { + create: { + carNumber: 10, + projectNumber: 0, + workPackageNumber: 0, + name: 'Latest Car', + organizationId: org.organizationId, + leadId: adminUser.userId, + managerId: adminUser.userId + } + } + } + }); + + await prisma.car.create({ + data: { + wbsElement: { + create: { + carNumber: 1, + projectNumber: 0, + workPackageNumber: 0, + name: 'Old Car', + organizationId: org.organizationId, + leadId: adminUser.userId, + managerId: adminUser.userId + } + } + } + }); + + const currentCar = await CarsService.getCurrentCar(org); + expect(currentCar).not.toBeNull(); + expect(currentCar!.wbsNum.carNumber).toBe(10); + expect(currentCar!.name).toBe('Latest Car'); + }); + + it('handles concurrent car creation scenarios', async () => { + // Simulate concurrent creation by creating multiple cars rapidly + const carPromises = Array.from({ length: 5 }, (_, index) => + CarsService.createCar(org, adminUser, `Concurrent Car ${index}`) + ); + + const createdCars = await Promise.all(carPromises); + + // Verify all cars were created with proper numbering + const carNumbers = createdCars.map(car => car.wbsNum.carNumber).sort(); + expect(carNumbers).toEqual([0, 1, 2, 3, 4]); + + // Verify getCurrentCar returns the highest numbered car + const currentCar = await CarsService.getCurrentCar(org); + expect(currentCar!.wbsNum.carNumber).toBe(4); + }); + }); + + describe('createCar Integration', () => { + it('creates car with proper database relationships', async () => { + const carName = 'Integration Test Car'; + + const createdCar = await CarsService.createCar(org, adminUser, carName); + + // Verify the car was properly created in the database + const dbCar = await prisma.car.findUnique({ + where: { carId: createdCar.id }, + include: { + wbsElement: { + include: { + lead: true, + manager: true, + organization: true + } + } + } + }); + + expect(dbCar).not.toBeNull(); + expect(dbCar!.wbsElement.name).toBe(carName); + expect(dbCar!.wbsElement.leadId).toBe(adminUser.userId); + expect(dbCar!.wbsElement.managerId).toBe(adminUser.userId); + expect(dbCar!.wbsElement.organizationId).toBe(org.organizationId); + }); + + it('maintains data integrity across transactions', async () => { + const initialCarCount = await prisma.car.count({ + where: { + wbsElement: { + organizationId: org.organizationId + } + } + }); + + try { + // This should fail due to permissions + await CarsService.createCar(org, nonAdminUser, 'Should Fail'); + } catch (error) { + expect(error).toBeInstanceOf(AccessDeniedAdminOnlyException); + } + + // Verify no car was created due to the failed transaction + const finalCarCount = await prisma.car.count({ + where: { + wbsElement: { + organizationId: org.organizationId + } + } + }); + + expect(finalCarCount).toBe(initialCarCount); + }); + + it('handles database constraints properly', async () => { + // Test with edge cases that might violate constraints + const longName = 'A'.repeat(1000); // Very long name + + // This should either succeed or fail gracefully depending on DB constraints + await expect(async () => { + await CarsService.createCar(org, adminUser, longName); + }).not.toThrow(/Unexpected error/); + }); + }); + + describe('Cross-Organization Data Isolation', () => { + it('ensures complete data isolation between organizations', async () => { + // Create cars in first org + await CarsService.createCar(org, adminUser, 'Org1 Car 1'); + await CarsService.createCar(org, adminUser, 'Org1 Car 2'); + + // Create second org with its own cars + const org2 = await createTestOrganization(); + const admin2 = await createTestUser(supermanAdmin, org2.organizationId); + await CarsService.createCar(org2, admin2, 'Org2 Car 1'); + + // Verify each org only sees its own cars + const org1Cars = await CarsService.getAllCars(org); + const org2Cars = await CarsService.getAllCars(org2); + + expect(org1Cars).toHaveLength(2); + expect(org2Cars).toHaveLength(1); + + expect(org1Cars.every(car => car.name.startsWith('Org1'))).toBe(true); + expect(org2Cars.every(car => car.name.startsWith('Org2'))).toBe(true); + + // Verify current car logic is also isolated + const org1Current = await CarsService.getCurrentCar(org); + const org2Current = await CarsService.getCurrentCar(org2); + + expect(org1Current!.name).toBe('Org1 Car 2'); + expect(org2Current!.name).toBe('Org2 Car 1'); + }); + }); + + describe('Error Handling and Edge Cases', () => { + it('handles database connection issues gracefully', async () => { + // Create a car first + await CarsService.createCar(org, adminUser, 'Test Car'); + + // This test would require mocking Prisma to simulate connection issues + // For now, we'll test that the service handles null/undefined gracefully + const result = await CarsService.getCurrentCar(org); + expect(result).toBeTruthy(); + }); + + it('maintains consistency during high-concurrency operations', async () => { + // Simulate multiple users creating cars simultaneously + const promises = Array.from({ length: 10 }, (_, i) => + CarsService.createCar(org, adminUser, `Concurrent Car ${i}`) + ); + + const results = await Promise.allSettled(promises); + const successful = results.filter(r => r.status === 'fulfilled'); + + // All cars should be created successfully + expect(successful).toHaveLength(10); + + // Verify car numbering is sequential + const cars = await CarsService.getAllCars(org); + const carNumbers = cars.map(car => car.wbsNum.carNumber).sort((a, b) => a - b); + expect(carNumbers).toEqual([0, 1, 2, 3, 4, 5, 6, 7, 8, 9]); + }); + + it('handles edge cases in car naming and validation', async () => { + // Test various edge cases + const edgeCases = [ + 'Car with special chars !@#$%^&*()', + 'Car with numbers 12345', + 'Car with spaces and tabs', + 'Very long car name that exceeds typical limits but should still work fine', + '车名中文', // Non-ASCII characters + '' // Empty string + ]; + + for (const carName of edgeCases) { + try { + const car = await CarsService.createCar(org, adminUser, carName); + expect(car.name).toBe(carName); + } catch (error) { + // Some edge cases might fail due to validation, which is acceptable + console.log(`Edge case "${carName}" failed as expected:`, error); + } + } + }); + + it('properly handles organization and user validation', async () => { + // Test with malformed organization + const invalidOrg = { organizationId: '' } as Organization; + + await expect(CarsService.getCurrentCar(invalidOrg)).rejects.toThrow(); + + // Test with non-existent organization + const nonExistentOrg = { organizationId: 'non-existent' } as Organization; + const result = await CarsService.getCurrentCar(nonExistentOrg); + expect(result).toBeNull(); + }); + + it('handles transaction rollbacks properly', async () => { + const initialCount = await prisma.car.count({ + where: { wbsElement: { organizationId: org.organizationId } } + }); + + // Attempt operation that should fail + try { + await CarsService.createCar(org, nonAdminUser, 'Should Fail'); + } catch (error) { + expect(error).toBeInstanceOf(AccessDeniedAdminOnlyException); + } + + // Verify count hasn't changed + const finalCount = await prisma.car.count({ + where: { wbsElement: { organizationId: org.organizationId } } + }); + + expect(finalCount).toBe(initialCount); + }); + + it('ensures proper cleanup and resource management', async () => { + // Create multiple cars and verify they can be properly queried + const carNames = ['Car A', 'Car B', 'Car C']; + const createdCars = []; + + for (const name of carNames) { + const car = await CarsService.createCar(org, adminUser, name); + createdCars.push(car); + } + + // Verify all cars exist + const allCars = await CarsService.getAllCars(org); + expect(allCars).toHaveLength(3); + + // Verify current car is the latest + const currentCar = await CarsService.getCurrentCar(org); + expect(currentCar!.name).toBe('Car C'); + expect(currentCar!.wbsNum.carNumber).toBe(2); // 0-indexed, so third car is #2 + }); + }); +}); + +import { Organization, User } from '@prisma/client'; +import { createTestOrganization, createTestUser, resetUsers, createTestCar } from '../test-utils'; +import { supermanAdmin, batmanAppAdmin } 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 Service Integration Tests', () => { + let org: Organization; + let adminUser: User; + let nonAdminUser: User; + + beforeEach(async () => { + org = await createTestOrganization(); + adminUser = await createTestUser(supermanAdmin, org.organizationId); + nonAdminUser = await createTestUser(batmanAppAdmin, org.organizationId); + }); + + afterEach(async () => { + await resetUsers(); + }); + + describe('getAllCars Integration', () => { + it('returns cars with proper transformation and relations', async () => { + // Create test cars with complex data + const car1 = await createTestCar(org.organizationId, adminUser.userId); + const car2 = await createTestCar(org.organizationId, adminUser.userId); + + const cars = await CarsService.getAllCars(org); + + expect(cars).toHaveLength(2); + expect(cars[0]).toHaveProperty('id'); + expect(cars[0]).toHaveProperty('name'); + expect(cars[0]).toHaveProperty('wbsNum'); + expect(cars[0]).toHaveProperty('dateCreated'); + expect(cars[0]).toHaveProperty('lead'); + expect(cars[0]).toHaveProperty('manager'); + }); + + it('handles database errors gracefully', async () => { + // Create a mock organization that doesn't exist + const fakeOrg = { organizationId: 'non-existent-org' } as Organization; + + const cars = await CarsService.getAllCars(fakeOrg); + expect(cars).toEqual([]); + }); + }); + + describe('getCurrentCar Integration', () => { + it('correctly identifies current car with database ordering', async () => { + // Create cars with specific ordering scenarios + await prisma.car.create({ + data: { + wbsElement: { + create: { + carNumber: 5, + projectNumber: 0, + workPackageNumber: 0, + name: 'Middle Car', + organizationId: org.organizationId, + leadId: adminUser.userId, + managerId: adminUser.userId + } + } + } + }); + + await prisma.car.create({ + data: { + wbsElement: { + create: { + carNumber: 10, + projectNumber: 0, + workPackageNumber: 0, + name: 'Latest Car', + organizationId: org.organizationId, + leadId: adminUser.userId, + managerId: adminUser.userId + } + } + } + }); + + await prisma.car.create({ + data: { + wbsElement: { + create: { + carNumber: 1, + projectNumber: 0, + workPackageNumber: 0, + name: 'Old Car', + organizationId: org.organizationId, + leadId: adminUser.userId, + managerId: adminUser.userId + } + } + } + }); + + const currentCar = await CarsService.getCurrentCar(org); + expect(currentCar).not.toBeNull(); + expect(currentCar!.wbsNum.carNumber).toBe(10); + expect(currentCar!.name).toBe('Latest Car'); + }); + + it('handles concurrent car creation scenarios', async () => { + // Simulate concurrent creation by creating multiple cars rapidly + const carPromises = Array.from({ length: 5 }, (_, index) => + CarsService.createCar(org, adminUser, `Concurrent Car ${index}`) + ); + + const createdCars = await Promise.all(carPromises); + + // Verify all cars were created with proper numbering + const carNumbers = createdCars.map(car => car.wbsNum.carNumber).sort(); + expect(carNumbers).toEqual([0, 1, 2, 3, 4]); + + // Verify getCurrentCar returns the highest numbered car + const currentCar = await CarsService.getCurrentCar(org); + expect(currentCar!.wbsNum.carNumber).toBe(4); + }); + }); + + describe('createCar Integration', () => { + it('creates car with proper database relationships', async () => { + const carName = 'Integration Test Car'; + + const createdCar = await CarsService.createCar(org, adminUser, carName); + + // Verify the car was properly created in the database + const dbCar = await prisma.car.findUnique({ + where: { carId: createdCar.id }, + include: { + wbsElement: { + include: { + lead: true, + manager: true, + organization: true + } + } + } + }); + + expect(dbCar).not.toBeNull(); + expect(dbCar!.wbsElement.name).toBe(carName); + expect(dbCar!.wbsElement.leadId).toBe(adminUser.userId); + expect(dbCar!.wbsElement.managerId).toBe(adminUser.userId); + expect(dbCar!.wbsElement.organizationId).toBe(org.organizationId); + }); + + it('maintains data integrity across transactions', async () => { + const initialCarCount = await prisma.car.count({ + where: { + wbsElement: { + organizationId: org.organizationId + } + } + }); + + try { + // This should fail due to permissions + await CarsService.createCar(org, nonAdminUser, 'Should Fail'); + } catch (error) { + expect(error).toBeInstanceOf(AccessDeniedAdminOnlyException); + } + + // Verify no car was created due to the failed transaction + const finalCarCount = await prisma.car.count({ + where: { + wbsElement: { + organizationId: org.organizationId + } + } + }); + + expect(finalCarCount).toBe(initialCarCount); + }); + + it('handles database constraints properly', async () => { + // Test with edge cases that might violate constraints + const longName = 'A'.repeat(1000); // Very long name + + // This should either succeed or fail gracefully depending on DB constraints + await expect(async () => { + await CarsService.createCar(org, adminUser, longName); + }).not.toThrow(/Unexpected error/); + }); + }); + + describe('Cross-Organization Data Isolation', () => { + it('ensures complete data isolation between organizations', async () => { + // Create cars in first org + await CarsService.createCar(org, adminUser, 'Org1 Car 1'); + await CarsService.createCar(org, adminUser, 'Org1 Car 2'); + + // Create second org with its own cars + const org2 = await createTestOrganization(); + const admin2 = await createTestUser(supermanAdmin, org2.organizationId); + await CarsService.createCar(org2, admin2, 'Org2 Car 1'); + + // Verify each org only sees its own cars + const org1Cars = await CarsService.getAllCars(org); + const org2Cars = await CarsService.getAllCars(org2); + + expect(org1Cars).toHaveLength(2); + expect(org2Cars).toHaveLength(1); + + expect(org1Cars.every(car => car.name.startsWith('Org1'))).toBe(true); + expect(org2Cars.every(car => car.name.startsWith('Org2'))).toBe(true); + + // Verify current car logic is also isolated + const org1Current = await CarsService.getCurrentCar(org); + const org2Current = await CarsService.getCurrentCar(org2); + + expect(org1Current!.name).toBe('Org1 Car 2'); + expect(org2Current!.name).toBe('Org2 Car 1'); + }); + }); + + describe('Error Handling and Edge Cases', () => { + it('handles database connection issues gracefully', async () => { + // Create a car first + await CarsService.createCar(org, adminUser, 'Test Car'); + + // This test would require mocking Prisma to simulate connection issues + // For now, we'll test that the service handles null/undefined gracefully + const result = await CarsService.getCurrentCar(org); + expect(result).toBeTruthy(); + }); + + it('maintains consistency during high-concurrency operations', async () => { + // Simulate multiple users creating cars simultaneously + const promises = Array.from({ length: 10 }, (_, i) => + CarsService.createCar(org, adminUser, `Concurrent Car ${i}`) + ); + + const results = await Promise.allSettled(promises); + const successful = results.filter(r => r.status === 'fulfilled'); + + // All cars should be created successfully + expect(successful).toHaveLength(10); + + // Verify car numbering is sequential + const cars = await CarsService.getAllCars(org); + const carNumbers = cars.map(car => car.wbsNum.carNumber).sort((a, b) => a - b); + expect(carNumbers).toEqual([0, 1, 2, 3, 4, 5, 6, 7, 8, 9]); + }); + + it('handles edge cases in car naming and validation', async () => { + // Test various edge cases + const edgeCases = [ + 'Car with special chars !@#$%^&*()', + 'Car with numbers 12345', + 'Car with spaces and tabs', + 'Very long car name that exceeds typical limits but should still work fine', + '车名中文', // Non-ASCII characters + '' // Empty string + ]; + + for (const carName of edgeCases) { + try { + const car = await CarsService.createCar(org, adminUser, carName); + expect(car.name).toBe(carName); + } catch (error) { + // Some edge cases might fail due to validation, which is acceptable + console.log(`Edge case "${carName}" failed as expected:`, error); + } + } + }); + + it('properly handles organization and user validation', async () => { + // Test with malformed organization + const invalidOrg = { organizationId: '' } as Organization; + + await expect(CarsService.getCurrentCar(invalidOrg)).rejects.toThrow(); + + // Test with non-existent organization + const nonExistentOrg = { organizationId: 'non-existent' } as Organization; + const result = await CarsService.getCurrentCar(nonExistentOrg); + expect(result).toBeNull(); + }); + + it('handles transaction rollbacks properly', async () => { + const initialCount = await prisma.car.count({ + where: { wbsElement: { organizationId: org.organizationId } } + }); + + // Attempt operation that should fail + try { + await CarsService.createCar(org, nonAdminUser, 'Should Fail'); + } catch (error) { + expect(error).toBeInstanceOf(AccessDeniedAdminOnlyException); + } + + // Verify count hasn't changed + const finalCount = await prisma.car.count({ + where: { wbsElement: { organizationId: org.organizationId } } + }); + + expect(finalCount).toBe(initialCount); + }); + + it('ensures proper cleanup and resource management', async () => { + // Create multiple cars and verify they can be properly queried + const carNames = ['Car A', 'Car B', 'Car C']; + const createdCars = []; + + for (const name of carNames) { + const car = await CarsService.createCar(org, adminUser, name); + createdCars.push(car); + } + + // Verify all cars exist + const allCars = await CarsService.getAllCars(org); + expect(allCars).toHaveLength(3); + + // Verify current car is the latest + const currentCar = await CarsService.getCurrentCar(org); + expect(currentCar!.name).toBe('Car C'); + expect(currentCar!.wbsNum.carNumber).toBe(2); // 0-indexed, so third car is #2 + }); + }); +}); +}); + +describe('Cars API Integration Tests', () => { + let org: Organization; + let adminUser: User; + let nonAdminUser: User; + + beforeEach(async () => { + org = await createTestOrganization(); + adminUser = await createTestUser(supermanAdmin, org.organizationId); + nonAdminUser = await createTestUser(batmanAppAdmin, org.organizationId); + }); + + afterEach(async () => { + await resetUsers(); + }); + + describe('GET /cars', () => { + it('returns empty array when no cars exist', async () => { + const response = await request(app) + .get('/cars') + .set('authorization', `Bearer ${adminUser.userId}`) + .expect(200); + + expect(response.body).toEqual([]); + }); + + it('returns all cars for organization', async () => { + // Create test cars + await createTestCar(org.organizationId, adminUser.userId); + await createTestCar(org.organizationId, adminUser.userId); + + const response = await request(app) + .get('/cars') + .set('authorization', `Bearer ${adminUser.userId}`) + .expect(200); + + expect(response.body).toHaveLength(2); + expect(response.body[0]).toHaveProperty('id'); + expect(response.body[0]).toHaveProperty('name'); + expect(response.body[0]).toHaveProperty('wbsNum'); + expect(response.body[0]).toHaveProperty('dateCreated'); + }); + + it('only returns cars for user\'s organization', async () => { + // Create car in our org + await createTestCar(org.organizationId, adminUser.userId); + + // Create car in different org + const otherOrg = await createTestOrganization(); + const otherUser = await createTestUser(supermanAdmin, otherOrg.organizationId); + await createTestCar(otherOrg.organizationId, otherUser.userId); + + const response = await request(app) + .get('/cars') + .set('authorization', `Bearer ${adminUser.userId}`) + .expect(200); + + expect(response.body).toHaveLength(1); + }); + + it('requires authentication', async () => { + await request(app) + .get('/cars') + .expect(401); + }); + }); + + describe('GET /cars/current', () => { + it('returns null when no cars exist', async () => { + const response = await request(app) + .get('/cars/current') + .set('authorization', `Bearer ${adminUser.userId}`) + .expect(200); + + expect(response.body).toBeNull(); + }); + + it('returns the only car when one exists', async () => { + const testCar = await createTestCar(org.organizationId, adminUser.userId); + + const response = await request(app) + .get('/cars/current') + .set('authorization', `Bearer ${adminUser.userId}`) + .expect(200); + + expect(response.body).not.toBeNull(); + expect(response.body.id).toBe(testCar.carId); + }); + + it('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 + } + } + } + }); + + const car3 = 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 response = await request(app) + .get('/cars/current') + .set('authorization', `Bearer ${adminUser.userId}`) + .expect(200); + + expect(response.body).not.toBeNull(); + expect(response.body.wbsNum.carNumber).toBe(3); + expect(response.body.id).toBe(car3.carId); + }); + + it('only considers cars from user\'s 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 otherOrg = await createTestOrganization(); + const otherUser = await createTestUser(supermanAdmin, 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 response = await request(app) + .get('/cars/current') + .set('authorization', `Bearer ${adminUser.userId}`) + .expect(200); + + expect(response.body).not.toBeNull(); + expect(response.body.wbsNum.carNumber).toBe(1); + expect(response.body.name).toBe('Our Car'); + }); + + it('requires authentication', async () => { + await request(app) + .get('/cars/current') + .expect(401); + }); + }); + + describe('POST /cars/create', () => { + it('successfully creates car with admin permissions', async () => { + const carData = { name: 'Test Car' }; + + const response = await request(app) + .post('/cars/create') + .set('authorization', `Bearer ${adminUser.userId}`) + .send(carData) + .expect(201); + + expect(response.body.name).toBe('Test Car'); + expect(response.body.wbsNum.carNumber).toBe(0); // First car should have car number 0 + expect(response.body.wbsNum.projectNumber).toBe(0); + expect(response.body.wbsNum.workPackageNumber).toBe(0); + }); + + it('assigns correct car number based on existing cars', async () => { + // Create first car + await request(app) + .post('/cars/create') + .set('authorization', `Bearer ${adminUser.userId}`) + .send({ name: 'Car 1' }) + .expect(201); + + // Create second car + const response = await request(app) + .post('/cars/create') + .set('authorization', `Bearer ${adminUser.userId}`) + .send({ name: 'Car 2' }) + .expect(201); + + expect(response.body.wbsNum.carNumber).toBe(1); // Should be incremented + }); + + it('denies access for non-admin user', async () => { + const carData = { name: 'Test Car' }; + + await request(app) + .post('/cars/create') + .set('authorization', `Bearer ${nonAdminUser.userId}`) + .send(carData) + .expect(400); // AccessDeniedAdminOnlyException should return 400 + }); + + it('requires car name', async () => { + await request(app) + .post('/cars/create') + .set('authorization', `Bearer ${adminUser.userId}`) + .send({}) + .expect(400); + }); + + it('requires authentication', async () => { + await request(app) + .post('/cars/create') + .send({ name: 'Test Car' }) + .expect(401); + }); + + it('car numbers are organization-specific', async () => { + // Create car in first org + const firstResponse = await request(app) + .post('/cars/create') + .set('authorization', `Bearer ${adminUser.userId}`) + .send({ name: 'First Org Car' }) + .expect(201); + + // Create different org and admin + const otherOrg = await createTestOrganization(); + const otherAdmin = await createTestUser(supermanAdmin, otherOrg.organizationId); + + // Create car in second org + const secondResponse = await request(app) + .post('/cars/create') + .set('authorization', `Bearer ${otherAdmin.userId}`) + .send({ name: 'Second Org Car' }) + .expect(201); + + // Both should start from car number 0 + expect(firstResponse.body.wbsNum.carNumber).toBe(0); + expect(secondResponse.body.wbsNum.carNumber).toBe(0); + }); + }); +}); diff --git a/src/backend/tests/unmocked/cars.test.ts b/src/backend/tests/unmocked/cars.test.ts new file mode 100644 index 0000000000..85f8d1c255 --- /dev/null +++ b/src/backend/tests/unmocked/cars.test.ts @@ -0,0 +1,207 @@ +import { Organization, User } from '@prisma/client'; +import { createTestOrganization, createTestUser, resetUsers, createTestCar } from '../test-utils'; +import { supermanAdmin, batmanAppAdmin } 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(batmanAppAdmin, 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 + await createTestCar(org.organizationId, adminUser.userId); + await createTestCar(org.organizationId, 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 createTestCar(org.organizationId, adminUser.userId); + + // Create car in different org + const otherOrg = await createTestOrganization(); + const otherUser = await createTestUser(supermanAdmin, otherOrg.organizationId); + await createTestCar(otherOrg.organizationId, 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 + } + } + } + }); + + const car2 = 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 otherOrg = await createTestOrganization(); + const otherUser = await createTestUser(supermanAdmin, 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 otherOrg = await createTestOrganization(); + const otherAdmin = await createTestUser(supermanAdmin, 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/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/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..0b39bfa80d --- /dev/null +++ b/src/frontend/src/tests/test-support/test-data/cars.stub.ts @@ -0,0 +1,61 @@ +/* + * 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 diff --git a/src/frontend/src/utils/urls.ts b/src/frontend/src/utils/urls.ts index 78f3a99beb..f27ff2100e 100644 --- a/src/frontend/src/utils/urls.ts +++ b/src/frontend/src/utils/urls.ts @@ -374,6 +374,7 @@ const organizationsSetSlackSponsorshipNotificationChannelId = () => `${organizat /******************* Car Endpoints ********************/ const cars = () => `${API_URL}/cars`; +const carsCurrent = () => `${cars()}/current`; const carsCreate = () => `${cars()}/create`; /************** Recruitment Endpoints ***************/ @@ -679,6 +680,7 @@ export const apiUrls = { organizationsSetSlackSponsorshipNotificationChannelId, cars, + carsCurrent, carsCreate, recruitment, From 3acd65fbc34efd56d6e7be7147be344868ae1a36 Mon Sep 17 00:00:00 2001 From: harish Date: Thu, 16 Oct 2025 17:25:04 -0400 Subject: [PATCH 02/11] hooks --- src/frontend/src/app/AppContext.tsx | 5 +- .../src/app/AppGlobalCarFilterContext.tsx | 83 +++++++ .../components/FinanceDashboardCarFilter.tsx | 154 +++++++++++++ .../components/GlobalCarFilterDropdown.tsx | 176 +++++++++++++++ .../src/hooks/finance-car-filter.hooks.ts | 100 ++++++++ .../src/hooks/page-car-filter.hooks.ts | 94 ++++++++ .../src/layouts/Sidebar/NavPageLink.tsx | 2 +- src/frontend/src/layouts/Sidebar/Sidebar.tsx | 6 + .../hooks/GlobalCarFilterContext.test.tsx | 213 ++++++++++++++++++ .../tests/test-support/test-data/cars.stub.ts | 5 + 10 files changed, 836 insertions(+), 2 deletions(-) create mode 100644 src/frontend/src/app/AppGlobalCarFilterContext.tsx create mode 100644 src/frontend/src/components/FinanceDashboardCarFilter.tsx create mode 100644 src/frontend/src/components/GlobalCarFilterDropdown.tsx create mode 100644 src/frontend/src/hooks/finance-car-filter.hooks.ts create mode 100644 src/frontend/src/hooks/page-car-filter.hooks.ts create mode 100644 src/frontend/src/tests/hooks/GlobalCarFilterContext.test.tsx 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..9d7c5197ea --- /dev/null +++ b/src/frontend/src/app/AppGlobalCarFilterContext.tsx @@ -0,0 +1,83 @@ +/* + * 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 { 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) { + 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]); + + const setSelectedCar = (car: Car | null) => { + 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..ea0d99346e --- /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 && ( + + {startDate.toLocaleDateString()} - {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..843d9f48da --- /dev/null +++ b/src/frontend/src/components/GlobalCarFilterDropdown.tsx @@ -0,0 +1,176 @@ +/* + * 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, Menu, MenuItem, Chip, Tooltip, Paper, useTheme } from '@mui/material'; +import { ExpandMore as ExpandMoreIcon, DirectionsCar as CarIcon, HelpOutline as HelpIcon } 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 theme = useTheme(); + const { selectedCar, allCars, setSelectedCar, isLoading, error } = useGlobalCarFilter(); + const [anchorEl, setAnchorEl] = useState(null); + + const handleClick = (event: React.MouseEvent) => { + setAnchorEl(event.currentTarget); + }; + + const handleClose = () => { + setAnchorEl(null); + }; + + const handleCarSelect = (car: any) => { + setSelectedCar(car); + handleClose(); + }; + + if (isLoading) { + return ; + } + + if (error || !selectedCar) { + return ( + + + {error?.message || 'No car selected'} + + + ); + } + + const sortedCars = [...allCars].sort((a, b) => b.wbsNum.carNumber - a.wbsNum.carNumber); + + const currentCarLabel = selectedCar.wbsNum.carNumber === 0 ? selectedCar.name : `Car ${selectedCar.wbsNum.carNumber}`; + + if (compact) { + return ( + + + } + variant="outlined" + size="small" + sx={{ + borderColor: theme.palette.primary.main, + color: theme.palette.primary.main, + '& .MuiChip-deleteIcon': { + color: theme.palette.primary.main + } + }} + /> + + {sortedCars.map((car) => ( + handleCarSelect(car)}> + + + {car.wbsNum.carNumber === 0 ? car.name : `Car ${car.wbsNum.carNumber}`} + + + {car.name} + + + + ))} + + + ); + } + + return ( + + + + + + Global Car Filter + + + {selectedCar.name} + + + + + + + + + } + color="primary" + variant="outlined" + /> + + + {sortedCars.map((car) => ( + handleCarSelect(car)} + sx={{ py: 1.5 }} + > + + + {car.wbsNum.carNumber === 0 ? car.name : `Car ${car.wbsNum.carNumber}`} + + + {car.name} + + + Created: {car.dateCreated.toLocaleDateString()} + + + + ))} + + + + ); +}; + +export default GlobalCarFilterDropdown; 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..680cc06764 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; @@ -167,12 +168,17 @@ const Sidebar = ({ drawerOpen, setDrawerOpen, moveContent, setMoveContent }: Sid {linkItems.map((linkItem) => ( handleOpenSubmenu(linkItem.name)} onSubmenuCollapse={() => handleCloseSubmenu()} /> ))} + + + + 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..cef5914714 --- /dev/null +++ b/src/frontend/src/tests/hooks/GlobalCarFilterContext.test.tsx @@ -0,0 +1,213 @@ +/* + * 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); + + 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 index 0b39bfa80d..db7d813004 100644 --- a/src/frontend/src/tests/test-support/test-data/cars.stub.ts +++ b/src/frontend/src/tests/test-support/test-data/cars.stub.ts @@ -59,3 +59,8 @@ export const exampleCar3: Car = { 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]; From 437803a40d0133c8665c146b95c6c595488f81d3 Mon Sep 17 00:00:00 2001 From: harish Date: Sat, 6 Dec 2025 16:28:47 -0500 Subject: [PATCH 03/11] #3629 frontend changes for CAR --- .../AdminFinanceDashboard.tsx | 162 ++--------------- .../GeneralFinanceDashboard.tsx | 169 +++--------------- 2 files changed, 37 insertions(+), 294 deletions(-) diff --git a/src/frontend/src/pages/FinancePage/FinanceDashboard/AdminFinanceDashboard.tsx b/src/frontend/src/pages/FinancePage/FinanceDashboard/AdminFinanceDashboard.tsx index 52fdce6039..87952a206a 100644 --- a/src/frontend/src/pages/FinancePage/FinanceDashboard/AdminFinanceDashboard.tsx +++ b/src/frontend/src/pages/FinancePage/FinanceDashboard/AdminFinanceDashboard.tsx @@ -1,4 +1,4 @@ -import React, { useEffect, useState } from 'react'; +import React, { useState } from 'react'; import { useAllTeamTypes } from '../../../hooks/team-types.hooks'; import ErrorPage from '../../ErrorPage'; import LoadingIndicator from '../../../components/LoadingIndicator'; @@ -16,12 +16,11 @@ import { ArrowDropDownIcon } from '@mui/x-date-pickers/icons'; import { ListItemIcon, Menu, MenuItem } 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 { isAdmin } from 'shared'; -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 AdminFinanceDashboardProps { startDate?: Date; @@ -36,9 +35,8 @@ const AdminFinanceDashboard: React.FC = ({ startDate 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, carNumber); const { data: allTeamTypes, @@ -59,16 +57,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,17 +80,11 @@ 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 tabs = []; tabs.push({ tabUrlValue: 'all', tabName: 'All' }); @@ -124,83 +108,13 @@ const AdminFinanceDashboard: React.FC = ({ startDate setAnchorEl(null); }; - const datePickerStyle = { - width: 150, - 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' - } - } - }; - const dateAndActionsDropdown = ( = ({ 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)} - /> - - - - - - - (startDateState ? date < startDateState : false)} - slotProps={{ - textField: { - size: 'small', - sx: datePickerStyle - }, - field: { clearable: true } - }} - onChange={(newValue: Date | null) => setEndDateState(newValue ?? undefined)} - /> - + } variant="contained" @@ -319,16 +189,16 @@ const AdminFinanceDashboard: React.FC = ({ startDate /> )} {tabIndex === 0 ? ( - + ) : tabIndex === tabs.length - 1 ? ( - + ) : ( selectedTab && ( ) )} 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 && ( )} From 936b908155c940572531f78e3e2c9e8326f78574 Mon Sep 17 00:00:00 2001 From: harish Date: Sat, 6 Dec 2025 21:40:39 -0500 Subject: [PATCH 04/11] #3629 tests pass --- .../integration/cars.integration.test.ts | 940 ------------------ src/backend/tests/unmocked/cars.test.ts | 163 ++- 2 files changed, 150 insertions(+), 953 deletions(-) delete mode 100644 src/backend/tests/integration/cars.integration.test.ts diff --git a/src/backend/tests/integration/cars.integration.test.ts b/src/backend/tests/integration/cars.integration.test.ts deleted file mode 100644 index d64fef3e2d..0000000000 --- a/src/backend/tests/integration/cars.integration.test.ts +++ /dev/null @@ -1,940 +0,0 @@ -/* - * 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 { Organization, User } from '@prisma/client'; -import { createTestOrganization, createTestUser, resetUsers, createTestCar } from '../test-utils'; -import { supermanAdmin, batmanAppAdmin } 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 Service Integration Tests', () => { - let org: Organization; - let adminUser: User; - let nonAdminUser: User; - - beforeEach(async () => { - org = await createTestOrganization(); - adminUser = await createTestUser(supermanAdmin, org.organizationId); - nonAdminUser = await createTestUser(batmanAppAdmin, org.organizationId); - }); - - afterEach(async () => { - await resetUsers(); - }); - - describe('getAllCars Integration', () => { - it('returns cars with proper transformation and relations', async () => { - // Create test cars with complex data - const car1 = await createTestCar(org.organizationId, adminUser.userId); - const car2 = await createTestCar(org.organizationId, adminUser.userId); - - const cars = await CarsService.getAllCars(org); - - expect(cars).toHaveLength(2); - expect(cars[0]).toHaveProperty('id'); - expect(cars[0]).toHaveProperty('name'); - expect(cars[0]).toHaveProperty('wbsNum'); - expect(cars[0]).toHaveProperty('dateCreated'); - expect(cars[0]).toHaveProperty('lead'); - expect(cars[0]).toHaveProperty('manager'); - }); - - it('handles database errors gracefully', async () => { - // Create a mock organization that doesn't exist - const fakeOrg = { organizationId: 'non-existent-org' } as Organization; - - const cars = await CarsService.getAllCars(fakeOrg); - expect(cars).toEqual([]); - }); - }); - - describe('getCurrentCar Integration', () => { - it('correctly identifies current car with database ordering', async () => { - // Create cars with specific ordering scenarios - await prisma.car.create({ - data: { - wbsElement: { - create: { - carNumber: 5, - projectNumber: 0, - workPackageNumber: 0, - name: 'Middle Car', - organizationId: org.organizationId, - leadId: adminUser.userId, - managerId: adminUser.userId - } - } - } - }); - - await prisma.car.create({ - data: { - wbsElement: { - create: { - carNumber: 10, - projectNumber: 0, - workPackageNumber: 0, - name: 'Latest Car', - organizationId: org.organizationId, - leadId: adminUser.userId, - managerId: adminUser.userId - } - } - } - }); - - await prisma.car.create({ - data: { - wbsElement: { - create: { - carNumber: 1, - projectNumber: 0, - workPackageNumber: 0, - name: 'Old Car', - organizationId: org.organizationId, - leadId: adminUser.userId, - managerId: adminUser.userId - } - } - } - }); - - const currentCar = await CarsService.getCurrentCar(org); - expect(currentCar).not.toBeNull(); - expect(currentCar!.wbsNum.carNumber).toBe(10); - expect(currentCar!.name).toBe('Latest Car'); - }); - - it('handles concurrent car creation scenarios', async () => { - // Simulate concurrent creation by creating multiple cars rapidly - const carPromises = Array.from({ length: 5 }, (_, index) => - CarsService.createCar(org, adminUser, `Concurrent Car ${index}`) - ); - - const createdCars = await Promise.all(carPromises); - - // Verify all cars were created with proper numbering - const carNumbers = createdCars.map(car => car.wbsNum.carNumber).sort(); - expect(carNumbers).toEqual([0, 1, 2, 3, 4]); - - // Verify getCurrentCar returns the highest numbered car - const currentCar = await CarsService.getCurrentCar(org); - expect(currentCar!.wbsNum.carNumber).toBe(4); - }); - }); - - describe('createCar Integration', () => { - it('creates car with proper database relationships', async () => { - const carName = 'Integration Test Car'; - - const createdCar = await CarsService.createCar(org, adminUser, carName); - - // Verify the car was properly created in the database - const dbCar = await prisma.car.findUnique({ - where: { carId: createdCar.id }, - include: { - wbsElement: { - include: { - lead: true, - manager: true, - organization: true - } - } - } - }); - - expect(dbCar).not.toBeNull(); - expect(dbCar!.wbsElement.name).toBe(carName); - expect(dbCar!.wbsElement.leadId).toBe(adminUser.userId); - expect(dbCar!.wbsElement.managerId).toBe(adminUser.userId); - expect(dbCar!.wbsElement.organizationId).toBe(org.organizationId); - }); - - it('maintains data integrity across transactions', async () => { - const initialCarCount = await prisma.car.count({ - where: { - wbsElement: { - organizationId: org.organizationId - } - } - }); - - try { - // This should fail due to permissions - await CarsService.createCar(org, nonAdminUser, 'Should Fail'); - } catch (error) { - expect(error).toBeInstanceOf(AccessDeniedAdminOnlyException); - } - - // Verify no car was created due to the failed transaction - const finalCarCount = await prisma.car.count({ - where: { - wbsElement: { - organizationId: org.organizationId - } - } - }); - - expect(finalCarCount).toBe(initialCarCount); - }); - - it('handles database constraints properly', async () => { - // Test with edge cases that might violate constraints - const longName = 'A'.repeat(1000); // Very long name - - // This should either succeed or fail gracefully depending on DB constraints - await expect(async () => { - await CarsService.createCar(org, adminUser, longName); - }).not.toThrow(/Unexpected error/); - }); - }); - - describe('Cross-Organization Data Isolation', () => { - it('ensures complete data isolation between organizations', async () => { - // Create cars in first org - await CarsService.createCar(org, adminUser, 'Org1 Car 1'); - await CarsService.createCar(org, adminUser, 'Org1 Car 2'); - - // Create second org with its own cars - const org2 = await createTestOrganization(); - const admin2 = await createTestUser(supermanAdmin, org2.organizationId); - await CarsService.createCar(org2, admin2, 'Org2 Car 1'); - - // Verify each org only sees its own cars - const org1Cars = await CarsService.getAllCars(org); - const org2Cars = await CarsService.getAllCars(org2); - - expect(org1Cars).toHaveLength(2); - expect(org2Cars).toHaveLength(1); - - expect(org1Cars.every(car => car.name.startsWith('Org1'))).toBe(true); - expect(org2Cars.every(car => car.name.startsWith('Org2'))).toBe(true); - - // Verify current car logic is also isolated - const org1Current = await CarsService.getCurrentCar(org); - const org2Current = await CarsService.getCurrentCar(org2); - - expect(org1Current!.name).toBe('Org1 Car 2'); - expect(org2Current!.name).toBe('Org2 Car 1'); - }); - }); - - describe('Error Handling and Edge Cases', () => { - it('handles database connection issues gracefully', async () => { - // Create a car first - await CarsService.createCar(org, adminUser, 'Test Car'); - - // This test would require mocking Prisma to simulate connection issues - // For now, we'll test that the service handles null/undefined gracefully - const result = await CarsService.getCurrentCar(org); - expect(result).toBeTruthy(); - }); - - it('maintains consistency during high-concurrency operations', async () => { - // Simulate multiple users creating cars simultaneously - const promises = Array.from({ length: 10 }, (_, i) => - CarsService.createCar(org, adminUser, `Concurrent Car ${i}`) - ); - - const results = await Promise.allSettled(promises); - const successful = results.filter(r => r.status === 'fulfilled'); - - // All cars should be created successfully - expect(successful).toHaveLength(10); - - // Verify car numbering is sequential - const cars = await CarsService.getAllCars(org); - const carNumbers = cars.map(car => car.wbsNum.carNumber).sort((a, b) => a - b); - expect(carNumbers).toEqual([0, 1, 2, 3, 4, 5, 6, 7, 8, 9]); - }); - - it('handles edge cases in car naming and validation', async () => { - // Test various edge cases - const edgeCases = [ - 'Car with special chars !@#$%^&*()', - 'Car with numbers 12345', - 'Car with spaces and tabs', - 'Very long car name that exceeds typical limits but should still work fine', - '车名中文', // Non-ASCII characters - '' // Empty string - ]; - - for (const carName of edgeCases) { - try { - const car = await CarsService.createCar(org, adminUser, carName); - expect(car.name).toBe(carName); - } catch (error) { - // Some edge cases might fail due to validation, which is acceptable - console.log(`Edge case "${carName}" failed as expected:`, error); - } - } - }); - - it('properly handles organization and user validation', async () => { - // Test with malformed organization - const invalidOrg = { organizationId: '' } as Organization; - - await expect(CarsService.getCurrentCar(invalidOrg)).rejects.toThrow(); - - // Test with non-existent organization - const nonExistentOrg = { organizationId: 'non-existent' } as Organization; - const result = await CarsService.getCurrentCar(nonExistentOrg); - expect(result).toBeNull(); - }); - - it('handles transaction rollbacks properly', async () => { - const initialCount = await prisma.car.count({ - where: { wbsElement: { organizationId: org.organizationId } } - }); - - // Attempt operation that should fail - try { - await CarsService.createCar(org, nonAdminUser, 'Should Fail'); - } catch (error) { - expect(error).toBeInstanceOf(AccessDeniedAdminOnlyException); - } - - // Verify count hasn't changed - const finalCount = await prisma.car.count({ - where: { wbsElement: { organizationId: org.organizationId } } - }); - - expect(finalCount).toBe(initialCount); - }); - - it('ensures proper cleanup and resource management', async () => { - // Create multiple cars and verify they can be properly queried - const carNames = ['Car A', 'Car B', 'Car C']; - const createdCars = []; - - for (const name of carNames) { - const car = await CarsService.createCar(org, adminUser, name); - createdCars.push(car); - } - - // Verify all cars exist - const allCars = await CarsService.getAllCars(org); - expect(allCars).toHaveLength(3); - - // Verify current car is the latest - const currentCar = await CarsService.getCurrentCar(org); - expect(currentCar!.name).toBe('Car C'); - expect(currentCar!.wbsNum.carNumber).toBe(2); // 0-indexed, so third car is #2 - }); - }); -}); - -import { Organization, User } from '@prisma/client'; -import { createTestOrganization, createTestUser, resetUsers, createTestCar } from '../test-utils'; -import { supermanAdmin, batmanAppAdmin } 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 Service Integration Tests', () => { - let org: Organization; - let adminUser: User; - let nonAdminUser: User; - - beforeEach(async () => { - org = await createTestOrganization(); - adminUser = await createTestUser(supermanAdmin, org.organizationId); - nonAdminUser = await createTestUser(batmanAppAdmin, org.organizationId); - }); - - afterEach(async () => { - await resetUsers(); - }); - - describe('getAllCars Integration', () => { - it('returns cars with proper transformation and relations', async () => { - // Create test cars with complex data - const car1 = await createTestCar(org.organizationId, adminUser.userId); - const car2 = await createTestCar(org.organizationId, adminUser.userId); - - const cars = await CarsService.getAllCars(org); - - expect(cars).toHaveLength(2); - expect(cars[0]).toHaveProperty('id'); - expect(cars[0]).toHaveProperty('name'); - expect(cars[0]).toHaveProperty('wbsNum'); - expect(cars[0]).toHaveProperty('dateCreated'); - expect(cars[0]).toHaveProperty('lead'); - expect(cars[0]).toHaveProperty('manager'); - }); - - it('handles database errors gracefully', async () => { - // Create a mock organization that doesn't exist - const fakeOrg = { organizationId: 'non-existent-org' } as Organization; - - const cars = await CarsService.getAllCars(fakeOrg); - expect(cars).toEqual([]); - }); - }); - - describe('getCurrentCar Integration', () => { - it('correctly identifies current car with database ordering', async () => { - // Create cars with specific ordering scenarios - await prisma.car.create({ - data: { - wbsElement: { - create: { - carNumber: 5, - projectNumber: 0, - workPackageNumber: 0, - name: 'Middle Car', - organizationId: org.organizationId, - leadId: adminUser.userId, - managerId: adminUser.userId - } - } - } - }); - - await prisma.car.create({ - data: { - wbsElement: { - create: { - carNumber: 10, - projectNumber: 0, - workPackageNumber: 0, - name: 'Latest Car', - organizationId: org.organizationId, - leadId: adminUser.userId, - managerId: adminUser.userId - } - } - } - }); - - await prisma.car.create({ - data: { - wbsElement: { - create: { - carNumber: 1, - projectNumber: 0, - workPackageNumber: 0, - name: 'Old Car', - organizationId: org.organizationId, - leadId: adminUser.userId, - managerId: adminUser.userId - } - } - } - }); - - const currentCar = await CarsService.getCurrentCar(org); - expect(currentCar).not.toBeNull(); - expect(currentCar!.wbsNum.carNumber).toBe(10); - expect(currentCar!.name).toBe('Latest Car'); - }); - - it('handles concurrent car creation scenarios', async () => { - // Simulate concurrent creation by creating multiple cars rapidly - const carPromises = Array.from({ length: 5 }, (_, index) => - CarsService.createCar(org, adminUser, `Concurrent Car ${index}`) - ); - - const createdCars = await Promise.all(carPromises); - - // Verify all cars were created with proper numbering - const carNumbers = createdCars.map(car => car.wbsNum.carNumber).sort(); - expect(carNumbers).toEqual([0, 1, 2, 3, 4]); - - // Verify getCurrentCar returns the highest numbered car - const currentCar = await CarsService.getCurrentCar(org); - expect(currentCar!.wbsNum.carNumber).toBe(4); - }); - }); - - describe('createCar Integration', () => { - it('creates car with proper database relationships', async () => { - const carName = 'Integration Test Car'; - - const createdCar = await CarsService.createCar(org, adminUser, carName); - - // Verify the car was properly created in the database - const dbCar = await prisma.car.findUnique({ - where: { carId: createdCar.id }, - include: { - wbsElement: { - include: { - lead: true, - manager: true, - organization: true - } - } - } - }); - - expect(dbCar).not.toBeNull(); - expect(dbCar!.wbsElement.name).toBe(carName); - expect(dbCar!.wbsElement.leadId).toBe(adminUser.userId); - expect(dbCar!.wbsElement.managerId).toBe(adminUser.userId); - expect(dbCar!.wbsElement.organizationId).toBe(org.organizationId); - }); - - it('maintains data integrity across transactions', async () => { - const initialCarCount = await prisma.car.count({ - where: { - wbsElement: { - organizationId: org.organizationId - } - } - }); - - try { - // This should fail due to permissions - await CarsService.createCar(org, nonAdminUser, 'Should Fail'); - } catch (error) { - expect(error).toBeInstanceOf(AccessDeniedAdminOnlyException); - } - - // Verify no car was created due to the failed transaction - const finalCarCount = await prisma.car.count({ - where: { - wbsElement: { - organizationId: org.organizationId - } - } - }); - - expect(finalCarCount).toBe(initialCarCount); - }); - - it('handles database constraints properly', async () => { - // Test with edge cases that might violate constraints - const longName = 'A'.repeat(1000); // Very long name - - // This should either succeed or fail gracefully depending on DB constraints - await expect(async () => { - await CarsService.createCar(org, adminUser, longName); - }).not.toThrow(/Unexpected error/); - }); - }); - - describe('Cross-Organization Data Isolation', () => { - it('ensures complete data isolation between organizations', async () => { - // Create cars in first org - await CarsService.createCar(org, adminUser, 'Org1 Car 1'); - await CarsService.createCar(org, adminUser, 'Org1 Car 2'); - - // Create second org with its own cars - const org2 = await createTestOrganization(); - const admin2 = await createTestUser(supermanAdmin, org2.organizationId); - await CarsService.createCar(org2, admin2, 'Org2 Car 1'); - - // Verify each org only sees its own cars - const org1Cars = await CarsService.getAllCars(org); - const org2Cars = await CarsService.getAllCars(org2); - - expect(org1Cars).toHaveLength(2); - expect(org2Cars).toHaveLength(1); - - expect(org1Cars.every(car => car.name.startsWith('Org1'))).toBe(true); - expect(org2Cars.every(car => car.name.startsWith('Org2'))).toBe(true); - - // Verify current car logic is also isolated - const org1Current = await CarsService.getCurrentCar(org); - const org2Current = await CarsService.getCurrentCar(org2); - - expect(org1Current!.name).toBe('Org1 Car 2'); - expect(org2Current!.name).toBe('Org2 Car 1'); - }); - }); - - describe('Error Handling and Edge Cases', () => { - it('handles database connection issues gracefully', async () => { - // Create a car first - await CarsService.createCar(org, adminUser, 'Test Car'); - - // This test would require mocking Prisma to simulate connection issues - // For now, we'll test that the service handles null/undefined gracefully - const result = await CarsService.getCurrentCar(org); - expect(result).toBeTruthy(); - }); - - it('maintains consistency during high-concurrency operations', async () => { - // Simulate multiple users creating cars simultaneously - const promises = Array.from({ length: 10 }, (_, i) => - CarsService.createCar(org, adminUser, `Concurrent Car ${i}`) - ); - - const results = await Promise.allSettled(promises); - const successful = results.filter(r => r.status === 'fulfilled'); - - // All cars should be created successfully - expect(successful).toHaveLength(10); - - // Verify car numbering is sequential - const cars = await CarsService.getAllCars(org); - const carNumbers = cars.map(car => car.wbsNum.carNumber).sort((a, b) => a - b); - expect(carNumbers).toEqual([0, 1, 2, 3, 4, 5, 6, 7, 8, 9]); - }); - - it('handles edge cases in car naming and validation', async () => { - // Test various edge cases - const edgeCases = [ - 'Car with special chars !@#$%^&*()', - 'Car with numbers 12345', - 'Car with spaces and tabs', - 'Very long car name that exceeds typical limits but should still work fine', - '车名中文', // Non-ASCII characters - '' // Empty string - ]; - - for (const carName of edgeCases) { - try { - const car = await CarsService.createCar(org, adminUser, carName); - expect(car.name).toBe(carName); - } catch (error) { - // Some edge cases might fail due to validation, which is acceptable - console.log(`Edge case "${carName}" failed as expected:`, error); - } - } - }); - - it('properly handles organization and user validation', async () => { - // Test with malformed organization - const invalidOrg = { organizationId: '' } as Organization; - - await expect(CarsService.getCurrentCar(invalidOrg)).rejects.toThrow(); - - // Test with non-existent organization - const nonExistentOrg = { organizationId: 'non-existent' } as Organization; - const result = await CarsService.getCurrentCar(nonExistentOrg); - expect(result).toBeNull(); - }); - - it('handles transaction rollbacks properly', async () => { - const initialCount = await prisma.car.count({ - where: { wbsElement: { organizationId: org.organizationId } } - }); - - // Attempt operation that should fail - try { - await CarsService.createCar(org, nonAdminUser, 'Should Fail'); - } catch (error) { - expect(error).toBeInstanceOf(AccessDeniedAdminOnlyException); - } - - // Verify count hasn't changed - const finalCount = await prisma.car.count({ - where: { wbsElement: { organizationId: org.organizationId } } - }); - - expect(finalCount).toBe(initialCount); - }); - - it('ensures proper cleanup and resource management', async () => { - // Create multiple cars and verify they can be properly queried - const carNames = ['Car A', 'Car B', 'Car C']; - const createdCars = []; - - for (const name of carNames) { - const car = await CarsService.createCar(org, adminUser, name); - createdCars.push(car); - } - - // Verify all cars exist - const allCars = await CarsService.getAllCars(org); - expect(allCars).toHaveLength(3); - - // Verify current car is the latest - const currentCar = await CarsService.getCurrentCar(org); - expect(currentCar!.name).toBe('Car C'); - expect(currentCar!.wbsNum.carNumber).toBe(2); // 0-indexed, so third car is #2 - }); - }); -}); -}); - -describe('Cars API Integration Tests', () => { - let org: Organization; - let adminUser: User; - let nonAdminUser: User; - - beforeEach(async () => { - org = await createTestOrganization(); - adminUser = await createTestUser(supermanAdmin, org.organizationId); - nonAdminUser = await createTestUser(batmanAppAdmin, org.organizationId); - }); - - afterEach(async () => { - await resetUsers(); - }); - - describe('GET /cars', () => { - it('returns empty array when no cars exist', async () => { - const response = await request(app) - .get('/cars') - .set('authorization', `Bearer ${adminUser.userId}`) - .expect(200); - - expect(response.body).toEqual([]); - }); - - it('returns all cars for organization', async () => { - // Create test cars - await createTestCar(org.organizationId, adminUser.userId); - await createTestCar(org.organizationId, adminUser.userId); - - const response = await request(app) - .get('/cars') - .set('authorization', `Bearer ${adminUser.userId}`) - .expect(200); - - expect(response.body).toHaveLength(2); - expect(response.body[0]).toHaveProperty('id'); - expect(response.body[0]).toHaveProperty('name'); - expect(response.body[0]).toHaveProperty('wbsNum'); - expect(response.body[0]).toHaveProperty('dateCreated'); - }); - - it('only returns cars for user\'s organization', async () => { - // Create car in our org - await createTestCar(org.organizationId, adminUser.userId); - - // Create car in different org - const otherOrg = await createTestOrganization(); - const otherUser = await createTestUser(supermanAdmin, otherOrg.organizationId); - await createTestCar(otherOrg.organizationId, otherUser.userId); - - const response = await request(app) - .get('/cars') - .set('authorization', `Bearer ${adminUser.userId}`) - .expect(200); - - expect(response.body).toHaveLength(1); - }); - - it('requires authentication', async () => { - await request(app) - .get('/cars') - .expect(401); - }); - }); - - describe('GET /cars/current', () => { - it('returns null when no cars exist', async () => { - const response = await request(app) - .get('/cars/current') - .set('authorization', `Bearer ${adminUser.userId}`) - .expect(200); - - expect(response.body).toBeNull(); - }); - - it('returns the only car when one exists', async () => { - const testCar = await createTestCar(org.organizationId, adminUser.userId); - - const response = await request(app) - .get('/cars/current') - .set('authorization', `Bearer ${adminUser.userId}`) - .expect(200); - - expect(response.body).not.toBeNull(); - expect(response.body.id).toBe(testCar.carId); - }); - - it('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 - } - } - } - }); - - const car3 = 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 response = await request(app) - .get('/cars/current') - .set('authorization', `Bearer ${adminUser.userId}`) - .expect(200); - - expect(response.body).not.toBeNull(); - expect(response.body.wbsNum.carNumber).toBe(3); - expect(response.body.id).toBe(car3.carId); - }); - - it('only considers cars from user\'s 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 otherOrg = await createTestOrganization(); - const otherUser = await createTestUser(supermanAdmin, 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 response = await request(app) - .get('/cars/current') - .set('authorization', `Bearer ${adminUser.userId}`) - .expect(200); - - expect(response.body).not.toBeNull(); - expect(response.body.wbsNum.carNumber).toBe(1); - expect(response.body.name).toBe('Our Car'); - }); - - it('requires authentication', async () => { - await request(app) - .get('/cars/current') - .expect(401); - }); - }); - - describe('POST /cars/create', () => { - it('successfully creates car with admin permissions', async () => { - const carData = { name: 'Test Car' }; - - const response = await request(app) - .post('/cars/create') - .set('authorization', `Bearer ${adminUser.userId}`) - .send(carData) - .expect(201); - - expect(response.body.name).toBe('Test Car'); - expect(response.body.wbsNum.carNumber).toBe(0); // First car should have car number 0 - expect(response.body.wbsNum.projectNumber).toBe(0); - expect(response.body.wbsNum.workPackageNumber).toBe(0); - }); - - it('assigns correct car number based on existing cars', async () => { - // Create first car - await request(app) - .post('/cars/create') - .set('authorization', `Bearer ${adminUser.userId}`) - .send({ name: 'Car 1' }) - .expect(201); - - // Create second car - const response = await request(app) - .post('/cars/create') - .set('authorization', `Bearer ${adminUser.userId}`) - .send({ name: 'Car 2' }) - .expect(201); - - expect(response.body.wbsNum.carNumber).toBe(1); // Should be incremented - }); - - it('denies access for non-admin user', async () => { - const carData = { name: 'Test Car' }; - - await request(app) - .post('/cars/create') - .set('authorization', `Bearer ${nonAdminUser.userId}`) - .send(carData) - .expect(400); // AccessDeniedAdminOnlyException should return 400 - }); - - it('requires car name', async () => { - await request(app) - .post('/cars/create') - .set('authorization', `Bearer ${adminUser.userId}`) - .send({}) - .expect(400); - }); - - it('requires authentication', async () => { - await request(app) - .post('/cars/create') - .send({ name: 'Test Car' }) - .expect(401); - }); - - it('car numbers are organization-specific', async () => { - // Create car in first org - const firstResponse = await request(app) - .post('/cars/create') - .set('authorization', `Bearer ${adminUser.userId}`) - .send({ name: 'First Org Car' }) - .expect(201); - - // Create different org and admin - const otherOrg = await createTestOrganization(); - const otherAdmin = await createTestUser(supermanAdmin, otherOrg.organizationId); - - // Create car in second org - const secondResponse = await request(app) - .post('/cars/create') - .set('authorization', `Bearer ${otherAdmin.userId}`) - .send({ name: 'Second Org Car' }) - .expect(201); - - // Both should start from car number 0 - expect(firstResponse.body.wbsNum.carNumber).toBe(0); - expect(secondResponse.body.wbsNum.carNumber).toBe(0); - }); - }); -}); diff --git a/src/backend/tests/unmocked/cars.test.ts b/src/backend/tests/unmocked/cars.test.ts index 85f8d1c255..20e9607840 100644 --- a/src/backend/tests/unmocked/cars.test.ts +++ b/src/backend/tests/unmocked/cars.test.ts @@ -1,6 +1,6 @@ import { Organization, User } from '@prisma/client'; import { createTestOrganization, createTestUser, resetUsers, createTestCar } from '../test-utils'; -import { supermanAdmin, batmanAppAdmin } from '../test-data/users.test-data'; +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'; @@ -13,7 +13,7 @@ describe('Cars Tests', () => { beforeEach(async () => { org = await createTestOrganization(); adminUser = await createTestUser(supermanAdmin, org.organizationId); - nonAdminUser = await createTestUser(batmanAppAdmin, org.organizationId); + nonAdminUser = await createTestUser(member, org.organizationId); }); afterEach(async () => { @@ -27,9 +27,38 @@ describe('Cars Tests', () => { }); test('getAllCars returns all cars for organization', async () => { - // Create test cars - await createTestCar(org.organizationId, adminUser.userId); - await createTestCar(org.organizationId, adminUser.userId); + // 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); @@ -37,12 +66,67 @@ describe('Cars Tests', () => { test('getAllCars only returns cars for specified organization', async () => { // Create car in our org - await createTestCar(org.organizationId, adminUser.userId); + 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 otherOrg = await createTestOrganization(); - const otherUser = await createTestUser(supermanAdmin, otherOrg.organizationId); - await createTestCar(otherOrg.organizationId, otherUser.userId); + 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); @@ -137,8 +221,35 @@ describe('Cars Tests', () => { }); // Create car in different org with higher car number - const otherOrg = await createTestOrganization(); - const otherUser = await createTestUser(supermanAdmin, otherOrg.organizationId); + 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: { @@ -193,8 +304,34 @@ describe('Cars Tests', () => { const firstCar = await CarsService.createCar(org, adminUser, 'First Org Car'); // Create different org and admin - const otherOrg = await createTestOrganization(); - const otherAdmin = await createTestUser(supermanAdmin, otherOrg.organizationId); + 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'); From c098546d5767f3747763d1a024c52aab8e10ede3 Mon Sep 17 00:00:00 2001 From: harish Date: Sat, 6 Dec 2025 21:51:04 -0500 Subject: [PATCH 05/11] #3811 fixing frontend tests --- src/frontend/src/app/AppGlobalCarFilterContext.tsx | 8 ++++++-- src/frontend/src/tests/app/AppContext.test.tsx | 9 +++++++++ .../src/tests/hooks/GlobalCarFilterContext.test.tsx | 6 ++++-- 3 files changed, 19 insertions(+), 4 deletions(-) diff --git a/src/frontend/src/app/AppGlobalCarFilterContext.tsx b/src/frontend/src/app/AppGlobalCarFilterContext.tsx index 9d7c5197ea..693bf8a5ca 100644 --- a/src/frontend/src/app/AppGlobalCarFilterContext.tsx +++ b/src/frontend/src/app/AppGlobalCarFilterContext.tsx @@ -23,6 +23,7 @@ interface GlobalCarFilterProviderProps { 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(); @@ -31,7 +32,7 @@ export const GlobalCarFilterProvider: React.FC = ( const error = currentCarError || allCarsError; useEffect(() => { - if (!isLoading && allCars.length > 0) { + if (!isLoading && allCars.length > 0 && !hasBeenManuallyCleared) { const savedCarId = sessionStorage.getItem('selectedCarId'); if (savedCarId) { @@ -51,9 +52,12 @@ export const GlobalCarFilterProvider: React.FC = ( setSelectedCarState(mostRecentCar); } } - }, [currentCar, allCars, isLoading]); + }, [currentCar, allCars, isLoading, hasBeenManuallyCleared]); const setSelectedCar = (car: Car | null) => { + if (car === null) { + setHasBeenManuallyCleared(true); + } setSelectedCarState(car); if (car) { 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 index cef5914714..d53a8ba33c 100644 --- a/src/frontend/src/tests/hooks/GlobalCarFilterContext.test.tsx +++ b/src/frontend/src/tests/hooks/GlobalCarFilterContext.test.tsx @@ -207,7 +207,9 @@ describe('useGlobalCarFilter', () => { // Clear selection result.current.setSelectedCar(null); - expect(sessionStorage.getItem('selectedCarId')).toBeNull(); - expect(result.current.selectedCar).toBeNull(); + await waitFor(() => { + expect(sessionStorage.getItem('selectedCarId')).toBeNull(); + expect(result.current.selectedCar).toBeNull(); + }); }); }); From d2c8cffb08142b66d16d9facd0b57fe2ee6d186e Mon Sep 17 00:00:00 2001 From: harish Date: Sat, 6 Dec 2025 22:21:05 -0500 Subject: [PATCH 06/11] #3629 fixing sidebar --- .../components/FinanceDashboardCarFilter.tsx | 2 +- .../components/GlobalCarFilterDropdown.tsx | 66 +++++++++++++------ src/frontend/src/layouts/Sidebar/Sidebar.tsx | 7 +- .../AdminFinanceDashboard.tsx | 3 + src/frontend/src/utils/urls.ts | 24 +++---- 5 files changed, 65 insertions(+), 37 deletions(-) diff --git a/src/frontend/src/components/FinanceDashboardCarFilter.tsx b/src/frontend/src/components/FinanceDashboardCarFilter.tsx index ea0d99346e..097d6846bd 100644 --- a/src/frontend/src/components/FinanceDashboardCarFilter.tsx +++ b/src/frontend/src/components/FinanceDashboardCarFilter.tsx @@ -142,7 +142,7 @@ const FinanceDashboardCarFilterComponent: React.FC {startDate && endDate && ( - {startDate.toLocaleDateString()} - {endDate.toLocaleDateString()} + {new Date(startDate).toLocaleDateString()} - {new Date(endDate).toLocaleDateString()} )}
diff --git a/src/frontend/src/components/GlobalCarFilterDropdown.tsx b/src/frontend/src/components/GlobalCarFilterDropdown.tsx index 843d9f48da..6dd1300950 100644 --- a/src/frontend/src/components/GlobalCarFilterDropdown.tsx +++ b/src/frontend/src/components/GlobalCarFilterDropdown.tsx @@ -36,11 +36,21 @@ const GlobalCarFilterDropdown: React.FC = ({ compa return ; } - if (error || !selectedCar) { + if (error) { return ( - {error?.message || 'No car selected'} + {error.message} + + + ); + } + + if (allCars.length === 0) { + return ( + + + No cars available ); @@ -48,27 +58,37 @@ const GlobalCarFilterDropdown: React.FC = ({ compa const sortedCars = [...allCars].sort((a, b) => b.wbsNum.carNumber - a.wbsNum.carNumber); - const currentCarLabel = selectedCar.wbsNum.carNumber === 0 ? selectedCar.name : `Car ${selectedCar.wbsNum.carNumber}`; + const currentCarLabel = selectedCar + ? selectedCar.wbsNum.carNumber === 0 + ? selectedCar.name + : `Car ${selectedCar.wbsNum.carNumber}` + : 'Select Car'; if (compact) { return ( - - - } - variant="outlined" - size="small" - sx={{ - borderColor: theme.palette.primary.main, - color: theme.palette.primary.main, - '& .MuiChip-deleteIcon': { - color: theme.palette.primary.main - } - }} - /> + + + Working with: + + + + } + variant="outlined" + size="small" + sx={{ + borderColor: 'white', + color: 'white', + '& .MuiChip-deleteIcon': { + color: 'white' + }, + flex: 1 + }} + /> + = ({ compa }} > {sortedCars.map((car) => ( - handleCarSelect(car)}> + handleCarSelect(car)} + > {car.wbsNum.carNumber === 0 ? car.name : `Car ${car.wbsNum.carNumber}`} diff --git a/src/frontend/src/layouts/Sidebar/Sidebar.tsx b/src/frontend/src/layouts/Sidebar/Sidebar.tsx index 680cc06764..e70e776172 100644 --- a/src/frontend/src/layouts/Sidebar/Sidebar.tsx +++ b/src/frontend/src/layouts/Sidebar/Sidebar.tsx @@ -157,6 +157,10 @@ const Sidebar = ({ drawerOpen, setDrawerOpen, moveContent, setMoveContent }: Sid handleMoveContent()}>{moveContent ? : } + + + + ))} - - - diff --git a/src/frontend/src/pages/FinancePage/FinanceDashboard/AdminFinanceDashboard.tsx b/src/frontend/src/pages/FinancePage/FinanceDashboard/AdminFinanceDashboard.tsx index 87952a206a..cc6624b621 100644 --- a/src/frontend/src/pages/FinancePage/FinanceDashboard/AdminFinanceDashboard.tsx +++ b/src/frontend/src/pages/FinancePage/FinanceDashboard/AdminFinanceDashboard.tsx @@ -129,6 +129,9 @@ const AdminFinanceDashboard: React.FC = ({ startDate variant="contained" id="project-actions-dropdown" onClick={handleClick} + sx={{ + color: 'white' + }} > Actions diff --git a/src/frontend/src/utils/urls.ts b/src/frontend/src/utils/urls.ts index f27ff2100e..8ca8239e78 100644 --- a/src/frontend/src/utils/urls.ts +++ b/src/frontend/src/utils/urls.ts @@ -245,8 +245,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(); @@ -259,8 +259,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(); @@ -268,8 +268,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(); @@ -277,8 +277,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(); @@ -286,8 +286,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(); @@ -295,8 +295,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(); From 9da7ea59ad1dffc3ef2715867aa3b86f5d28f78a Mon Sep 17 00:00:00 2001 From: harish Date: Sat, 6 Dec 2025 22:47:29 -0500 Subject: [PATCH 07/11] #3629 fixing finance dashboard --- .../AdminFinanceDashboard.tsx | 193 ++++++++++++++++-- 1 file changed, 173 insertions(+), 20 deletions(-) diff --git a/src/frontend/src/pages/FinancePage/FinanceDashboard/AdminFinanceDashboard.tsx b/src/frontend/src/pages/FinancePage/FinanceDashboard/AdminFinanceDashboard.tsx index cc6624b621..6b7e065713 100644 --- a/src/frontend/src/pages/FinancePage/FinanceDashboard/AdminFinanceDashboard.tsx +++ b/src/frontend/src/pages/FinancePage/FinanceDashboard/AdminFinanceDashboard.tsx @@ -1,4 +1,4 @@ -import React, { useState } from 'react'; +import React, { useEffect, useState } from 'react'; import { useAllTeamTypes } from '../../../hooks/team-types.hooks'; import ErrorPage from '../../ErrorPage'; import LoadingIndicator from '../../../components/LoadingIndicator'; @@ -13,14 +13,16 @@ 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 FinanceDashboardCarFilter from '../../../components/FinanceDashboardCarFilter'; -import { useFinanceDashboardCarFilter } from '../../../hooks/finance-car-filter.hooks'; +import { useGetAllCars } from '../../../hooks/cars.hooks'; +import NERAutocomplete from '../../../components/NERAutocomplete'; interface AdminFinanceDashboardProps { startDate?: Date; @@ -35,8 +37,9 @@ const AdminFinanceDashboard: React.FC = ({ startDate const [tabIndex, setTabIndex] = useState(0); const [showPendingAdvisorListModal, setShowPendingAdvisorListModal] = useState(false); const [showTotalAmountSpent, setShowTotalAmountSpent] = useState(false); - - const filter = useFinanceDashboardCarFilter(startDate, endDate, carNumber); + const [startDateState, setStartDateState] = useState(startDate); + const [endDateState, setEndDateState] = useState(endDate); + const [carNumberState, setCarNumberState] = useState(carNumber); const { data: allTeamTypes, @@ -57,8 +60,16 @@ const AdminFinanceDashboard: React.FC = ({ startDate error: allPendingAdvisorListError } = useGetPendingAdvisorList(); - if (filter.error) { - return ; + 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 (allTeamTypesIsError) { @@ -80,11 +91,17 @@ const AdminFinanceDashboard: React.FC = ({ startDate allReimbursementRequestsIsLoading || !allPendingAdvisorList || allPendingAdvisorListIsLoading || - filter.isLoading + !allCars || + allCarsIsLoading ) { return ; } + const carAutocompleteOptions = allCars.map((car) => ({ + label: car.name, + id: car.wbsNum.carNumber.toString() + })); + const tabs = []; tabs.push({ tabUrlValue: 'all', tabName: 'All' }); @@ -108,13 +125,83 @@ const AdminFinanceDashboard: React.FC = ({ startDate setAnchorEl(null); }; + const datePickerStyle = { + width: 150, + 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' + } + } + }; + const dateAndActionsDropdown = ( = ({ 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)} + /> + + + + + + + - + + + + (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={{ - color: 'white' - }} + sx={{ flexShrink: 0 }} > Actions @@ -192,16 +345,16 @@ const AdminFinanceDashboard: React.FC = ({ startDate /> )} {tabIndex === 0 ? ( - + ) : tabIndex === tabs.length - 1 ? ( - + ) : ( selectedTab && ( ) )} From 7b68b505c08b93bfdcc5a0c6b38038c80a3199c5 Mon Sep 17 00:00:00 2001 From: harish Date: Sat, 6 Dec 2025 22:50:51 -0500 Subject: [PATCH 08/11] 3629 fixing dropdown sidebar UI --- .../components/GlobalCarFilterDropdown.tsx | 128 ++++++++++-------- 1 file changed, 71 insertions(+), 57 deletions(-) diff --git a/src/frontend/src/components/GlobalCarFilterDropdown.tsx b/src/frontend/src/components/GlobalCarFilterDropdown.tsx index 6dd1300950..d87774ddff 100644 --- a/src/frontend/src/components/GlobalCarFilterDropdown.tsx +++ b/src/frontend/src/components/GlobalCarFilterDropdown.tsx @@ -4,8 +4,8 @@ */ import React, { useState } from 'react'; -import { Box, Typography, Menu, MenuItem, Chip, Tooltip, Paper, useTheme } from '@mui/material'; -import { ExpandMore as ExpandMoreIcon, DirectionsCar as CarIcon, HelpOutline as HelpIcon } from '@mui/icons-material'; +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'; @@ -15,21 +15,16 @@ interface GlobalCarFilterDropdownProps { } const GlobalCarFilterDropdown: React.FC = ({ compact = false, sx = {} }) => { - const theme = useTheme(); const { selectedCar, allCars, setSelectedCar, isLoading, error } = useGlobalCarFilter(); - const [anchorEl, setAnchorEl] = useState(null); + const [expanded, setExpanded] = useState(false); - const handleClick = (event: React.MouseEvent) => { - setAnchorEl(event.currentTarget); - }; - - const handleClose = () => { - setAnchorEl(null); + const handleToggle = () => { + setExpanded(!expanded); }; const handleCarSelect = (car: any) => { setSelectedCar(car); - handleClose(); + setExpanded(false); }; if (isLoading) { @@ -67,56 +62,75 @@ const GlobalCarFilterDropdown: React.FC = ({ compa if (compact) { return ( - - Working with: - - + - } - variant="outlined" - size="small" + + + Working with: + + + {currentCarLabel} + + + + + + + + - - - {sortedCars.map((car) => ( - handleCarSelect(car)} - > - - - {car.wbsNum.carNumber === 0 ? car.name : `Car ${car.wbsNum.carNumber}`} - - - {car.name} - - - - ))} - + > + {sortedCars.map((car) => { + const carLabel = car.wbsNum.carNumber === 0 ? car.name : `Car ${car.wbsNum.carNumber}`; + 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' + }} + /> + ); + })} + + ); } From 41af04965d5df7ecf979d758e3a9f312a495537c Mon Sep 17 00:00:00 2001 From: harish Date: Sat, 6 Dec 2025 22:55:01 -0500 Subject: [PATCH 09/11] #3629 linting --- .../components/GlobalCarFilterDropdown.tsx | 80 +++---------------- 1 file changed, 10 insertions(+), 70 deletions(-) diff --git a/src/frontend/src/components/GlobalCarFilterDropdown.tsx b/src/frontend/src/components/GlobalCarFilterDropdown.tsx index d87774ddff..8e20df612c 100644 --- a/src/frontend/src/components/GlobalCarFilterDropdown.tsx +++ b/src/frontend/src/components/GlobalCarFilterDropdown.tsx @@ -135,79 +135,19 @@ const GlobalCarFilterDropdown: React.FC = ({ compa ); } + // Non-compact mode (not used in current implementation) return ( - + + + Working with: + - - - - Global Car Filter - - - {selectedCar.name} - - - - - - - - - } - color="primary" - variant="outlined" - /> - - - {sortedCars.map((car) => ( - handleCarSelect(car)} - sx={{ py: 1.5 }} - > - - - {car.wbsNum.carNumber === 0 ? car.name : `Car ${car.wbsNum.carNumber}`} - - - {car.name} - - - Created: {car.dateCreated.toLocaleDateString()} - - - - ))} - + + + {currentCarLabel} + - + ); }; From 564fdd8c76ab65366888449ea6a7c3cc8e7146bf Mon Sep 17 00:00:00 2001 From: harish Date: Sat, 6 Dec 2025 22:57:38 -0500 Subject: [PATCH 10/11] #3629 linting --- src/backend/tests/unmocked/cars.test.ts | 2 +- src/frontend/src/tests/hooks/GlobalCarFilterContext.test.tsx | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/backend/tests/unmocked/cars.test.ts b/src/backend/tests/unmocked/cars.test.ts index 20e9607840..0f9d90c689 100644 --- a/src/backend/tests/unmocked/cars.test.ts +++ b/src/backend/tests/unmocked/cars.test.ts @@ -181,7 +181,7 @@ describe('Cars Tests', () => { } }); - const car2 = await prisma.car.create({ + await prisma.car.create({ data: { wbsElement: { create: { diff --git a/src/frontend/src/tests/hooks/GlobalCarFilterContext.test.tsx b/src/frontend/src/tests/hooks/GlobalCarFilterContext.test.tsx index d53a8ba33c..11fc26acfd 100644 --- a/src/frontend/src/tests/hooks/GlobalCarFilterContext.test.tsx +++ b/src/frontend/src/tests/hooks/GlobalCarFilterContext.test.tsx @@ -209,7 +209,7 @@ describe('useGlobalCarFilter', () => { await waitFor(() => { expect(sessionStorage.getItem('selectedCarId')).toBeNull(); - expect(result.current.selectedCar).toBeNull(); }); + expect(result.current.selectedCar).toBeNull(); }); }); From 5d677edbddb0e4941d9584037fa2ccb50c16c40e Mon Sep 17 00:00:00 2001 From: harish Date: Sat, 6 Dec 2025 23:26:24 -0500 Subject: [PATCH 11/11] filtering for other data --- src/backend/src/prisma/seed.ts | 34 +++++++++++++++++++ .../components/GlobalCarFilterDropdown.tsx | 8 ++--- .../AdminFinanceDashboard.tsx | 14 ++++++-- .../ProjectGanttChartPage.tsx | 21 +++++++++++- .../pages/ProjectsPage/ProjectsOverview.tsx | 18 ++++++++-- .../src/pages/ProjectsPage/ProjectsTable.tsx | 8 ++++- 6 files changed, 90 insertions(+), 13 deletions(-) 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/frontend/src/components/GlobalCarFilterDropdown.tsx b/src/frontend/src/components/GlobalCarFilterDropdown.tsx index 8e20df612c..069174fa29 100644 --- a/src/frontend/src/components/GlobalCarFilterDropdown.tsx +++ b/src/frontend/src/components/GlobalCarFilterDropdown.tsx @@ -53,11 +53,7 @@ const GlobalCarFilterDropdown: React.FC = ({ compa const sortedCars = [...allCars].sort((a, b) => b.wbsNum.carNumber - a.wbsNum.carNumber); - const currentCarLabel = selectedCar - ? selectedCar.wbsNum.carNumber === 0 - ? selectedCar.name - : `Car ${selectedCar.wbsNum.carNumber}` - : 'Select Car'; + const currentCarLabel = selectedCar ? selectedCar.name : 'Select Car'; if (compact) { return ( @@ -107,7 +103,7 @@ const GlobalCarFilterDropdown: React.FC = ({ compa }} > {sortedCars.map((car) => { - const carLabel = car.wbsNum.carNumber === 0 ? car.name : `Car ${car.wbsNum.carNumber}`; + const carLabel = car.name; const isSelected = selectedCar ? car.id === selectedCar.id : false; return ( = ({ startDate, endDate, carNumber }) => { const user = useCurrentUser(); + const { selectedCar } = useGlobalCarFilter(); const [anchorEl, setAnchorEl] = useState(null); const [tabIndex, setTabIndex] = useState(0); @@ -62,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 ; 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),