diff --git a/apps/backend/src/foodRequests/dtos/order-details.dto.ts b/apps/backend/src/foodRequests/dtos/order-details.dto.ts index 21d360ec..d650119f 100644 --- a/apps/backend/src/foodRequests/dtos/order-details.dto.ts +++ b/apps/backend/src/foodRequests/dtos/order-details.dto.ts @@ -11,5 +11,6 @@ export class OrderDetailsDto { orderId: number; status: OrderStatus; foodManufacturerName: string; + trackingLink?: string; items: OrderItemDetailsDto[]; } diff --git a/apps/backend/src/foodRequests/request.controller.spec.ts b/apps/backend/src/foodRequests/request.controller.spec.ts index 46772a4c..03f95c3f 100644 --- a/apps/backend/src/foodRequests/request.controller.spec.ts +++ b/apps/backend/src/foodRequests/request.controller.spec.ts @@ -78,13 +78,14 @@ describe('RequestsController', () => { }); }); - describe('GET /all-order-details/:requestId', () => { + describe('GET /:requestId/order-details', () => { 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', + trackingLink: 'examplelink.com', items: [ { name: 'Rice', @@ -102,6 +103,7 @@ describe('RequestsController', () => { orderId: 11, status: OrderStatus.PENDING, foodManufacturerName: 'Another Manufacturer', + trackingLink: 'examplelink.com', items: [ { name: 'Milk', diff --git a/apps/backend/src/foodRequests/request.service.ts b/apps/backend/src/foodRequests/request.service.ts index 0deee010..d7abdf1f 100644 --- a/apps/backend/src/foodRequests/request.service.ts +++ b/apps/backend/src/foodRequests/request.service.ts @@ -66,6 +66,7 @@ export class RequestsService { orderId: order.orderId, status: order.status, foodManufacturerName: order.foodManufacturer.foodManufacturerName, + trackingLink: order.trackingLink, items: order.allocations.map((allocation) => ({ name: allocation.item.itemName, quantity: allocation.allocatedQuantity, diff --git a/apps/backend/src/orders/dtos/food-request-summary.dto.ts b/apps/backend/src/orders/dtos/food-request-summary.dto.ts new file mode 100644 index 00000000..b8f14f32 --- /dev/null +++ b/apps/backend/src/orders/dtos/food-request-summary.dto.ts @@ -0,0 +1,12 @@ +import { FoodRequestStatus, RequestSize } from '../../foodRequests/types'; + +export class FoodRequestSummaryDto { + requestId!: number; + pantryId!: number; + pantryName!: string; + requestedSize!: RequestSize; + requestedItems!: string[]; + additionalInformation?: string; + requestedAt!: Date; + status!: FoodRequestStatus; +} diff --git a/apps/backend/src/orders/order.controller.spec.ts b/apps/backend/src/orders/order.controller.spec.ts index 5267ceed..75ce664c 100644 --- a/apps/backend/src/orders/order.controller.spec.ts +++ b/apps/backend/src/orders/order.controller.spec.ts @@ -10,8 +10,11 @@ import { FoodRequest } from '../foodRequests/request.entity'; import { Pantry } from '../pantries/pantries.entity'; import { AWSS3Service } from '../aws/aws-s3.service'; import { TrackingCostDto } from './dtos/tracking-cost.dto'; +import { OrderDetailsDto } from '../foodRequests/dtos/order-details.dto'; +import { FoodType } from '../donationItems/types'; import { BadRequestException, NotFoundException } from '@nestjs/common'; import { FoodManufacturer } from '../foodManufacturers/manufacturers.entity'; +import { FoodRequestSummaryDto } from './dtos/food-request-summary.dto'; import { ConfirmDeliveryDto } from './dtos/confirm-delivery.dto'; const mockOrdersService = mock(); @@ -33,6 +36,11 @@ describe('OrdersController', () => { { requestId: 3, pantry: mockPantries[2] as Pantry }, ]; + const mockRequestSummary: Partial = { + requestId: 4, + pantryName: 'Example Pantry', + }; + const mockFoodManufacturer: Partial = { foodManufacturerId: 1, foodManufacturerName: 'Test FM', @@ -65,6 +73,20 @@ describe('OrdersController', () => { { allocationId: 3, orderId: 2 }, ]; + const mockOrderDetails: Partial = { + orderId: 1, + status: OrderStatus.DELIVERED, + foodManufacturerName: 'food manufacturer 1', + trackingLink: 'example-link.com', + items: [ + { + name: 'item1', + quantity: 10, + foodType: FoodType.DAIRY_FREE_ALTERNATIVES, + }, + ], + }; + beforeEach(async () => { const module: TestingModule = await Test.createTestingModule({ controllers: [OrdersController], @@ -82,6 +104,21 @@ describe('OrdersController', () => { expect(controller).toBeDefined(); }); + describe('getOrder', () => { + it('should call ordersService.findOrderDetails and return order details', async () => { + mockOrdersService.findOrderDetails.mockResolvedValueOnce( + mockOrderDetails as OrderDetailsDto, + ); + + const orderId = 1; + + const result = await controller.getOrder(orderId); + + expect(result).toEqual(mockOrderDetails as OrderDetailsDto); + expect(mockOrdersService.findOrderDetails).toHaveBeenCalledWith(orderId); + }); + }); + describe('getAllOrders', () => { it('should call ordersService.getAll and return orders', async () => { const status = 'pending'; @@ -159,12 +196,12 @@ describe('OrdersController', () => { it('should call ordersService.findOrderFoodRequest and return food request', async () => { const orderId = 1; mockOrdersService.findOrderFoodRequest.mockResolvedValueOnce( - mockRequests[0] as FoodRequest, + mockRequestSummary as FoodRequestSummaryDto, ); const result = await controller.getRequestFromOrder(orderId); - expect(result).toEqual(mockRequests[0] as FoodRequest); + expect(result).toEqual(mockRequestSummary as FoodRequestSummaryDto); expect(mockOrdersService.findOrderFoodRequest).toHaveBeenCalledWith( orderId, ); @@ -215,30 +252,6 @@ describe('OrdersController', () => { }); }); - describe('getOrder', () => { - it('should call ordersService.findOne and return order', async () => { - const orderId = 1; - mockOrdersService.findOne.mockResolvedValueOnce(mockOrders[0] as Order); - - const result = await controller.getOrder(orderId); - - expect(result).toEqual(mockOrders[0] as Order); - expect(mockOrdersService.findOne).toHaveBeenCalledWith(orderId); - }); - - it('should propagate NotFoundException when order not found', async () => { - const orderId = 999; - mockOrdersService.findOne.mockRejectedValueOnce( - new NotFoundException(`Order ${orderId} not found`), - ); - - const promise = controller.getOrder(orderId); - await expect(promise).rejects.toBeInstanceOf(NotFoundException); - await expect(promise).rejects.toThrow(`Order ${orderId} not found`); - expect(mockOrdersService.findOne).toHaveBeenCalledWith(orderId); - }); - }); - describe('getOrderByRequestId', () => { it('should call ordersService.findOrderByRequest and return order', async () => { const requestId = 1; diff --git a/apps/backend/src/orders/order.controller.ts b/apps/backend/src/orders/order.controller.ts index e075e985..6bf0eb31 100644 --- a/apps/backend/src/orders/order.controller.ts +++ b/apps/backend/src/orders/order.controller.ts @@ -17,10 +17,11 @@ import { OrdersService } from './order.service'; import { Order } from './order.entity'; import { Pantry } from '../pantries/pantries.entity'; import { FoodManufacturer } from '../foodManufacturers/manufacturers.entity'; -import { FoodRequest } from '../foodRequests/request.entity'; import { AllocationsService } from '../allocations/allocations.service'; import { OrderStatus } from './types'; import { TrackingCostDto } from './dtos/tracking-cost.dto'; +import { OrderDetailsDto } from '../foodRequests/dtos/order-details.dto'; +import { FoodRequestSummaryDto } from './dtos/food-request-summary.dto'; import { AWSS3Service } from '../aws/aws-s3.service'; import { FilesInterceptor } from '@nestjs/platform-express'; import * as multer from 'multer'; @@ -68,7 +69,7 @@ export class OrdersController { @Get('/:orderId/request') async getRequestFromOrder( @Param('orderId', ParseIntPipe) orderId: number, - ): Promise { + ): Promise { return this.ordersService.findOrderFoodRequest(orderId); } @@ -82,8 +83,8 @@ export class OrdersController { @Get('/:orderId') async getOrder( @Param('orderId', ParseIntPipe) orderId: number, - ): Promise { - return this.ordersService.findOne(orderId); + ): Promise { + return this.ordersService.findOrderDetails(orderId); } @Get('/order/:requestId') diff --git a/apps/backend/src/orders/order.service.spec.ts b/apps/backend/src/orders/order.service.spec.ts index a672cd29..e164fe36 100644 --- a/apps/backend/src/orders/order.service.spec.ts +++ b/apps/backend/src/orders/order.service.spec.ts @@ -5,8 +5,10 @@ import { Order } from './order.entity'; import { testDataSource } from '../config/typeormTestDataSource'; import { OrderStatus } from './types'; import { Pantry } from '../pantries/pantries.entity'; +import { OrderDetailsDto } from '../foodRequests/dtos/order-details.dto'; import { BadRequestException, NotFoundException } from '@nestjs/common'; import { TrackingCostDto } from './dtos/tracking-cost.dto'; +import { FoodType } from '../donationItems/types'; import { FoodRequest } from '../foodRequests/request.entity'; import 'multer'; import { FoodRequestStatus } from '../foodRequests/types'; @@ -133,6 +135,59 @@ describe('OrdersService', () => { }); }); + describe('findOrderDetails', () => { + it('returns mapped OrderDetailsDto including allocations and manufacturer', async () => { + const orderId = 1; + + const result = await service.findOrderDetails(orderId); + + const expected: OrderDetailsDto = { + orderId: 1, + status: OrderStatus.DELIVERED, + foodManufacturerName: 'FoodCorp Industries', + trackingLink: 'www.samplelink/samplelink', + items: [ + { + foodType: FoodType.SEED_BUTTERS, + name: 'Peanut Butter (16oz)', + quantity: 10, + }, + { + foodType: FoodType.REFRIGERATED_MEALS, + name: 'Canned Green Beans', + quantity: 5, + }, + { + foodType: FoodType.GLUTEN_FREE_BREAD, + name: 'Whole Wheat Bread', + quantity: 25, + }, + ], + }; + + expect(result).toMatchObject({ + orderId: expected.orderId, + status: expected.status, + foodManufacturerName: expected.foodManufacturerName, + trackingLink: expected.trackingLink, + }); + + expect(result.items).toHaveLength(expected.items.length); + expect(result.items).toEqual(expect.arrayContaining(expected.items)); + }); + + it('throws NotFoundException when order does not exist', async () => { + const missingOrderId = 99999999; + + await expect(service.findOrderDetails(missingOrderId)).rejects.toThrow( + NotFoundException, + ); + await expect(service.findOrderDetails(missingOrderId)).rejects.toThrow( + `Order ${missingOrderId} not found`, + ); + }); + }); + describe('getCurrentOrders', () => { it(`returns only orders with status 'pending' or 'shipped'`, async () => { const orders = await service.getCurrentOrders(); diff --git a/apps/backend/src/orders/order.service.ts b/apps/backend/src/orders/order.service.ts index 8b62a51a..b6176620 100644 --- a/apps/backend/src/orders/order.service.ts +++ b/apps/backend/src/orders/order.service.ts @@ -8,10 +8,11 @@ import { Repository, In } from 'typeorm'; import { Order } from './order.entity'; import { Pantry } from '../pantries/pantries.entity'; import { FoodManufacturer } from '../foodManufacturers/manufacturers.entity'; -import { FoodRequest } from '../foodRequests/request.entity'; import { validateId } from '../utils/validation.utils'; import { OrderStatus } from './types'; import { TrackingCostDto } from './dtos/tracking-cost.dto'; +import { OrderDetailsDto } from '../foodRequests/dtos/order-details.dto'; +import { FoodRequestSummaryDto } from './dtos/food-request-summary.dto'; import { ConfirmDeliveryDto } from './dtos/confirm-delivery.dto'; import { RequestsService } from '../foodRequests/request.service'; @@ -80,6 +81,36 @@ export class OrdersService { return order; } + async findOrderDetails(orderId: number): Promise { + validateId(orderId, 'Order'); + + const order = await this.repo.findOne({ + where: { orderId }, + relations: { + allocations: { + item: true, + }, + foodManufacturer: true, + }, + }); + + if (!order) { + throw new NotFoundException(`Order ${orderId} not found`); + } + + return { + orderId: order.orderId, + status: order.status, + foodManufacturerName: order.foodManufacturer.foodManufacturerName, + trackingLink: order.trackingLink, + items: order.allocations.map((allocation) => ({ + name: allocation.item.itemName, + quantity: allocation.allocatedQuantity, + foodType: allocation.item.foodType, + })), + }; + } + async findOrderByRequest(requestId: number): Promise { validateId(requestId, 'Request'); @@ -109,18 +140,50 @@ export class OrdersService { return pantry; } - async findOrderFoodRequest(orderId: number): Promise { + async findOrderFoodRequest(orderId: number): Promise { validateId(orderId, 'Order'); const order = await this.repo.findOne({ where: { orderId }, - relations: ['request'], + relations: { + request: { + pantry: true, + }, + }, + select: { + request: { + requestId: true, + pantryId: true, + requestedSize: true, + requestedItems: true, + additionalInformation: true, + requestedAt: true, + status: true, + pantry: { + pantryName: true, + }, + }, + }, }); if (!order) { throw new NotFoundException(`Order ${orderId} not found`); } - return order.request; + + return { + requestId: order.request.requestId, + pantryId: order.request.pantryId, + pantryName: order.request.pantry.pantryName, + + requestedSize: order.request.requestedSize, + requestedItems: order.request.requestedItems, + + additionalInformation: order.request.additionalInformation ?? null, + + requestedAt: order.request.requestedAt, + + status: order.request.status, + }; } async findOrderFoodManufacturer(orderId: number): Promise { diff --git a/apps/frontend/src/api/apiClient.ts b/apps/frontend/src/api/apiClient.ts index 25a96a7b..9fa7e815 100644 --- a/apps/frontend/src/api/apiClient.ts +++ b/apps/frontend/src/api/apiClient.ts @@ -20,6 +20,7 @@ import { OrderSummary, UserDto, OrderDetails, + FoodRequestSummaryDto, } from 'types/types'; const defaultBaseUrl = @@ -172,7 +173,7 @@ export class ApiClient { public async getFoodRequestFromOrder( orderId: number, - ): Promise { + ): Promise { return this.axiosInstance .get(`/api/orders/${orderId}/request`) .then((response) => response.data); @@ -237,10 +238,6 @@ export class ApiClient { .then((response) => response.data); } - public async getOrder(orderId: number): Promise { - return this.axiosInstance.get(`/api/orders/${orderId}`) as Promise; - } - public async getOrderDetailsListFromRequest( requestId: number, ): Promise { @@ -249,6 +246,12 @@ export class ApiClient { .then((response) => response.data) as Promise; } + public async getOrder(orderId: number): Promise { + return this.axiosInstance + .get(`/api/orders/${orderId}`) + .then((response) => response.data) as Promise; + } + async getAllAllocationsByOrder(orderId: number): Promise { return this.axiosInstance .get(`/api/orders/${orderId}/allocations`) diff --git a/apps/frontend/src/components/forms/orderDetailsModal.tsx b/apps/frontend/src/components/forms/orderDetailsModal.tsx index 309e1599..1fe83248 100644 --- a/apps/frontend/src/components/forms/orderDetailsModal.tsx +++ b/apps/frontend/src/components/forms/orderDetailsModal.tsx @@ -1,29 +1,48 @@ import React, { useState, useEffect } from 'react'; -import { Text, Dialog, CloseButton, Textarea, Field } from '@chakra-ui/react'; +import { + Text, + Dialog, + CloseButton, + Flex, + Field, + Box, + Badge, + Tabs, + Menu, + Link, +} from '@chakra-ui/react'; import ApiClient from '@api/apiClient'; -import { FoodRequest, OrderSummary } from 'types/types'; -import { formatDate } from '@utils/utils'; +import { + FoodRequestSummaryDto, + GroupedByFoodType, + OrderDetails, +} from 'types/types'; +import { FoodRequestStatus } from '../../types/types'; import { TagGroup } from './tagGroup'; +import { useGroupedItemsByFoodType } from '../../hooks/groupedItemsByType'; interface OrderDetailsModalProps { - order: OrderSummary; + orderId: number; isOpen: boolean; onClose: () => void; } const OrderDetailsModal: React.FC = ({ - order, + orderId, isOpen, onClose, }) => { - const [foodRequest, setFoodRequest] = useState(null); + const [foodRequest, setFoodRequest] = useState( + null, + ); + const [orderDetails, setOrderDetails] = useState(null); useEffect(() => { if (isOpen) { - const fetchData = async () => { + const fetchRequestData = async () => { try { const foodRequestData = await ApiClient.getFoodRequestFromOrder( - order.orderId, + orderId, ); setFoodRequest(foodRequestData); } catch (error) { @@ -31,9 +50,41 @@ const OrderDetailsModal: React.FC = ({ } }; - fetchData(); + fetchRequestData(); + } + }, [isOpen, orderId]); + + useEffect(() => { + if (isOpen) { + const fetchOrderDetails = async () => { + try { + const orderDetailsData = await ApiClient.getOrder(orderId); + setOrderDetails(orderDetailsData); + } catch (error) { + alert('Error fetching order details:' + error); + } + }; + + fetchOrderDetails(); } - }, [isOpen, order.orderId]); + }, [isOpen, orderId]); + + const groupedOrderItemsByType: GroupedByFoodType = + useGroupedItemsByFoodType(orderDetails); + + const sectionTitleStyles = { + textStyle: 'p2', + fontWeight: '600', + color: 'neutral.800', + }; + + const badgeStyles = { + py: '1', + px: '2', + textStyle: 'p2', + fontSize: '12px', + fontWeight: '500', + }; return ( = ({ - - Order {order.orderId} + + Order #{orderId} - {foodRequest && ( - <> - - {order.request.pantry.pantryName} - - - Requested {formatDate(foodRequest.requestedAt)} - + + Fulfilled by {orderDetails?.foodManufacturerName} + + + + + + Order Details + + + Associated Request + + + + {!foodRequest && ( + + {' '} + No associated food request to display{' '} + + )} - - - - Size of Shipment - - - - {foodRequest.requestedSize} + + + Request {foodRequest.requestId} - + + {' '} + {foodRequest.pantryName} + + + {foodRequest?.status === FoodRequestStatus.CLOSED ? ( + + Closed + + ) : ( + + Active + + )} + + + + + Size of Shipment + + + + {foodRequest.requestedSize} + + + + + + + + Food Type(s) + + + + {foodRequest.requestedItems.length > 0 && ( + + )} + + + + + + Additional Information + + + + {foodRequest.additionalInformation} + + + + )} + + + + {Object.entries(groupedOrderItemsByType).map( + ([foodType, items]) => ( + + {foodType} + {items.map((item, idx) => ( + + + {item.name} + + + + + + {item.quantity} + + + ))} + + ), + )} + + Tracking + + {orderDetails?.trackingLink ? ( + + {orderDetails.trackingLink} + + ) : ( + + No tracking link available at this time - - - - - - Food Type(s) - - - - - - - - - Additional Information - - -