diff --git a/apps/backend/src/allocations/allocations.entity.ts b/apps/backend/src/allocations/allocations.entity.ts index a5a3c734..26985283 100644 --- a/apps/backend/src/allocations/allocations.entity.ts +++ b/apps/backend/src/allocations/allocations.entity.ts @@ -6,15 +6,20 @@ import { JoinColumn, } from 'typeorm'; import { DonationItem } from '../donationItems/donationItems.entity'; +import { Order } from '../orders/order.entity'; @Entity('allocations') export class Allocation { @PrimaryGeneratedColumn({ name: 'allocation_id' }) allocationId: number; - @Column({ name: 'order_id', type: 'int' }) + @Column({ name: 'order_id', type: 'int', nullable: false }) orderId: number; + @ManyToOne(() => Order, (order) => order.allocations) + @JoinColumn({ name: 'order_id' }) + order: Order; + @Column({ name: 'item_id', type: 'int', nullable: false }) itemId: number; diff --git a/apps/backend/src/foodRequests/dtos/order-details.dto.ts b/apps/backend/src/foodRequests/dtos/order-details.dto.ts new file mode 100644 index 00000000..21d360ec --- /dev/null +++ b/apps/backend/src/foodRequests/dtos/order-details.dto.ts @@ -0,0 +1,15 @@ +import { FoodType } from '../../donationItems/types'; +import { OrderStatus } from '../../orders/types'; + +export class OrderItemDetailsDto { + name: string; + quantity: number; + foodType: FoodType; +} + +export class OrderDetailsDto { + orderId: number; + status: OrderStatus; + foodManufacturerName: string; + items: OrderItemDetailsDto[]; +} diff --git a/apps/backend/src/foodRequests/request.controller.spec.ts b/apps/backend/src/foodRequests/request.controller.spec.ts index 71c35ca4..334f4019 100644 --- a/apps/backend/src/foodRequests/request.controller.spec.ts +++ b/apps/backend/src/foodRequests/request.controller.spec.ts @@ -8,6 +8,8 @@ import { Readable } from 'stream'; import { FoodRequest } from './request.entity'; import { RequestSize } from './types'; import { OrderStatus } from '../orders/types'; +import { FoodType } from '../donationItems/types'; +import { OrderDetailsDto } from './dtos/order-details.dto'; const mockRequestsService = mock(); const mockOrdersService = mock(); @@ -26,6 +28,7 @@ describe('RequestsController', () => { mockRequestsService.find.mockReset(); mockRequestsService.create.mockReset(); mockRequestsService.updateDeliveryDetails?.mockReset(); + mockRequestsService.getOrderDetails.mockReset(); mockAWSS3Service.upload.mockReset(); mockOrdersService.updateStatus.mockReset(); @@ -91,6 +94,55 @@ describe('RequestsController', () => { }); }); + describe('GET /all-order-details/:requestId', () => { + it('should call requestsService.getOrderDetails and return all associated orders and their details', async () => { + const mockOrderDetails: OrderDetailsDto[] = [ + { + orderId: 10, + status: OrderStatus.DELIVERED, + foodManufacturerName: 'Test Manufacturer', + items: [ + { + name: 'Rice', + quantity: 5, + foodType: FoodType.GRANOLA, + }, + { + name: 'Beans', + quantity: 3, + foodType: FoodType.DRIED_BEANS, + }, + ], + }, + { + orderId: 11, + status: OrderStatus.PENDING, + foodManufacturerName: 'Another Manufacturer', + items: [ + { + name: 'Milk', + quantity: 2, + foodType: FoodType.DAIRY_FREE_ALTERNATIVES, + }, + ], + }, + ]; + + const requestId = 1; + + mockRequestsService.getOrderDetails.mockResolvedValueOnce( + mockOrderDetails as OrderDetailsDto[], + ); + + const result = await controller.getAllOrderDetailsFromRequest(requestId); + + expect(result).toEqual(mockOrderDetails); + expect(mockRequestsService.getOrderDetails).toHaveBeenCalledWith( + requestId, + ); + }); + }); + describe('POST /create', () => { it('should call requestsService.create and return the created food request', async () => { const createBody: Partial = { @@ -107,7 +159,7 @@ describe('RequestsController', () => { requestId: 1, ...createBody, requestedAt: new Date(), - order: null, + orders: null, }; mockRequestsService.create.mockResolvedValueOnce( @@ -181,7 +233,7 @@ describe('RequestsController', () => { mockRequestsService.findOne.mockResolvedValue({ requestId, pantryId: 1, - order: { orderId: 99 }, + orders: [{ orderId: 99 }], } as FoodRequest); mockOrdersService.updateStatus.mockResolvedValue(); @@ -230,7 +282,7 @@ describe('RequestsController', () => { mockRequestsService.findOne.mockResolvedValue({ requestId, pantryId: 1, - order: { orderId: 100 }, + orders: [{ orderId: 100 }], } as FoodRequest); mockOrdersService.updateStatus.mockResolvedValue(); @@ -275,7 +327,7 @@ describe('RequestsController', () => { mockRequestsService.findOne.mockResolvedValue({ requestId, pantryId: 1, - order: { orderId: 101 }, + orders: [{ orderId: 101 }], } as FoodRequest); mockOrdersService.updateStatus.mockResolvedValue(); diff --git a/apps/backend/src/foodRequests/request.controller.ts b/apps/backend/src/foodRequests/request.controller.ts index 1f449491..84cc1060 100644 --- a/apps/backend/src/foodRequests/request.controller.ts +++ b/apps/backend/src/foodRequests/request.controller.ts @@ -8,6 +8,8 @@ import { UploadedFiles, UseInterceptors, BadRequestException, + NotFoundException, + ConflictException, } from '@nestjs/common'; import { ApiBody } from '@nestjs/swagger'; import { RequestsService } from './request.service'; @@ -16,9 +18,9 @@ import { AWSS3Service } from '../aws/aws-s3.service'; import { FilesInterceptor } from '@nestjs/platform-express'; import * as multer from 'multer'; import { OrdersService } from '../orders/order.service'; -import { Order } from '../orders/order.entity'; import { RequestSize } from './types'; import { OrderStatus } from '../orders/types'; +import { OrderDetailsDto } from './dtos/order-details.dto'; @Controller('requests') // @UseInterceptors() @@ -43,6 +45,13 @@ export class RequestsController { return this.requestsService.find(pantryId); } + @Get('/all-order-details/:requestId') + async getAllOrderDetailsFromRequest( + @Param('requestId', ParseIntPipe) requestId: number, + ): Promise { + return this.requestsService.getOrderDetails(requestId); + } + @Post('/create') @ApiBody({ description: 'Details for creating a food request', @@ -109,6 +118,7 @@ export class RequestsController { ); } + //TODO: delete endpoint, here temporarily as a logic reference for order status impl. @Post('/:requestId/confirm-delivery') @ApiBody({ description: 'Details for a confirmation form', @@ -157,17 +167,29 @@ export class RequestsController { photos?.length, ); - const request = await this.requestsService.findOne(requestId); - await this.ordersService.updateStatus( - request.order.orderId, - OrderStatus.DELIVERED, - ); - - return this.requestsService.updateDeliveryDetails( + const updatedRequest = await this.requestsService.updateDeliveryDetails( requestId, formattedDate, body.feedback, uploadedPhotoUrls, ); + + if (!updatedRequest) { + throw new NotFoundException('Invalid request ID'); + } + + if (!updatedRequest.orders || updatedRequest.orders.length == 0) { + throw new ConflictException( + 'No associated orders found for this request', + ); + } + + await Promise.all( + updatedRequest.orders.map((order) => + this.ordersService.updateStatus(order.orderId, OrderStatus.DELIVERED), + ), + ); + + return updatedRequest; } } diff --git a/apps/backend/src/foodRequests/request.entity.ts b/apps/backend/src/foodRequests/request.entity.ts index 06c2ce79..9864b4cc 100644 --- a/apps/backend/src/foodRequests/request.entity.ts +++ b/apps/backend/src/foodRequests/request.entity.ts @@ -47,5 +47,5 @@ export class FoodRequest { photos: string[]; @OneToMany(() => Order, (order) => order.request, { nullable: true }) - order: Order; + orders: Order[]; } diff --git a/apps/backend/src/foodRequests/request.service.spec.ts b/apps/backend/src/foodRequests/request.service.spec.ts index cc69a5a3..165cb036 100644 --- a/apps/backend/src/foodRequests/request.service.spec.ts +++ b/apps/backend/src/foodRequests/request.service.spec.ts @@ -8,9 +8,14 @@ import { Pantry } from '../pantries/pantries.entity'; import { RequestSize } from './types'; import { Order } from '../orders/order.entity'; import { OrderStatus } from '../orders/types'; +import { FoodManufacturer } from '../foodManufacturers/manufacturer.entity'; +import { FoodType } from '../donationItems/types'; +import { DonationItem } from '../donationItems/donationItems.entity'; +import { Allocation } from '../allocations/allocations.entity'; const mockRequestsRepository = mock>(); const mockPantryRepository = mock>(); +const mockOrdersRepository = mock>(); const mockRequest: Partial = { requestId: 1, @@ -21,7 +26,7 @@ const mockRequest: Partial = { dateReceived: null, feedback: null, photos: null, - order: null, + orders: null, }; describe('RequestsService', () => { @@ -46,6 +51,10 @@ describe('RequestsService', () => { provide: getRepositoryToken(Pantry), useValue: mockPantryRepository, }, + { + provide: getRepositoryToken(Order), + useValue: mockOrdersRepository, + }, ], }).compile(); @@ -74,7 +83,7 @@ describe('RequestsService', () => { expect(result).toEqual(mockRequest); expect(mockRequestsRepository.findOne).toHaveBeenCalledWith({ where: { requestId }, - relations: ['order'], + relations: ['orders'], }); }); @@ -89,7 +98,134 @@ describe('RequestsService', () => { expect(mockRequestsRepository.findOne).toHaveBeenCalledWith({ where: { requestId }, - relations: ['order'], + relations: ['orders'], + }); + }); + }); + + describe('getOrderDetails', () => { + it('should return mapped order details for a valid requestId', async () => { + const requestId = 1; + + const mockOrders: Partial[] = [ + { + orderId: 10, + status: OrderStatus.DELIVERED, + foodManufacturer: { + foodManufacturerName: 'Test Manufacturer', + } as FoodManufacturer, + allocations: [ + { + allocatedQuantity: 5, + item: { + itemName: 'Rice', + foodType: FoodType.GRANOLA, + } as DonationItem, + } as Allocation, + { + allocatedQuantity: 3, + item: { + itemName: 'Beans', + foodType: FoodType.DRIED_BEANS, + } as DonationItem, + } as Allocation, + ], + }, + { + orderId: 11, + status: OrderStatus.SHIPPED, + foodManufacturer: { + foodManufacturerName: 'Another Manufacturer', + } as FoodManufacturer, + allocations: [ + { + allocatedQuantity: 2, + item: { + itemName: 'Milk', + foodType: FoodType.DAIRY_FREE_ALTERNATIVES, + } as DonationItem, + } as Allocation, + ], + }, + ]; + + mockOrdersRepository.find.mockResolvedValueOnce(mockOrders as Order[]); + + mockRequestsRepository.findOne.mockResolvedValueOnce( + mockRequest as FoodRequest, + ); + + const result = await service.getOrderDetails(requestId); + + expect(result).toEqual([ + { + orderId: 10, + status: OrderStatus.DELIVERED, + foodManufacturerName: 'Test Manufacturer', + items: [ + { + name: 'Rice', + quantity: 5, + foodType: FoodType.GRANOLA, + }, + { + name: 'Beans', + quantity: 3, + foodType: FoodType.DRIED_BEANS, + }, + ], + }, + { + orderId: 11, + status: OrderStatus.SHIPPED, + foodManufacturerName: 'Another Manufacturer', + items: [ + { + name: 'Milk', + quantity: 2, + foodType: FoodType.DAIRY_FREE_ALTERNATIVES, + }, + ], + }, + ]); + + expect(mockOrdersRepository.find).toHaveBeenCalledWith({ + where: { requestId }, + relations: { + foodManufacturer: true, + allocations: { + item: true, + }, + }, + }); + }); + + it('should throw an error if the request id is not found', async () => { + const requestId = 999; + + await expect(service.getOrderDetails(requestId)).rejects.toThrow( + `Request ${requestId} not found`, + ); + }); + + it('should return empty list if no associated orders', async () => { + const requestId = 1; + + mockRequestsRepository.findOne.mockResolvedValueOnce( + mockRequest as FoodRequest, + ); + mockOrdersRepository.find.mockResolvedValueOnce([]); + + const result = await service.getOrderDetails(requestId); + expect(result).toEqual([]); + expect(mockOrdersRepository.find).toHaveBeenCalledWith({ + where: { requestId }, + relations: { + foodManufacturer: true, + allocations: { + item: true, + }, + }, }); }); }); @@ -166,7 +302,7 @@ describe('RequestsService', () => { dateReceived: null, feedback: null, photos: null, - order: null, + orders: null, }, { requestId: 3, @@ -178,7 +314,7 @@ describe('RequestsService', () => { dateReceived: null, feedback: null, photos: null, - order: null, + orders: null, }, ]; const pantryId = 1; @@ -191,7 +327,7 @@ describe('RequestsService', () => { expect(result).toEqual(mockRequests.slice(0, 2)); expect(mockRequestsRepository.find).toHaveBeenCalledWith({ where: { pantryId }, - relations: ['order'], + relations: ['orders'], }); }); }); @@ -213,7 +349,7 @@ describe('RequestsService', () => { const mockRequest2: Partial = { ...mockRequest, - order: mockOrder as Order, + orders: [mockOrder] as Order[], }; const requestId = 1; @@ -224,15 +360,15 @@ describe('RequestsService', () => { mockRequestsRepository.findOne.mockResolvedValueOnce( mockRequest2 as FoodRequest, ); + + const updatedOrder = { ...mockOrder, status: OrderStatus.DELIVERED }; + mockRequestsRepository.save.mockResolvedValueOnce({ ...mockRequest, dateReceived: deliveryDate, feedback, photos, - order: { - ...(mockOrder as Order), - status: OrderStatus.DELIVERED, - } as Order, + orders: [updatedOrder], } as FoodRequest); const result = await service.updateDeliveryDetails( @@ -247,12 +383,12 @@ describe('RequestsService', () => { dateReceived: deliveryDate, feedback, photos, - order: { ...mockOrder, status: 'delivered' }, + orders: [updatedOrder], }); expect(mockRequestsRepository.findOne).toHaveBeenCalledWith({ where: { requestId }, - relations: ['order'], + relations: ['orders'], }); expect(mockRequestsRepository.save).toHaveBeenCalledWith({ @@ -260,7 +396,7 @@ describe('RequestsService', () => { dateReceived: deliveryDate, feedback, photos, - order: { ...mockOrder, status: 'delivered' }, + orders: [updatedOrder], }); }); @@ -283,7 +419,7 @@ describe('RequestsService', () => { expect(mockRequestsRepository.findOne).toHaveBeenCalledWith({ where: { requestId }, - relations: ['order'], + relations: ['orders'], }); }); @@ -304,11 +440,11 @@ describe('RequestsService', () => { feedback, photos, ), - ).rejects.toThrow('No associated order found for this request'); + ).rejects.toThrow('No associated orders found for this request'); expect(mockRequestsRepository.findOne).toHaveBeenCalledWith({ where: { requestId }, - relations: ['order'], + relations: ['orders'], }); }); @@ -327,7 +463,7 @@ describe('RequestsService', () => { }; const mockRequest2: Partial = { ...mockRequest, - order: mockOrder as Order, + orders: [mockOrder] as Order[], }; const requestId = 1; @@ -346,11 +482,13 @@ describe('RequestsService', () => { feedback, photos, ), - ).rejects.toThrow('No associated food manufacturer found for this order'); + ).rejects.toThrow( + 'No associated food manufacturer found for an associated order', + ); expect(mockRequestsRepository.findOne).toHaveBeenCalledWith({ where: { requestId }, - relations: ['order'], + relations: ['orders'], }); }); }); diff --git a/apps/backend/src/foodRequests/request.service.ts b/apps/backend/src/foodRequests/request.service.ts index 32e600ba..82eb06f7 100644 --- a/apps/backend/src/foodRequests/request.service.ts +++ b/apps/backend/src/foodRequests/request.service.ts @@ -10,12 +10,15 @@ import { validateId } from '../utils/validation.utils'; import { RequestSize } from './types'; import { OrderStatus } from '../orders/types'; import { Pantry } from '../pantries/pantries.entity'; +import { Order } from '../orders/order.entity'; +import { OrderDetailsDto } from './dtos/order-details.dto'; @Injectable() export class RequestsService { constructor( @InjectRepository(FoodRequest) private repo: Repository, @InjectRepository(Pantry) private pantryRepo: Repository, + @InjectRepository(Order) private orderRepo: Repository, ) {} async findOne(requestId: number): Promise { @@ -23,7 +26,7 @@ export class RequestsService { const request = await this.repo.findOne({ where: { requestId }, - relations: ['order'], + relations: ['orders'], }); if (!request) { @@ -32,6 +35,43 @@ export class RequestsService { return request; } + async getOrderDetails(requestId: number): Promise { + validateId(requestId, 'Request'); + + const requestExists = await this.repo.findOne({ + where: { requestId }, + }); + + if (!requestExists) { + throw new NotFoundException(`Request ${requestId} not found`); + } + + const orders = await this.orderRepo.find({ + where: { requestId }, + relations: { + foodManufacturer: true, + allocations: { + item: true, + }, + }, + }); + + if (!orders.length) { + return []; + } + + return orders.map((order) => ({ + orderId: order.orderId, + status: order.status, + foodManufacturerName: order.foodManufacturer.foodManufacturerName, + items: order.allocations.map((allocation) => ({ + name: allocation.item.itemName, + quantity: allocation.allocatedQuantity, + foodType: allocation.item.foodType, + })), + })); + } + async create( pantryId: number, requestedSize: RequestSize, @@ -66,7 +106,7 @@ export class RequestsService { return await this.repo.find({ where: { pantryId }, - relations: ['order'], + relations: ['orders'], }); } @@ -80,29 +120,32 @@ export class RequestsService { const request = await this.repo.findOne({ where: { requestId }, - relations: ['order'], + relations: ['orders'], }); if (!request) { throw new NotFoundException('Invalid request ID'); } - if (!request.order) { - throw new ConflictException('No associated order found for this request'); + if (!request.orders || request.orders.length == 0) { + throw new ConflictException( + 'No associated orders found for this request', + ); } - const order = request.order; + const orders = request.orders; - if (!order.shippedBy) { - throw new ConflictException( - 'No associated food manufacturer found for this order', - ); + for (const order of orders) { + if (!order.shippedBy) { + throw new ConflictException( + 'No associated food manufacturer found for an associated order', + ); + } } request.feedback = feedback; request.dateReceived = deliveryDate; request.photos = photos; - request.order.status = OrderStatus.DELIVERED; return await this.repo.save(request); } diff --git a/apps/backend/src/orders/order.entity.ts b/apps/backend/src/orders/order.entity.ts index 4c38457b..7c40fdb4 100644 --- a/apps/backend/src/orders/order.entity.ts +++ b/apps/backend/src/orders/order.entity.ts @@ -5,11 +5,13 @@ import { CreateDateColumn, ManyToOne, JoinColumn, + OneToMany, } from 'typeorm'; import { FoodRequest } from '../foodRequests/request.entity'; import { Pantry } from '../pantries/pantries.entity'; import { FoodManufacturer } from '../foodManufacturers/manufacturer.entity'; import { OrderStatus } from './types'; +import { Allocation } from '../allocations/allocations.entity'; @Entity('orders') export class Order { @@ -72,4 +74,7 @@ export class Order { nullable: true, }) deliveredAt: Date | null; + + @OneToMany(() => Allocation, (allocation) => allocation.order) + allocations: Allocation[]; } diff --git a/apps/frontend/src/api/apiClient.ts b/apps/frontend/src/api/apiClient.ts index f62efd8f..1c87bb48 100644 --- a/apps/frontend/src/api/apiClient.ts +++ b/apps/frontend/src/api/apiClient.ts @@ -11,6 +11,7 @@ import { Pantry, PantryApplicationDto, UserDto, + OrderDetails, } from 'types/types'; const defaultBaseUrl = @@ -193,9 +194,17 @@ export class ApiClient { return this.axiosInstance.get(`api/orders/${orderId}`) as Promise; } + public async getOrderDetailsListFromRequest( + requestId: number, + ): Promise { + return this.axiosInstance + .get(`api/requests/get-all-order-details/${requestId}`) + .then((response) => response.data) as Promise; + } + async getAllAllocationsByOrder(orderId: number): Promise { return this.axiosInstance - .get(`api/orders/${orderId}/allocations`) + .get(`/api/orders/${orderId}/allocations`) .then((response) => response.data); } diff --git a/apps/frontend/src/components/forms/requestDetailsModal.tsx b/apps/frontend/src/components/forms/requestDetailsModal.tsx new file mode 100644 index 00000000..88153e95 --- /dev/null +++ b/apps/frontend/src/components/forms/requestDetailsModal.tsx @@ -0,0 +1,356 @@ +import apiClient from '@api/apiClient'; +import { + FoodRequest, + FoodTypes, + OrderDetails, + OrderItemDetails, +} from 'types/types'; +import { OrderStatus } from '../../types/types'; +import React, { useState, useEffect, useMemo } from 'react'; +import { + Flex, + Box, + Menu, + Text, + Dialog, + Tag, + Field, + CloseButton, + Tabs, + Badge, + Pagination, + ButtonGroup, + IconButton, +} from '@chakra-ui/react'; +import { ChevronRight, ChevronLeft } from 'lucide-react'; + +interface RequestDetailsModalProps { + request: FoodRequest; + isOpen: boolean; + onClose: () => void; + pantryId: number; +} + +const RequestDetailsModal: React.FC = ({ + request, + isOpen, + onClose, + pantryId, +}) => { + const [orderDetailsList, setOrderDetailsList] = useState([]); + const [pantryName, setPantryName] = useState(''); + const [currentPage, setCurrentPage] = useState(1); + + const requestedSize = request.requestedSize; + const selectedItems = request.requestedItems; + const additionalNotes = request.additionalInformation; + + useEffect(() => { + const fetchRequestOrderDetails = async () => { + try { + const orderDetailsList = await apiClient.getOrderDetailsListFromRequest( + request.requestId, + ); + const sortedData = orderDetailsList + .slice() + .sort((a, b) => b.orderId - a.orderId); + setOrderDetailsList(sortedData); + } catch (error) { + console.error('Error fetching order details', error); + } + }; + fetchRequestOrderDetails(); + }, [isOpen, request.requestId]); + + useEffect(() => { + const fetchPantryData = async () => { + try { + const pantry = await apiClient.getPantry(pantryId); + setPantryName(pantry.pantryName); + } catch (error) { + console.error('Error fetching pantry data', error); + } + }; + fetchPantryData(); + }, [pantryId]); + + const currentOrder = orderDetailsList[currentPage - 1]; + + const groupedOrderItemsByType = useMemo(() => { + if (!currentOrder) return {}; + + return currentOrder.items.reduce( + (acc: Record<(typeof FoodTypes)[number], OrderItemDetails[]>, item) => { + if (!acc[item.foodType]) acc[item.foodType] = []; + acc[item.foodType].push(item); + return acc; + }, + {} as Record<(typeof FoodTypes)[number], OrderItemDetails[]>, + ); + }, [currentOrder]); + + return ( + { + if (!e.open) onClose(); + }} + closeOnInteractOutside + > + + + + + + Food Request #{request.requestId} + + + + + {pantryName} + + + + + + Request Details + + + Associated Orders + + + + + + + Size of Shipment + + + + + {requestedSize} + + + + + + + + Food Type(s) + + + + {selectedItems.length > 0 && ( + + {selectedItems.map((item) => ( + + {item} + + ))} + + )} + + + + + + Additional Information + + + + {additionalNotes} + + + + + + {currentOrder && ( + + + + Order {currentOrder.orderId} - + + {' '} + Fulfilled by {currentOrder.foodManufacturerName} + + + {currentOrder.status === OrderStatus.DELIVERED ? ( + + Received + + ) : ( + + In Progress + + )} + + {Object.entries( + groupedOrderItemsByType as Record< + string, + OrderItemDetails[] + >, + ).map(([foodType, items]) => ( + + + {foodType} + + {items.map((item) => ( + + + {item.name} + + + + + + {item.quantity} + + + ))} + + ))} + + Tracking + + + No tracking link available at this time + + + )} + + + setCurrentPage(page)} + > + + + + setCurrentPage((prev) => Math.max(prev - 1, 1)) + } + > + + + + + ( + setCurrentPage(page.value)} + > + {page.value} + + )} + /> + + + + setCurrentPage((prev) => + Math.min( + prev + 1, + Math.ceil(orderDetailsList.length), + ), + ) + } + > + + + + + + + + + + + + + + + + ); +}; + +export default RequestDetailsModal; diff --git a/apps/frontend/src/components/forms/requestFormModal.tsx b/apps/frontend/src/components/forms/requestFormModal.tsx index 348cb247..5a42a965 100644 --- a/apps/frontend/src/components/forms/requestFormModal.tsx +++ b/apps/frontend/src/components/forms/requestFormModal.tsx @@ -76,9 +76,9 @@ const FoodRequestFormModal: React.FC = ({ {readOnly - ? `Order ${previousRequest?.requestId}` + ? `Request ${previousRequest?.requestId}` : previousRequest - ? 'Resubmit Latest Order' + ? 'Resubmit Latest Request' : 'New Food Request'} @@ -100,7 +100,7 @@ const FoodRequestFormModal: React.FC = ({ previousRequest.requestedAt, ).toLocaleDateString()}` : previousRequest - ? 'Confirm order details.' + ? 'Confirm request details.' : `Please keep in mind that we may not be able to accommodate specific food requests at all times, but we will do our best to match your preferences.`} diff --git a/apps/frontend/src/containers/FormRequests.tsx b/apps/frontend/src/containers/FormRequests.tsx index 50d20601..502e0cd7 100644 --- a/apps/frontend/src/containers/FormRequests.tsx +++ b/apps/frontend/src/containers/FormRequests.tsx @@ -1,22 +1,28 @@ import React, { useEffect, useState } from 'react'; import { useParams } from 'react-router-dom'; import { - Center, + Box, Table, Text, Button, HStack, useDisclosure, - NativeSelect, + Link, + Badge, + Pagination, + ButtonGroup, + IconButton, + Flex, } from '@chakra-ui/react'; +import { ChevronRight, ChevronLeft } from 'lucide-react'; import FoodRequestFormModal from '@components/forms/requestFormModal'; -import DeliveryConfirmationModal from '@components/forms/deliveryConfirmationModal'; -import OrderInformationModal from '@components/forms/orderInformationModal'; -import { FoodRequest } from 'types/types'; -import { formatDate, formatReceivedDate } from '@utils/utils'; +import { OrderStatus, FoodRequest } from '../types/types'; +import RequestDetailsModal from '@components/forms/requestDetailsModal'; +import { formatDate } from '@utils/utils'; import ApiClient from '@api/apiClient'; const FormRequests: React.FC = () => { + const [currentPage, setCurrentPage] = useState(1); const newRequestDisclosure = useDisclosure(); const previousRequestDisclosure = useDisclosure(); @@ -24,34 +30,27 @@ const FormRequests: React.FC = () => { const [previousRequest, setPreviousRequest] = useState< FoodRequest | undefined >(undefined); - const [sortBy, setSortBy] = useState<'mostRecent' | 'oldest' | 'confirmed'>( - 'mostRecent', - ); const { pantryId: pantryIdParam } = useParams<{ pantryId: string }>(); const pantryId = parseInt(pantryIdParam!, 10); - const [allConfirmed, setAllConfirmed] = useState(false); - const [openDeliveryRequestId, setOpenDeliveryRequestId] = useState< - number | null - >(null); const [openReadOnlyRequest, setOpenReadOnlyRequest] = useState(null); - const [openOrderId, setOpenOrderId] = useState(null); + + const pageSize = 8; useEffect(() => { const fetchRequests = async () => { if (pantryId) { try { const data = await ApiClient.getPantryRequests(pantryId); - setRequests(data); + const sortedData = data + .slice() + .sort((a, b) => b.requestId - a.requestId); + setRequests(sortedData); - if (data.length > 0) { - setPreviousRequest( - data.reduce((prev, current) => - prev.requestId > current.requestId ? prev : current, - ), - ); + if (sortedData.length > 0) { + setPreviousRequest(sortedData[0]); } } catch (error) { alert('Error fetching requests: ' + error); @@ -62,33 +61,24 @@ const FormRequests: React.FC = () => { fetchRequests(); }, [pantryId]); - useEffect(() => { - setAllConfirmed(requests.every((request) => request.dateReceived !== null)); - }, [requests]); - - const sortedRequests = [...requests].sort((a, b) => { - if (sortBy === 'mostRecent') - return ( - new Date(b.requestedAt).getTime() - new Date(a.requestedAt).getTime() - ); - if (sortBy === 'oldest') - return ( - new Date(a.requestedAt).getTime() - new Date(b.requestedAt).getTime() - ); - if (sortBy === 'confirmed') - return ( - new Date(b.dateReceived || 0).getTime() - - new Date(a.dateReceived || 0).getTime() - ); - - return 0; - }); + const paginatedRequests = requests.slice( + (currentPage - 1) * pageSize, + currentPage * pageSize, + ); return ( -
- - { <> { )} - - - - setSortBy(e.target.value as 'mostRecent' | 'oldest' | 'confirmed') - } - > - - - - - - - - + - Request ID - Order ID - Date Requested - Status - Shipped By - Date Fulfilled - Actions + + Request # + + + Status + + + Date Requested + - {sortedRequests.map((request) => ( + {paginatedRequests.map((request) => ( - - + - {request.order?.orderId ? ( - - ) : ( - 'N/A' - )} - - {formatDate(request.requestedAt)} - {request.order?.status ?? 'pending'} - - {request.order?.status === 'pending' - ? 'N/A' - : request.order?.shippedBy ?? 'N/A'} - - - {formatReceivedDate(request.dateReceived)} - - - {!request.order || request.order?.status === 'pending' ? ( - Awaiting Order Assignment - ) : request.order?.status === 'delivered' ? ( - Food Request is Already Delivered + Closed + ) : ( - + Active + )} + + {formatDate(request.requestedAt)} + ))} {openReadOnlyRequest && ( - setOpenReadOnlyRequest(null)} pantryId={pantryId} /> )} - {openOrderId && ( - setOpenOrderId(null)} - /> - )} - {openDeliveryRequestId && ( - setOpenDeliveryRequestId(null)} - pantryId={pantryId} - /> - )} -
+ + setCurrentPage(page)} + > + + + setCurrentPage((prev) => Math.max(prev - 1, 1))} + > + + + + + ( + setCurrentPage(page.value)} + > + {page.value} + + )} + /> + + + + setCurrentPage((prev) => + Math.min(prev + 1, Math.ceil(requests.length / pageSize)), + ) + } + > + + + + + + + ); }; diff --git a/apps/frontend/src/types/types.ts b/apps/frontend/src/types/types.ts index c823a22c..84bab5e5 100644 --- a/apps/frontend/src/types/types.ts +++ b/apps/frontend/src/types/types.ts @@ -135,6 +135,23 @@ export const FoodTypes = [ 'Quinoa', ] as const; +export enum FoodType { + DAIRY_FREE_ALTERNATIVES = 'Dairy-Free Alternatives', + DRIED_BEANS = 'Dried Beans (Gluten-Free, Nut-Free)', + GLUTEN_FREE_BAKING_PANCAKE_MIXES = 'Gluten-Free Baking/Pancake Mixes', + GLUTEN_FREE_BREAD = 'Gluten-Free Bread', + GLUTEN_FREE_TORTILLAS = 'Gluten-Free Tortillas', + GRANOLA = 'Granola', + MASA_HARINA_FLOUR = 'Masa Harina Flour', + NUT_FREE_GRANOLA_BARS = 'Nut-Free Granola Bars', + OLIVE_OIL = 'Olive Oil', + REFRIGERATED_MEALS = 'Refrigerated Meals', + RICE_NOODLES = 'Rice Noodles', + SEED_BUTTERS = 'Seed Butters (Peanut Butter Alternative)', + WHOLE_GRAIN_COOKIES = 'Whole-Grain Cookies', + QUINOA = 'Quinoa', +} + export interface User { id: number; role: string; @@ -146,11 +163,11 @@ export interface User { } export interface UserDto { - email: string, - firstName: string, - lastName: string, - phone: string, - role: Role, + email: string; + firstName: string; + lastName: string; + phone: string; + role: Role; } export interface FoodRequest { @@ -161,7 +178,7 @@ export interface FoodRequest { requestedItems: string[]; additionalInformation: string; orderId: number; - order?: Order; + orders?: Order[]; } export interface Order { @@ -177,6 +194,19 @@ export interface Order { donationId: number; } +export interface OrderItemDetails { + name: string; + quantity: number; + foodType: FoodType; +} + +export interface OrderDetails { + orderId: number; + status: OrderStatus; + foodManufacturerName: string; + items: OrderItemDetails[]; +} + export interface FoodManufacturer { foodManufacturerId: number; foodManufacturerName: string;