diff --git a/apps/backend/src/donationItems/donationItems.service.ts b/apps/backend/src/donationItems/donationItems.service.ts index 311ee8a51..c5a273f84 100644 --- a/apps/backend/src/donationItems/donationItems.service.ts +++ b/apps/backend/src/donationItems/donationItems.service.ts @@ -35,6 +35,15 @@ export class DonationItemsService { return this.repo.find({ where: { donation: { donationId } } }); } + async getAllForManufacturer( + foodManufacturerId: number, + ): Promise { + validateId(foodManufacturerId, 'Manufacturer'); + return this.repo.find({ + where: { donation: { foodManufacturer: { foodManufacturerId } } }, + }); + } + async getByIds(donationItemIds: number[]): Promise { donationItemIds.forEach((id) => validateId(id, 'Donation Item')); diff --git a/apps/backend/src/donations/donations.service.ts b/apps/backend/src/donations/donations.service.ts index 75dee06dd..817913266 100644 --- a/apps/backend/src/donations/donations.service.ts +++ b/apps/backend/src/donations/donations.service.ts @@ -495,6 +495,7 @@ export class DonationService { : this.allocationRepo; for (const donationId of donationIds) { + validateId(donationId, 'Donation'); const donation = await donationRepo.findOne({ where: { donationId }, }); diff --git a/apps/backend/src/orders/dtos/order-donation-item.dto.ts b/apps/backend/src/orders/dtos/order-donation-item.dto.ts new file mode 100644 index 000000000..1924bbd79 --- /dev/null +++ b/apps/backend/src/orders/dtos/order-donation-item.dto.ts @@ -0,0 +1,9 @@ +import { FoodType } from '../../donationItems/types'; + +export class OrderDonationItemDto { + itemId!: number; + itemName!: string; + foodType!: FoodType; + quantity!: number; + reservedQuantity!: number; +} diff --git a/apps/backend/src/orders/order.controller.spec.ts b/apps/backend/src/orders/order.controller.spec.ts index 2ecb6821a..fd1f7ed48 100644 --- a/apps/backend/src/orders/order.controller.spec.ts +++ b/apps/backend/src/orders/order.controller.spec.ts @@ -124,6 +124,32 @@ describe('OrdersController', () => { }); }); + describe('getOrderDonationItems', () => { + it('should call ordersService.getManufacturerDonationItems and return its result', async () => { + const items = [ + { + itemId: 1, + itemName: 'Peanut Butter (16oz)', + foodType: FoodType.DAIRY_FREE_ALTERNATIVES, + quantity: 100, + reservedQuantity: 10, + }, + ]; + mockOrdersService.getManufacturerDonationItems.mockResolvedValueOnce( + items, + ); + + const orderId = 1; + + const result = await controller.getOrderDonationItems(orderId); + + expect(result).toEqual(items); + expect( + mockOrdersService.getManufacturerDonationItems, + ).toHaveBeenCalledWith(orderId); + }); + }); + describe('getAllOrders', () => { it('should call ordersService.getAll and return orders', async () => { const status = 'pending'; diff --git a/apps/backend/src/orders/order.controller.ts b/apps/backend/src/orders/order.controller.ts index eec2205e7..28a5e2fd2 100644 --- a/apps/backend/src/orders/order.controller.ts +++ b/apps/backend/src/orders/order.controller.ts @@ -32,6 +32,7 @@ import { DonationService } from '../donations/donations.service'; import { Donation } from '../donations/donations.entity'; import { BulkUpdateTrackingCostDto } from './dtos/bulk-update-tracking-cost.dto'; import { OrderDetailsDto } from './dtos/order-details.dto'; +import { OrderDonationItemDto } from './dtos/order-donation-item.dto'; import { FoodRequestSummaryDto } from '../foodRequests/dtos/food-request-summary.dto'; import { AWSS3Service } from '../aws/aws-s3.service'; import { FilesInterceptor } from '@nestjs/platform-express'; @@ -133,6 +134,18 @@ export class OrdersController { return this.ordersService.findOrderDetails(orderId); } + @CheckOwnership({ + idParam: 'orderId', + resolver: resolveOrderAuthorizedUserIds, + }) + @Roles(Role.VOLUNTEER) + @Get('/:orderId/donation-items') + async getOrderDonationItems( + @Param('orderId', ParseIntPipe) orderId: number, + ): Promise { + return this.ordersService.getManufacturerDonationItems(orderId); + } + @Roles(Role.ADMIN, Role.VOLUNTEER) @CheckOwnership({ idParam: 'foodRequestId', diff --git a/apps/backend/src/orders/order.service.spec.ts b/apps/backend/src/orders/order.service.spec.ts index 6c1b48826..d6724d7b4 100644 --- a/apps/backend/src/orders/order.service.spec.ts +++ b/apps/backend/src/orders/order.service.spec.ts @@ -53,6 +53,7 @@ describe('OrdersService', () => { let service: OrdersService; let donationService: DonationService; let allocationsService: AllocationsService; + let donationItemsService: DonationItemsService; beforeAll(async () => { mockEmailsService.sendEmails.mockResolvedValue(undefined); @@ -130,6 +131,8 @@ describe('OrdersService', () => { service = module.get(OrdersService); donationService = module.get(DonationService); allocationsService = module.get(AllocationsService); + donationItemsService = + module.get(DonationItemsService); }); beforeEach(async () => { @@ -313,6 +316,72 @@ describe('OrdersService', () => { }); }); + describe('getManufacturerDonationItems', () => { + it("returns the order manufacturer's donation items with quantity and reservedQuantity", async () => { + const orderId = 1; + const order = await service.findOne(orderId); + + const result = await service.getManufacturerDonationItems(orderId); + const expectedItems = await donationItemsService.getAllForManufacturer( + order.foodManufacturerId, + ); + + expect(result).toHaveLength(expectedItems.length); + expect(result).toEqual( + expect.arrayContaining( + expectedItems.map((item) => ({ + itemId: item.itemId, + itemName: item.itemName, + foodType: item.foodType, + quantity: item.quantity, + reservedQuantity: item.reservedQuantity, + })), + ), + ); + }); + + it('includes fully-reserved items (does not filter by availability)', async () => { + const orderId = 1; + const order = await service.findOne(orderId); + + const fullyReserved = { + itemId: 999, + itemName: 'Fully Reserved Item', + foodType: FoodType.SPREADS_SEED_BUTTERS, + quantity: 10, + reservedQuantity: 10, + donationId: 1, + } as DonationItem; + + const spy = jest + .spyOn(donationItemsService, 'getAllForManufacturer') + .mockResolvedValue([fullyReserved]); + + const result = await service.getManufacturerDonationItems(orderId); + + expect(spy).toHaveBeenCalledWith(order.foodManufacturerId); + expect(result).toEqual([ + { + itemId: 999, + itemName: 'Fully Reserved Item', + foodType: FoodType.SPREADS_SEED_BUTTERS, + quantity: 10, + reservedQuantity: 10, + }, + ]); + + spy.mockRestore(); + }); + + it('throws NotFoundException when order does not exist', async () => { + const missingOrderId = 99999999; + + await expect( + service.getManufacturerDonationItems(missingOrderId), + ).rejects.toThrow(NotFoundException); + }); + }); + describe('findOne', () => { it('returns order by ID', async () => { const orderId = 1; @@ -1246,7 +1315,7 @@ ${request.pantry.shipmentAddressCity}, ${request.pantry.shipmentAddressState} ${ }; }; - it('sets the order status to CLOSED on success', async () => { + it('sets the order status to CLOSED when everything succeeds', async () => { const { orderId } = await createPendingOrder(); await service.closeOrder(orderId); @@ -1540,26 +1609,29 @@ ${request.pantry.shipmentAddressCity}, ${request.pantry.shipmentAddressState} ${ }); describe('getAllOrdersForVolunteer', () => { - it('should return all orders across all pantries and assignees, with required actions for assigned orders', async () => { + it('should return only orders assigned to the volunteer, each with actionCompletion', async () => { const volunteerId = 6; - const result = await service.getAllOrdersForVolunteer(volunteerId); + // assign all seed orders away from volunteer 6, then a single order back to them + await testDataSource.query( + `UPDATE orders SET assignee_id = (SELECT user_id FROM users WHERE role = 'volunteer' AND user_id != 6 LIMIT 1)`, + ); + await testDataSource.query( + `UPDATE orders SET assignee_id = 6 WHERE order_id = 4`, + ); - expect(result).toHaveLength(4); + const result = await service.getAllOrdersForVolunteer(volunteerId); - const assignedOrder = result.find((o) => o.assignee.id === volunteerId); - expect(assignedOrder?.actionCompletion).toEqual({ + expect(result).toHaveLength(1); + expect(result.every((o) => o.assignee.id === volunteerId)).toBe(true); + expect(result[0].actionCompletion).toEqual({ confirmDonationReceipt: false, notifyPantry: false, }); - - const notAssignedOrder = result.find( - (o) => o.assignee.id !== volunteerId, - ); - expect(notAssignedOrder?.actionCompletion).toBeUndefined(); }); it('should map the rest of the data correctly', async () => { const volunteerId = 6; + await testDataSource.query(`UPDATE orders SET assignee_id = 6`); const result = await service.getAllOrdersForVolunteer(volunteerId); const order = result.find((o) => o.orderId === 4); diff --git a/apps/backend/src/orders/order.service.ts b/apps/backend/src/orders/order.service.ts index ad017e692..6e5dbab9b 100644 --- a/apps/backend/src/orders/order.service.ts +++ b/apps/backend/src/orders/order.service.ts @@ -14,6 +14,7 @@ import { DonationService } from '../donations/donations.service'; import { OrderStatus, VolunteerAction } from './types'; import { BulkUpdateTrackingCostDto } from './dtos/bulk-update-tracking-cost.dto'; import { OrderDetailsDto } from './dtos/order-details.dto'; +import { OrderDonationItemDto } from './dtos/order-donation-item.dto'; import { UpdateAllocationsDto } from './dtos/update-allocations.dto'; import { FoodRequestSummaryDto } from '../foodRequests/dtos/food-request-summary.dto'; import { ConfirmDeliveryDto } from './dtos/confirm-delivery.dto'; @@ -86,8 +87,7 @@ export class OrdersService { return qb.getMany(); } - // returns ALL orders (not scoped to volunteer) - // for orders assigned to the given volunteer, includes actionCompletion (otherwise undefined) + // returns all orders assigned to the given volunteer, each with actionCompletion async getAllOrdersForVolunteer( volunteerId: number, ): Promise { @@ -110,27 +110,23 @@ export class OrdersService { 'assignee.firstName', 'assignee.lastName', ]) + .where('order.assigneeId = :volunteerId', { volunteerId }) .getMany(); - return orders.map((o) => { - const { assignee, confirmDonationReceipt, notifyPantry } = o; - const actionCompletion = - assignee.id === volunteerId - ? { confirmDonationReceipt, notifyPantry } - : undefined; - - return { - orderId: o.orderId, - status: o.status, - createdAt: o.createdAt, - shippedAt: o.shippedAt, - deliveredAt: o.deliveredAt, - pantryId: o.request.pantryId, - pantryName: o.request.pantry.pantryName, - assignee: o.assignee, - actionCompletion, - }; - }); + return orders.map((o) => ({ + orderId: o.orderId, + status: o.status, + createdAt: o.createdAt, + shippedAt: o.shippedAt, + deliveredAt: o.deliveredAt, + pantryId: o.request.pantryId, + pantryName: o.request.pantry.pantryName, + assignee: o.assignee, + actionCompletion: { + confirmDonationReceipt: o.confirmDonationReceipt, + notifyPantry: o.notifyPantry, + }, + })); } async getRecentOrdersByAssignee( @@ -393,6 +389,29 @@ ${request.pantry.shipmentAddressCity}, ${request.pantry.shipmentAddressState} ${ return order; } + async getManufacturerDonationItems( + orderId: number, + ): Promise { + validateId(orderId, 'Order'); + + const order = await this.repo.findOneBy({ orderId }); + if (!order) { + throw new NotFoundException(`Order ${orderId} not found`); + } + + const items = await this.donationItemsService.getAllForManufacturer( + order.foodManufacturerId, + ); + + return items.map((item) => ({ + itemId: item.itemId, + itemName: item.itemName, + foodType: item.foodType, + quantity: item.quantity, + reservedQuantity: item.reservedQuantity, + })); + } + async findOrderDetails(orderId: number): Promise { validateId(orderId, 'Order'); diff --git a/apps/frontend/src/api/apiClient.ts b/apps/frontend/src/api/apiClient.ts index 093122a9e..7ea6633c4 100644 --- a/apps/frontend/src/api/apiClient.ts +++ b/apps/frontend/src/api/apiClient.ts @@ -44,6 +44,8 @@ import { UpdateFoodRequestBody, DonationReminderDto, ReplaceDonationItemDto, + OrderDonationItemDto, + UpdateAllocationsDto, } from 'types/types'; const defaultBaseUrl = @@ -347,6 +349,25 @@ export class ApiClient { .then((response) => response.data); } + public async getOrderDonationItems( + orderId: number, + ): Promise { + return this.axiosInstance + .get(`/api/orders/${orderId}/donation-items`) + .then((response) => response.data); + } + + public async editAllocations( + orderId: number, + dto: UpdateAllocationsDto, + ): Promise { + await this.axiosInstance.patch(`/api/orders/${orderId}/allocations`, dto); + } + + public async closeOrder(orderId: number): Promise { + await this.axiosInstance.patch(`/api/orders/${orderId}/close`, {}); + } + public async updatePantryApplicationData( pantryId: number, data: UpdatePantryApplicationDto, diff --git a/apps/frontend/src/components/forms/orderDetailsModal.tsx b/apps/frontend/src/components/forms/orderDetailsModal.tsx index c7a25518a..570602a78 100644 --- a/apps/frontend/src/components/forms/orderDetailsModal.tsx +++ b/apps/frontend/src/components/forms/orderDetailsModal.tsx @@ -1,4 +1,4 @@ -import React, { useState, useEffect } from 'react'; +import React, { useState, useEffect, useMemo } from 'react'; import { Text, Dialog, @@ -10,41 +10,68 @@ import { Tabs, Menu, Link, + Input, + Button, } from '@chakra-ui/react'; import ApiClient from '@api/apiClient'; import { FoodRequestSummaryDto, OrderItemDetailsGroupedByFoodType, OrderDetails, + OrderDonationItemDto, } from 'types/types'; -import { FoodRequestStatus, AlertStatus } from '../../types/types'; +import { + FoodRequestStatus, + AlertStatus, + OrderStatus, + Role, + User, + FoodType, +} from '../../types/types'; import { TagGroup } from './tagGroup'; import { useGroupedItemsByFoodType } from '../../hooks/groupedItemsByFoodType'; import { FloatingAlert } from '@components/floatingAlert'; import { useAlert } from '../../hooks/alert'; import { useModalBodyCleanup } from '../../hooks/modalBodyCleanup'; +import { EditButton, DeleteButton } from '@components/editDeleteButtons'; interface OrderDetailsModalProps { - orderId: number; + orderId: number | null; isOpen: boolean; onClose: () => void; + onSuccess?: () => void; + onDelete?: (order: OrderDetails) => void; } const OrderDetailsModal: React.FC = ({ orderId, isOpen, onClose, + onSuccess, + onDelete, }) => { useModalBodyCleanup(); const [foodRequest, setFoodRequest] = useState( null, ); const [orderDetails, setOrderDetails] = useState(null); + const [currentUser, setCurrentUser] = useState(null); + + useEffect(() => { + const fetchUser = async () => { + try { + setCurrentUser(await ApiClient.getMe()); + } catch { + setCurrentUser(null); + } + }; + fetchUser(); + }, []); const [alertState, setAlertMessage] = useAlert(); useEffect(() => { - if (isOpen) { + if (isOpen && orderId !== null) { const fetchRequestData = async () => { try { const foodRequestData = await ApiClient.getFoodRequestFromOrder( @@ -64,7 +91,7 @@ const OrderDetailsModal: React.FC = ({ }, [isOpen, orderId, setAlertMessage]); useEffect(() => { - if (isOpen) { + if (isOpen && orderId !== null) { const fetchOrderDetails = async () => { try { const orderDetailsData = await ApiClient.getOrder(orderId); @@ -81,6 +108,73 @@ const OrderDetailsModal: React.FC = ({ const groupedOrderItemsByType: OrderItemDetailsGroupedByFoodType = useGroupedItemsByFoodType(orderDetails?.items); + const [isEditing, setIsEditing] = useState(false); + const [manufacturerItems, setManufacturerItems] = useState< + OrderDonationItemDto[] + >([]); + const [itemAllocations, setItemAllocations] = useState< + Record + >({}); + // The item whose allocation box is currently being edited (focused). + const [editingItemId, setEditingItemId] = useState(null); + + const groupedManufacturerItems = useGroupedItemsByFoodType(manufacturerItems); + + // This order's current allocation per item, keyed by itemId + const currentAllocations = useMemo(() => { + const map: Record = {}; + orderDetails?.items.forEach((item) => { + map[item.id] = item.quantity; + }); + return map; + }, [orderDetails]); + + const handleEdit = async () => { + if (orderId === null) return; + try { + const items = await ApiClient.getOrderDonationItems(orderId); + setManufacturerItems(items); + const initial: Record = {}; + items.forEach((item) => { + initial[item.itemId] = currentAllocations[item.itemId] ?? 0; + }); + setItemAllocations(initial); + setIsEditing(true); + } catch { + setAlertMessage('Error loading donation items', AlertStatus.ERROR); + } + }; + + const handleCancel = () => { + setItemAllocations({}); + setManufacturerItems([]); + setIsEditing(false); + }; + + // Only allocations of at least 1 are sent; omitted items (0) are freed/deleted by the backend. + const allocationsBody = Object.entries(itemAllocations) + .filter(([, quantity]) => quantity >= 1) + .map(([itemId, quantity]) => ({ + donationItemId: Number(itemId), + allocatedQuantity: quantity, + })); + + const handleSave = async () => { + if (orderId === null) return; + try { + await ApiClient.editAllocations(orderId, { + allocations: allocationsBody, + }); + const refreshed = await ApiClient.getOrder(orderId); + setOrderDetails(refreshed); + onSuccess?.(); + setIsEditing(false); + setAlertMessage('Successfully updated order.', AlertStatus.INFO); + } catch { + setAlertMessage('Order could not be updated.', AlertStatus.ERROR); + } + }; + const sectionTitleStyles = { textStyle: 'p2', fontWeight: '600', @@ -100,7 +194,10 @@ const OrderDetailsModal: React.FC = ({ open={isOpen} size="xl" onOpenChange={(e: { open: boolean }) => { - if (!e.open) onClose(); + if (!e.open) { + handleCancel(); + onClose(); + } }} closeOnInteractOutside > @@ -113,195 +210,327 @@ const OrderDetailsModal: React.FC = ({ /> )} - - - - - Order #{orderId} - - - - - Fulfilled by {orderDetails?.foodManufacturerName} - + {orderId !== null && ( + + + + + Order #{orderId} + + {!isEditing && + currentUser?.role === Role.VOLUNTEER && + orderDetails?.status === OrderStatus.PENDING && ( + <> + + orderDetails && onDelete?.(orderDetails)} + /> + + )} + + + + Fulfilled by {orderDetails?.foodManufacturerName} + - - - - Order Details - - - Associated Request - - - - {!foodRequest && ( - - {' '} - No associated food request to display{' '} + {isEditing ? ( + + + {orderDetails?.foodManufacturerName} Stock - )} + + {Object.entries(groupedManufacturerItems).map( + ([foodType, items]) => ( + + + {foodType} + {foodRequest?.requestedFoodTypes.includes( + foodType as FoodType, + ) && ( + + Matching + + )} + + {items.map((item) => ( + + + {item.itemName} + - {foodRequest && ( - - - - Request {foodRequest.requestId} - - - {' '} - {foodRequest.pantry.pantryName} - + setEditingItemId(item.itemId)} + > + {editingItemId === item.itemId ? ( + + setItemAllocations((prev) => ({ + ...prev, + [item.itemId]: + Number(e.target.value) || 0, + })) + } + onBlur={() => setEditingItemId(null)} + /> + ) : ( + + item.quantity - + item.reservedQuantity + + (currentAllocations[item.itemId] ?? 0) + ? 'red.core' + : 'neutral.800' + } + > + {itemAllocations[item.itemId] ?? 0} of{' '} + {item.quantity - + item.reservedQuantity + + (currentAllocations[item.itemId] ?? 0)} + + )} + + + ))} + + ), + )} + + + + + + + ) : ( + + + + Order Details + + + Associated Request + + + + {!foodRequest && ( + + {' '} + No associated food request to display{' '} - {foodRequest.status === FoodRequestStatus.CLOSED ? ( - - Closed - - ) : ( - - Active - - )} - + )} - - - Size of Shipment - - - - {foodRequest.requestedSize} - - - + {foodRequest && ( + + + + Request {foodRequest.requestId} - + + {' '} + {foodRequest.pantry.pantryName} + + + {foodRequest.status === FoodRequestStatus.CLOSED ? ( + + Closed + + ) : ( + + Active + + )} + - - - - Food Type(s) - - + + + + Size of Shipment + + + + + {foodRequest.requestedSize} + + + - {foodRequest.requestedFoodTypes.length > 0 && ( - - )} - + + + + Food Type(s) + + - - - - Additional Information - - - - {foodRequest.additionalInformation} - - - - )} - + {foodRequest.requestedFoodTypes.length > 0 && ( + + )} + - - {Object.entries(groupedOrderItemsByType).map( - ([foodType, items]) => ( - - {foodType} - {items.map((item) => ( - - - {item.name} + + + + Additional Information + + + + {foodRequest.additionalInformation} + + + )} + - + + {Object.entries(groupedOrderItemsByType).map( + ([foodType, items]) => ( + + {foodType} + {items.map((item) => ( + + + {item.name} + - - {item.quantity} - - - ))} - - ), - )} - - Tracking - - {orderDetails?.trackingLink ? ( - - {orderDetails.trackingLink} - - ) : ( - - No tracking link available at this time - - )} - - - - - - - - + + + {item.quantity} + + + + ))} + + ), + )} + + Tracking + + {orderDetails?.trackingLink ? ( + + {orderDetails.trackingLink} + + ) : ( + + No tracking link available at this time + + )} + + + )} + + + + + + + )} ); }; diff --git a/apps/frontend/src/components/forms/requestDetailsModal.tsx b/apps/frontend/src/components/forms/requestDetailsModal.tsx index c745ffddd..ed586a89b 100644 --- a/apps/frontend/src/components/forms/requestDetailsModal.tsx +++ b/apps/frontend/src/components/forms/requestDetailsModal.tsx @@ -3,6 +3,9 @@ import { OrderItemDetailsGroupedByFoodType, OrderDetails, FoodRequestSummaryDto, +} from 'types/types'; +import { ORDER_STATUS_LABELS } from '@utils/utils'; +import { FoodRequestStatus, AlertStatus, OrderStatus, @@ -11,7 +14,6 @@ import { User, Role, } from '../../types/types'; -import { ORDER_STATUS_COLORS, ORDER_STATUS_LABELS } from '@utils/utils'; import React, { useState, useEffect } from 'react'; import { Flex, diff --git a/apps/frontend/src/components/forms/volunteerCloseOrderModal.tsx b/apps/frontend/src/components/forms/volunteerCloseOrderModal.tsx new file mode 100644 index 000000000..ff7934092 --- /dev/null +++ b/apps/frontend/src/components/forms/volunteerCloseOrderModal.tsx @@ -0,0 +1,132 @@ +import React from 'react'; +import { + Box, + Button, + VStack, + CloseButton, + Text, + Flex, + Dialog, +} from '@chakra-ui/react'; +import { AlertStatus, OrderDetails } from '../../types/types'; +import apiClient from '@api/apiClient'; +import { useAlert } from '../../hooks/alert'; +import { FloatingAlert } from '@components/floatingAlert'; +import { useModalBodyCleanup } from '../../hooks/modalBodyCleanup'; + +interface VolunteerCloseOrderModalProps { + order: OrderDetails | null; + isOpen: boolean; + onClose: () => void; + onSuccess: () => void; +} + +const VolunteerCloseOrderModal: React.FC = ({ + order, + isOpen, + onClose, + onSuccess, +}) => { + useModalBodyCleanup(); + const [alertState, setAlertMessage] = useAlert(); + + const onCloseOrder = async () => { + if (order === null) return; + try { + await apiClient.closeOrder(order.orderId); + onClose(); + onSuccess(); + } catch { + setAlertMessage('Order could not be closed.', AlertStatus.ERROR); + } + }; + + return ( + { + if (!e.open) onClose(); + }} + closeOnInteractOutside + > + {alertState && ( + + )} + + {order !== null && ( + + + + + + + + + Confirm Action + + + + + + Are you sure you want to delete this order? This action cannot + be undone. + + + + Order #{order.orderId} + + + Fulfilled by {order.foodManufacturerName} + + + + + + + + + + + )} + + ); +}; + +export default VolunteerCloseOrderModal; diff --git a/apps/frontend/src/containers/volunteerOrderManagement.tsx b/apps/frontend/src/containers/volunteerOrderManagement.tsx index bcc1acbbe..2f0cac565 100644 --- a/apps/frontend/src/containers/volunteerOrderManagement.tsx +++ b/apps/frontend/src/containers/volunteerOrderManagement.tsx @@ -1,4 +1,4 @@ -import React, { useState, useEffect } from 'react'; +import React, { useState, useEffect, useCallback } from 'react'; import { Box, Button, @@ -36,8 +36,10 @@ import { VolunteerAction, User, AlertStatus, + OrderDetails, } from '../types/types'; import OrderDetailsModal from '@components/forms/orderDetailsModal'; +import VolunteerCloseOrderModal from '@components/forms/volunteerCloseOrderModal'; import CompleteRequiredActionsModal from '@components/forms/completeRequiredActionsModal'; import { FloatingAlert } from '@components/floatingAlert'; import { useAlert } from '../hooks/alert'; @@ -68,6 +70,7 @@ const VolunteerOrderManagement: React.FC = () => { }); const [selectedOrderId, setSelectedOrderId] = useState(null); + const [closeOrder, setCloseOrder] = useState(null); const [actionModalOrder, setActionModalOrder] = useState(null); @@ -118,66 +121,66 @@ const VolunteerOrderManagement: React.FC = () => { const MAX_PER_STATUS = 5; - useEffect(() => { - const fetchOrders = async () => { - let user: User; - let userId: number; - try { - user = await ApiClient.getMe(); - userId = user.id; - setCurrentUser(user); - } catch { - setAlertMessage( - 'Authentication error. Please log in and try again.', - AlertStatus.ERROR, - ); - setIsLoading(false); - return; - } - - try { - const data = await ApiClient.getVolunteerOrders(userId); + const fetchOrders = useCallback(async () => { + let user: User; + let userId: number; + try { + user = await ApiClient.getMe(); + userId = user.id; + setCurrentUser(user); + } catch { + setAlertMessage( + 'Authentication error. Please log in and try again.', + AlertStatus.ERROR, + ); + setIsLoading(false); + return; + } - const grouped: Record = { - [OrderStatus.SHIPPED]: [], - [OrderStatus.PENDING]: [], - [OrderStatus.DELIVERED]: [], - [OrderStatus.CLOSED]: [], - }; + try { + const data = await ApiClient.getVolunteerOrders(userId); - for (const order of data) { - const status = order.status; + const grouped: Record = { + [OrderStatus.SHIPPED]: [], + [OrderStatus.PENDING]: [], + [OrderStatus.DELIVERED]: [], + [OrderStatus.CLOSED]: [], + }; - const orderWithColor: VolunteerOrderWithColor = { ...order }; + for (const order of data) { + const status = order.status; - if (order.assignee) { - orderWithColor.assigneeColor = - USER_ICON_COLORS[order.assignee.id % USER_ICON_COLORS.length]; - } + const orderWithColor: VolunteerOrderWithColor = { ...order }; - grouped[status].push(orderWithColor); + if (order.assignee) { + orderWithColor.assigneeColor = + USER_ICON_COLORS[order.assignee.id % USER_ICON_COLORS.length]; } - setStatusOrders(grouped); - - // Initialize current page for each status - const initialPages: Record = { - [OrderStatus.SHIPPED]: 1, - [OrderStatus.PENDING]: 1, - [OrderStatus.DELIVERED]: 1, - [OrderStatus.CLOSED]: 1, - }; - setCurrentPages(initialPages); - } catch { - setAlertMessage('Error fetching assigned orders', AlertStatus.ERROR); - } finally { - setIsLoading(false); + grouped[status].push(orderWithColor); } - }; - fetchOrders(); + setStatusOrders(grouped); + + // Initialize current page for each status + const initialPages: Record = { + [OrderStatus.SHIPPED]: 1, + [OrderStatus.PENDING]: 1, + [OrderStatus.DELIVERED]: 1, + [OrderStatus.CLOSED]: 1, + }; + setCurrentPages(initialPages); + } catch { + setAlertMessage('Error fetching assigned orders', AlertStatus.ERROR); + } finally { + setIsLoading(false); + } }, [setAlertMessage]); + useEffect(() => { + fetchOrders(); + }, [fetchOrders]); + useEffect(() => { const orderIdFromUrl = searchParams.get('orderId'); @@ -378,16 +381,27 @@ const VolunteerOrderManagement: React.FC = () => { /> )} - {selectedOrderId && ( - { - setSelectedOrderId(null); - navigate(ROUTES.VOLUNTEER_ORDER_MANAGEMENT, { replace: true }); - }} - /> - )} + { + setSelectedOrderId(null); + navigate(ROUTES.VOLUNTEER_ORDER_MANAGEMENT, { replace: true }); + }} + onSuccess={fetchOrders} + onDelete={(order) => setCloseOrder(order)} + /> + + setCloseOrder(null)} + onSuccess={() => { + fetchOrders(); + setSelectedOrderId(null); + navigate(ROUTES.VOLUNTEER_ORDER_MANAGEMENT, { replace: true }); + }} + /> ); }; diff --git a/apps/frontend/src/types/types.ts b/apps/frontend/src/types/types.ts index 15dded719..5ffd4597b 100644 --- a/apps/frontend/src/types/types.ts +++ b/apps/frontend/src/types/types.ts @@ -174,6 +174,23 @@ export class DonationItemDetailsDto { availableQuantity!: number; } +export interface OrderDonationItemDto { + itemId: number; + itemName: string; + foodType: FoodType; + quantity: number; + reservedQuantity: number; +} + +export interface AllocationUpdate { + donationItemId: number; + allocatedQuantity: number; +} + +export interface UpdateAllocationsDto { + allocations: AllocationUpdate[]; +} + export enum RecurrenceEnum { NONE = 'none', WEEKLY = 'weekly',