diff --git a/apps/backend/src/foodRequests/request.controller.ts b/apps/backend/src/foodRequests/request.controller.ts index 935dab39..b7ebc12e 100644 --- a/apps/backend/src/foodRequests/request.controller.ts +++ b/apps/backend/src/foodRequests/request.controller.ts @@ -41,6 +41,7 @@ export class RequestsController { return this.requestsService.find(pantryId); } + @Roles(Role.VOLUNTEER, Role.PANTRY, Role.ADMIN) @Get('/:requestId/order-details') async getAllOrderDetailsFromRequest( @Param('requestId', ParseIntPipe) requestId: number, diff --git a/apps/backend/src/foodRequests/request.service.spec.ts b/apps/backend/src/foodRequests/request.service.spec.ts index d404d62a..c9343057 100644 --- a/apps/backend/src/foodRequests/request.service.spec.ts +++ b/apps/backend/src/foodRequests/request.service.spec.ts @@ -196,7 +196,7 @@ describe('RequestsService', () => { }); describe('find', () => { - it('should return all food requests for a specific pantry', async () => { + it('should return all food requests for a specific pantry with pantry details', async () => { const pantryId = 1; const result = await service.find(pantryId); @@ -206,6 +206,7 @@ describe('RequestsService', () => { result.forEach((request) => { expect(request.orders).toBeDefined(); }); + expect(result.every((r) => r.pantry)).toBeDefined(); }); it('should return empty array for pantry with no requests', async () => { diff --git a/apps/backend/src/foodRequests/request.service.ts b/apps/backend/src/foodRequests/request.service.ts index 0a3290fd..5dbd778b 100644 --- a/apps/backend/src/foodRequests/request.service.ts +++ b/apps/backend/src/foodRequests/request.service.ts @@ -218,7 +218,7 @@ export class RequestsService { return await this.repo.find({ where: { pantryId }, - relations: ['orders'], + relations: ['orders', 'pantry'], }); } diff --git a/apps/backend/src/pantries/pantries.controller.ts b/apps/backend/src/pantries/pantries.controller.ts index 746e67aa..d35dcded 100644 --- a/apps/backend/src/pantries/pantries.controller.ts +++ b/apps/backend/src/pantries/pantries.controller.ts @@ -55,7 +55,7 @@ export class PantriesController { return this.pantriesService.getPendingPantries(); } - @Roles(Role.PANTRY, Role.ADMIN) + @Roles(Role.PANTRY, Role.ADMIN, Role.VOLUNTEER) @Get('/:pantryId') async getPantry( @Param('pantryId', ParseIntPipe) pantryId: number, diff --git a/apps/backend/src/volunteers/volunteers.controller.spec.ts b/apps/backend/src/volunteers/volunteers.controller.spec.ts index 8d4a1788..93286c1c 100644 --- a/apps/backend/src/volunteers/volunteers.controller.spec.ts +++ b/apps/backend/src/volunteers/volunteers.controller.spec.ts @@ -1,13 +1,12 @@ -import { BadRequestException, NotFoundException } from '@nestjs/common'; import { VolunteersController } from './volunteers.controller'; -import { UsersController } from '../users/users.controller'; -import { UsersService } from '../users/users.service'; import { User } from '../users/users.entity'; import { Role } from '../users/types'; import { Test, TestingModule } from '@nestjs/testing'; import { mock } from 'jest-mock-extended'; import { Pantry } from '../pantries/pantries.entity'; import { VolunteersService } from './volunteers.service'; +import { FoodRequest } from '../foodRequests/request.entity'; +import { AuthenticatedRequest } from '../auth/authenticated-request'; const mockVolunteersService = mock(); @@ -156,11 +155,35 @@ describe('VolunteersController', () => { expect(result).toEqual(updatedUser); expect(result.pantries).toHaveLength(2); - expect(result.pantries![0].pantryId).toBe(1); - expect(result.pantries![1].pantryId).toBe(3); + expect(result.pantries?.[0].pantryId).toBe(1); + expect(result.pantries?.[1].pantryId).toBe(3); expect( mockVolunteersService.assignPantriesToVolunteer, ).toHaveBeenCalledWith(3, pantryIds); }); }); + + describe('GET /me/assigned-requests', () => { + it('returns assigned requests when req.currentUser is present', async () => { + const req: AuthenticatedRequest = { + user: { id: 1 }, + } as AuthenticatedRequest; + const foodRequests: Partial[] = [ + { requestId: 10 }, + { requestId: 5 }, + ]; + mockVolunteersService.findRequestsByVolunteer.mockResolvedValueOnce( + foodRequests as FoodRequest[], + ); + + const result = await controller.getAssignedRequests( + req as AuthenticatedRequest, + ); + + expect(result).toEqual(foodRequests); + expect( + mockVolunteersService.findRequestsByVolunteer, + ).toHaveBeenCalledWith(1); + }); + }); }); diff --git a/apps/backend/src/volunteers/volunteers.controller.ts b/apps/backend/src/volunteers/volunteers.controller.ts index 29763de8..c9d79676 100644 --- a/apps/backend/src/volunteers/volunteers.controller.ts +++ b/apps/backend/src/volunteers/volunteers.controller.ts @@ -5,6 +5,7 @@ import { ParseIntPipe, Post, Body, + Req, } from '@nestjs/common'; import { User } from '../users/users.entity'; import { Pantry } from '../pantries/pantries.entity'; @@ -12,6 +13,8 @@ import { VolunteersService } from './volunteers.service'; import { Role } from '../users/types'; import { Roles } from '../auth/roles.decorator'; import { Assignments } from './types'; +import { FoodRequest } from '../foodRequests/request.entity'; +import { AuthenticatedRequest } from '../auth/authenticated-request'; @Controller('volunteers') export class VolunteersController { @@ -42,4 +45,14 @@ export class VolunteersController { ): Promise { return this.volunteersService.assignPantriesToVolunteer(id, pantryIds); } + + @Roles(Role.VOLUNTEER) + @Get('/me/assigned-requests') + async getAssignedRequests( + @Req() req: AuthenticatedRequest, + ): Promise { + const currentUser = req.user; + + return this.volunteersService.findRequestsByVolunteer(currentUser.id); + } } diff --git a/apps/backend/src/volunteers/volunteers.module.ts b/apps/backend/src/volunteers/volunteers.module.ts index 7f7a5d89..4945ac9f 100644 --- a/apps/backend/src/volunteers/volunteers.module.ts +++ b/apps/backend/src/volunteers/volunteers.module.ts @@ -6,6 +6,7 @@ import { AuthModule } from '../auth/auth.module'; import { VolunteersController } from './volunteers.controller'; import { VolunteersService } from './volunteers.service'; import { UsersModule } from '../users/users.module'; +import { RequestsModule } from '../foodRequests/request.module'; @Module({ imports: [ @@ -13,6 +14,7 @@ import { UsersModule } from '../users/users.module'; UsersModule, forwardRef(() => PantriesModule), forwardRef(() => AuthModule), + RequestsModule, ], controllers: [VolunteersController], providers: [VolunteersService], diff --git a/apps/backend/src/volunteers/volunteers.service.spec.ts b/apps/backend/src/volunteers/volunteers.service.spec.ts index 1bbcc753..249bc2a0 100644 --- a/apps/backend/src/volunteers/volunteers.service.spec.ts +++ b/apps/backend/src/volunteers/volunteers.service.spec.ts @@ -8,6 +8,11 @@ import { testDataSource } from '../config/typeormTestDataSource'; import { UsersService } from '../users/users.service'; import { PantriesService } from '../pantries/pantries.service'; import { AuthService } from '../auth/auth.service'; +import { RequestsService } from '../foodRequests/request.service'; +import { FoodRequest } from '../foodRequests/request.entity'; +import { Order } from '../orders/order.entity'; +import { DonationItem } from '../donationItems/donationItems.entity'; +import { FoodManufacturer } from '../foodManufacturers/manufacturers.entity'; jest.setTimeout(60000); @@ -25,6 +30,7 @@ describe('VolunteersService', () => { VolunteersService, UsersService, PantriesService, + RequestsService, { provide: AuthService, useValue: {}, @@ -37,6 +43,22 @@ describe('VolunteersService', () => { provide: getRepositoryToken(Pantry), useValue: testDataSource.getRepository(Pantry), }, + { + provide: getRepositoryToken(FoodRequest), + useValue: testDataSource.getRepository(FoodRequest), + }, + { + provide: getRepositoryToken(Order), + useValue: testDataSource.getRepository(Order), + }, + { + provide: getRepositoryToken(FoodManufacturer), + useValue: testDataSource.getRepository(FoodManufacturer), + }, + { + provide: getRepositoryToken(DonationItem), + useValue: testDataSource.getRepository(DonationItem), + }, ], }).compile(); @@ -177,8 +199,8 @@ describe('VolunteersService', () => { expect(beforePantryIds).toEqual([2, 3]); const result = await service.assignPantriesToVolunteer(7, [1, 4]); - expect(result.pantries!).toHaveLength(4); - const afterPantryIds = result.pantries!.map((p) => p.pantryId); + expect(result.pantries).toHaveLength(4); + const afterPantryIds = result.pantries?.map((p) => p.pantryId); expect(afterPantryIds).toEqual([2, 3, 1, 4]); }); @@ -191,8 +213,8 @@ describe('VolunteersService', () => { expect(beforeAssignment).toEqual([]); const result = await service.assignPantriesToVolunteer(6, [2, 3]); - expect(result.pantries!).toHaveLength(2); - const pantryIds = result.pantries!.map((p) => p.pantryId); + expect(result.pantries).toHaveLength(2); + const pantryIds = result.pantries?.map((p) => p.pantryId); expect(pantryIds).toEqual([2, 3]); }); @@ -203,9 +225,76 @@ describe('VolunteersService', () => { expect(beforePantryIds).toEqual([2, 3]); const result = await service.assignPantriesToVolunteer(7, [2, 3]); - expect(result.pantries!).toHaveLength(2); - const pantryIds = result.pantries!.map((p) => p.pantryId); + expect(result.pantries).toHaveLength(2); + const pantryIds = result.pantries?.map((p) => p.pantryId); expect(pantryIds).toEqual([2, 3]); }); }); + + describe('findRequestsByVolunteer', () => { + it('returned requests include pantry info', async () => { + const requests = await service.findRequestsByVolunteer(7); + requests.forEach((request) => { + expect(request.pantry).toBeDefined(); + expect(request.pantry).toHaveProperty('pantryName'); + }); + }); + + it('returns requests only from assigned pantries', async () => { + const volunteerId = 6; + + const assignedPantries = await service.getVolunteerPantries(volunteerId); + const assignedPantryIds = assignedPantries.map((p) => p.pantryId); + + const requests = await service.findRequestsByVolunteer(volunteerId); + requests.forEach((request) => { + expect(assignedPantryIds).toContain(request.pantryId); + }); + }); + + it('returns empty array when volunteer has no assigned pantries', async () => { + const volunteerId = await testDataSource + .query( + ` + INSERT INTO users (first_name, last_name, email, phone, role) + VALUES ('Test', 'Volunteer', 'test@volunteer.com', '537-280-1238', 'volunteer') + RETURNING user_id + `, + ) + .then((rows) => rows[0].user_id); + + const result = await service.findRequestsByVolunteer(volunteerId); + expect(result).toEqual([]); + }); + + it('returns empty array when assigned pantries have no requests', async () => { + const volunteerId = 8; + + const assignedPantries = await service.getVolunteerPantries(volunteerId); + const assignedPantryIds = assignedPantries.map((p) => p.pantryId); + await testDataSource.query( + `DELETE FROM allocations + WHERE order_id IN ( + SELECT o.order_id FROM orders o + JOIN food_requests fr ON o.request_id = fr.request_id + WHERE fr.pantry_id = ANY($1) + )`, + [assignedPantryIds], + ); + await testDataSource.query( + `DELETE FROM orders + WHERE request_id IN ( + SELECT request_id FROM food_requests WHERE pantry_id = ANY($1) + )`, + [assignedPantryIds], + ); + await testDataSource.query( + `DELETE FROM food_requests WHERE pantry_id = ANY($1)`, + [assignedPantryIds], + ); + + const requests = await service.findRequestsByVolunteer(volunteerId); + expect(requests).toEqual([]); + }); + }); }); diff --git a/apps/backend/src/volunteers/volunteers.service.ts b/apps/backend/src/volunteers/volunteers.service.ts index 32429296..e5bab899 100644 --- a/apps/backend/src/volunteers/volunteers.service.ts +++ b/apps/backend/src/volunteers/volunteers.service.ts @@ -8,6 +8,8 @@ import { Pantry } from '../pantries/pantries.entity'; import { PantriesService } from '../pantries/pantries.service'; import { UsersService } from '../users/users.service'; import { Assignments } from './types'; +import { FoodRequest } from '../foodRequests/request.entity'; +import { RequestsService } from '../foodRequests/request.service'; @Injectable() export class VolunteersService { @@ -16,6 +18,7 @@ export class VolunteersService { private repo: Repository, private usersService: UsersService, private pantriesService: PantriesService, + private requestsService: RequestsService, ) {} async findOne(id: number): Promise { @@ -73,4 +76,17 @@ export class VolunteersService { volunteer.pantries = [...existingPantries, ...newPantries]; return this.repo.save(volunteer); } + + async findRequestsByVolunteer(volunteerId: number): Promise { + validateId(volunteerId, 'Volunteer'); + + const pantries = await this.getVolunteerPantries(volunteerId); + const pantryIds = pantries.map((p) => p.pantryId); + + const requestArrays = await Promise.all( + pantryIds.map((id) => this.requestsService.find(id)), + ); + + return requestArrays.flat(); + } } diff --git a/apps/frontend/src/api/apiClient.ts b/apps/frontend/src/api/apiClient.ts index 899e4ca8..33caa01b 100644 --- a/apps/frontend/src/api/apiClient.ts +++ b/apps/frontend/src/api/apiClient.ts @@ -21,6 +21,7 @@ import { OrderSummary, UserDto, OrderDetails, + FoodRequestSummaryDto, Assignments, } from 'types/types'; @@ -283,6 +284,11 @@ export class ApiClient { return data as FoodRequest[]; } + public async getVolunteerAssignedRequests(): Promise { + const data = await this.get(`/api/volunteers/me/assigned-requests`); + return data as FoodRequest[]; + } + public async confirmDelivery( requestId: number, data: FormData, diff --git a/apps/frontend/src/app.tsx b/apps/frontend/src/app.tsx index ecedbf43..30b512a7 100644 --- a/apps/frontend/src/app.tsx +++ b/apps/frontend/src/app.tsx @@ -28,6 +28,7 @@ import { Authenticator } from '@aws-amplify/ui-react'; import FoodManufacturerApplication from '@containers/foodManufacturerApplication'; import { submitManufacturerApplicationForm } from '@components/forms/manufacturerApplicationForm'; import AssignedPantries from '@containers/volunteerAssignedPantries'; +import VolunteerRequestManagement from '@containers/volunteerRequestManagement'; Amplify.configure(CognitoAuthConfig); @@ -189,6 +190,14 @@ const router = createBrowserRouter([ ), }, + { + path: '/volunteer-request-management', + element: ( + + + + ), + }, ], }, ]); diff --git a/apps/frontend/src/containers/adminDonation.tsx b/apps/frontend/src/containers/adminDonation.tsx index 9c9a761f..8eb1a376 100644 --- a/apps/frontend/src/containers/adminDonation.tsx +++ b/apps/frontend/src/containers/adminDonation.tsx @@ -10,6 +10,7 @@ import { Checkbox, VStack, ButtonGroup, + Link, } from '@chakra-ui/react'; import { Donation } from 'types/types'; import DonationDetailsModal from '@components/forms/donationDetailsModal'; @@ -225,20 +226,13 @@ const AdminDonation: React.FC = () => { borderRightColor="neutral.100" py={0} > - - {selectedDonation && ( - setSelectedDonation(null)} - /> - )} + { ))} + {selectedDonation && ( + setSelectedDonation(null)} + /> + )} diff --git a/apps/frontend/src/containers/adminOrderManagement.tsx b/apps/frontend/src/containers/adminOrderManagement.tsx index 772861e4..62fc77fa 100644 --- a/apps/frontend/src/containers/adminOrderManagement.tsx +++ b/apps/frontend/src/containers/adminOrderManagement.tsx @@ -10,6 +10,7 @@ import { ButtonGroup, Checkbox, Input, + Link, } from '@chakra-ui/react'; import { ArrowDownUp, @@ -619,21 +620,13 @@ const OrderStatusSection: React.FC = ({ borderRight="1px solid" borderRightColor="neutral.100" > - - {selectedOrderId === order.orderId && ( - onOrderSelect(null)} - /> - )} + = ({ color={colors[1]} display="inline-block" fontWeight="500" - my={2} - py={1} + fontSize="12px" + my={3} + py={0.5} px={3} > {capitalize(order.status)} @@ -713,6 +707,13 @@ const OrderStatusSection: React.FC = ({ ); })} + {selectedOrderId && ( + onOrderSelect(null)} + /> + )} diff --git a/apps/frontend/src/containers/foodManufacturerDonationManagement.tsx b/apps/frontend/src/containers/foodManufacturerDonationManagement.tsx index 6e2c3aa4..d295e2c5 100644 --- a/apps/frontend/src/containers/foodManufacturerDonationManagement.tsx +++ b/apps/frontend/src/containers/foodManufacturerDonationManagement.tsx @@ -7,6 +7,7 @@ import { Pagination, IconButton, ButtonGroup, + Link, } from '@chakra-ui/react'; import { ChevronRight, ChevronLeft, Mail, CircleCheck } from 'lucide-react'; import { capitalize, formatDate } from '@utils/utils'; @@ -286,14 +287,13 @@ const DonationStatusSection: React.FC = ({ borderRight="1px solid" borderRightColor="neutral.100" > - + {selectedDonationId === donation.donationId && ( { + + + + Food Request Management + + + diff --git a/apps/frontend/src/containers/volunteerRequestManagement.tsx b/apps/frontend/src/containers/volunteerRequestManagement.tsx new file mode 100644 index 00000000..dace2d42 --- /dev/null +++ b/apps/frontend/src/containers/volunteerRequestManagement.tsx @@ -0,0 +1,363 @@ +import React, { useState, useEffect } from 'react'; +import { + Box, + Button, + Table, + Heading, + Pagination, + IconButton, + VStack, + ButtonGroup, + Checkbox, + Link, +} from '@chakra-ui/react'; +import { ArrowDownUp, ChevronRight, ChevronLeft, Funnel } from 'lucide-react'; +import { capitalize, formatDate } from '@utils/utils'; +import ApiClient from '@api/apiClient'; +import { FloatingAlert } from '@components/floatingAlert'; +import { FoodRequest, FoodRequestStatus } from '../types/types'; +import RequestDetailsModal from '@components/forms/requestDetailsModal'; + +const VolunteerRequestManagement: React.FC = () => { + const [requests, setRequests] = useState([]); + const [sortAsc, setSortAsc] = useState(true); + const [currentPage, setCurrentPage] = useState(1); + const [isFilterOpen, setIsFilterOpen] = useState(false); + const [selectedPantries, setSelectedPantries] = useState([]); + const [selectedRequest, setSelectedRequest] = useState( + null, + ); + + const [alertMessage, setAlertMessage] = useState(''); + + useEffect(() => { + const fetchRequests = async () => { + try { + const data = await ApiClient.getVolunteerAssignedRequests(); + setRequests(data); + } catch (error) { + setAlertMessage('Error fetching requests' + error); + } + }; + fetchRequests(); + }, []); + + useEffect(() => { + setCurrentPage(1); + }, [selectedPantries]); + + const pantryOptions = [ + ...new Set( + requests + .map((r) => r.pantry?.pantryName) + .filter((name): name is string => !!name), + ), + ].sort((a, b) => a.localeCompare(b)); + + const handleFilterChange = (pantry: string, checked: boolean) => { + if (checked) { + setSelectedPantries([...selectedPantries, pantry]); + } else { + setSelectedPantries(selectedPantries.filter((p) => p !== pantry)); + } + }; + + const filteredRequests = requests + .filter((r) => { + const matchesFilter = + selectedPantries.length === 0 || + (r.pantry && selectedPantries.includes(r.pantry?.pantryName)); + return matchesFilter; + }) + .sort((a, b) => + sortAsc + ? a.requestedAt.localeCompare(b.requestedAt) + : b.requestedAt.localeCompare(a.requestedAt), + ); + + const itemsPerPage = 10; + const totalPages = Math.ceil(filteredRequests.length / itemsPerPage); + const paginatedRequests = filteredRequests.slice( + (currentPage - 1) * itemsPerPage, + currentPage * itemsPerPage, + ); + + const tableHeaderStyles = { + borderBottom: '1px solid', + borderColor: 'neutral.100', + color: 'neutral.800', + fontFamily: 'inter', + fontWeight: '600', + fontSize: 'sm', + }; + + const tableCellStyles = { + borderBottom: '1px solid', + borderColor: 'neutral.100', + color: 'black', + fontFamily: "'Inter', sans-serif", + fontSize: 'sm', + py: 0, + }; + + return ( + + + Food Request Management + + {alertMessage && ( + + )} + + + + + {isFilterOpen && ( + <> + setIsFilterOpen(false)} + zIndex={10} + /> + + + {pantryOptions.map((pantry) => ( + + handleFilterChange(pantry, e.checked) + } + color="black" + size="sm" + > + + + {pantry} + + ))} + + + + )} + + + + + + + + Request # + + + Status + + + Pantry + + + Date Requested + + + Action Required + + + + + {paginatedRequests.map((request, index) => ( + + + setSelectedRequest(request)} + > + {request.requestId} + + + + + {capitalize(request.status)} + + + + {request.pantry.pantryName} + + + {formatDate(request.requestedAt)} + + {/* TODO*/} + + ))} + + {selectedRequest && ( + setSelectedRequest(null)} + pantryId={selectedRequest.pantryId} + /> + )} + + + + {totalPages > 1 && ( + setCurrentPage(e.page)} + > + + + + + + ( + + {page.value} + + )} + /> + + + + + + + )} + + ); +}; + +export default VolunteerRequestManagement;