From f82c01c6b59877eb6ac36fb0562eae103519ada7 Mon Sep 17 00:00:00 2001 From: Dalton Burkhart Date: Fri, 5 Jun 2026 00:00:56 -0700 Subject: [PATCH 1/8] Made close order endpoint --- .../allocations/allocations.service.spec.ts | 96 ++++++++++ .../src/allocations/allocations.service.ts | 33 ++++ apps/backend/src/config/migrations.ts | 2 + .../1780562894014-AddOrderStatusClosed.ts | 64 +++++++ apps/backend/src/orders/order.controller.ts | 37 +++- apps/backend/src/orders/order.service.spec.ts | 175 +++++++++++++++++- apps/backend/src/orders/order.service.ts | 34 ++++ apps/backend/src/orders/types.ts | 1 + .../frontend/src/components/dashboardCard.tsx | 28 +-- .../components/forms/requestDetailsModal.tsx | 4 +- .../src/containers/adminOrderManagement.tsx | 58 +++--- .../src/containers/pantryOrderManagement.tsx | 62 +++---- .../containers/volunteerOrderManagement.tsx | 62 +++---- apps/frontend/src/types/types.ts | 10 +- apps/frontend/src/utils/utils.ts | 18 +- 15 files changed, 561 insertions(+), 123 deletions(-) create mode 100644 apps/backend/src/migrations/1780562894014-AddOrderStatusClosed.ts diff --git a/apps/backend/src/allocations/allocations.service.spec.ts b/apps/backend/src/allocations/allocations.service.spec.ts index 395c88ecb..df0939aaf 100644 --- a/apps/backend/src/allocations/allocations.service.spec.ts +++ b/apps/backend/src/allocations/allocations.service.spec.ts @@ -222,4 +222,100 @@ describe('AllocationsService', () => { expect(Number(allocationCountAfter)).toBe(Number(allocationCountBefore)); }); }); + + describe('freeAllByOrder', () => { + // Order 2 is seeded with 3 allocations (see getAllAllocationsByOrder above). + const orderId = 2; + + it('should remove all allocations for an order and decrement each reservedQuantity by the allocated amount', async () => { + const allocationRepo = testDataSource.getRepository(Allocation); + const donationItemRepo = testDataSource.getRepository(DonationItem); + + const allocations = await allocationRepo.find({ where: { orderId } }); + expect(allocations.length).toBeGreaterThan(0); + + // Sum the allocated quantity per item and capture reserved-before + const allocatedByItem = new Map(); + const reservedBefore = new Map(); + for (const allocation of allocations) { + allocatedByItem.set( + allocation.itemId, + (allocatedByItem.get(allocation.itemId) ?? 0) + + allocation.allocatedQuantity, + ); + if (!reservedBefore.has(allocation.itemId)) { + const item = (await donationItemRepo.findOne({ + where: { itemId: allocation.itemId }, + })) as DonationItem; + reservedBefore.set(allocation.itemId, item.reservedQuantity); + } + } + + await service.freeAllByOrder(orderId); + + expect(await allocationRepo.find({ where: { orderId } })).toHaveLength(0); + + for (const [itemId, allocated] of allocatedByItem) { + const item = (await donationItemRepo.findOne({ + where: { itemId }, + })) as DonationItem; + expect(item.reservedQuantity).toBe( + (reservedBefore.get(itemId) as number) - allocated, + ); + } + }); + + it('should work with a given transaction manager', async () => { + const allocationRepo = testDataSource.getRepository(Allocation); + + expect( + (await allocationRepo.find({ where: { orderId } })).length, + ).toBeGreaterThan(0); + + await testDataSource.transaction(async (manager) => { + await service.freeAllByOrder(orderId, manager); + }); + + expect(await allocationRepo.find({ where: { orderId } })).toHaveLength(0); + }); + + it('should rollback all changes if an error occurs during the transaction', async () => { + const allocationRepo = testDataSource.getRepository(Allocation); + const donationItemRepo = testDataSource.getRepository(DonationItem); + + const allocationsBefore = await allocationRepo.find({ + where: { orderId }, + }); + const allocationCountBefore = await allocationRepo.count(); + const itemIds = [...new Set(allocationsBefore.map((a) => a.itemId))]; + const reservedBefore = new Map(); + for (const itemId of itemIds) { + const item = (await donationItemRepo.findOne({ + where: { itemId }, + })) as DonationItem; + reservedBefore.set(itemId, item.reservedQuantity); + } + + await expect( + testDataSource.transaction(async (manager) => { + await service.freeAllByOrder(orderId, manager); + throw new Error('Simulated failure'); + }), + ).rejects.toThrow('Simulated failure'); + + // Nothing was removed and no reservedQuantity changed. + expect(await allocationRepo.count()).toBe(allocationCountBefore); + expect(await allocationRepo.find({ where: { orderId } })).toHaveLength( + allocationsBefore.length, + ); + for (const itemId of itemIds) { + const item = (await donationItemRepo.findOne({ + where: { itemId }, + })) as DonationItem; + expect(item.reservedQuantity).toBe( + reservedBefore.get(itemId) as number, + ); + } + }); + }); }); diff --git a/apps/backend/src/allocations/allocations.service.ts b/apps/backend/src/allocations/allocations.service.ts index bd951892c..1e4bc5401 100644 --- a/apps/backend/src/allocations/allocations.service.ts +++ b/apps/backend/src/allocations/allocations.service.ts @@ -65,4 +65,37 @@ export class AllocationsService { return targetAllocationRepo.save(allocations); } + + async freeAllByOrder( + orderId: number, + transactionManager?: EntityManager, + ): Promise { + const allocationTransactionRepo = transactionManager + ? transactionManager.getRepository(Allocation) + : undefined; + const itemTransactionRepo = transactionManager + ? transactionManager.getRepository(DonationItem) + : undefined; + const targetAllocationRepo = allocationTransactionRepo + ? allocationTransactionRepo + : this.repo; + const targetItemRepo = itemTransactionRepo + ? itemTransactionRepo + : this.donationItemRepo; + + validateId(orderId, 'Order'); + + // All orders have allocations so this will have something. + const allocations = await targetAllocationRepo.find({ where: { orderId } }); + + for (const allocation of allocations) { + await targetItemRepo.decrement( + { itemId: allocation.itemId }, + 'reservedQuantity', + allocation.allocatedQuantity, + ); + } + + await targetAllocationRepo.remove(allocations); + } } diff --git a/apps/backend/src/config/migrations.ts b/apps/backend/src/config/migrations.ts index 65a88e352..a7fe305a7 100644 --- a/apps/backend/src/config/migrations.ts +++ b/apps/backend/src/config/migrations.ts @@ -41,6 +41,7 @@ import { MakeFoodRescueRequired1773889925002 } from '../migrations/1773889925002 import { AddDonationItemConfirmation1774140453305 } from '../migrations/1774140453305-AddDonationItemConfirmation'; import { DonationItemsOnDeleteCascade1774214910101 } from '../migrations/1774214910101-DonationItemsOnDeleteCascade'; import { OrdersVolunteerActions1774883880543 } from '../migrations/1774883880543-OrdersVolunteerActions'; +import { AddOrderStatusClosed1780562894014 } from '../migrations/1780562894014-AddOrderStatusClosed'; const schemaMigrations = [ User1725726359198, @@ -86,6 +87,7 @@ const schemaMigrations = [ AddDonationItemConfirmation1774140453305, DonationItemsOnDeleteCascade1774214910101, OrdersVolunteerActions1774883880543, + AddOrderStatusClosed1780562894014, ]; export default schemaMigrations; diff --git a/apps/backend/src/migrations/1780562894014-AddOrderStatusClosed.ts b/apps/backend/src/migrations/1780562894014-AddOrderStatusClosed.ts new file mode 100644 index 000000000..2621779b5 --- /dev/null +++ b/apps/backend/src/migrations/1780562894014-AddOrderStatusClosed.ts @@ -0,0 +1,64 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class AddOrderStatusClosed1780562894014 implements MigrationInterface { + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE orders + ALTER COLUMN status DROP DEFAULT; + + CREATE TYPE orders_status_enum_new AS ENUM ( + 'delivered', + 'pending', + 'shipped', + 'closed' + ); + + ALTER TABLE orders + ALTER COLUMN status + TYPE orders_status_enum_new + USING status::text::orders_status_enum_new; + + DROP TYPE orders_status_enum; + + ALTER TYPE orders_status_enum_new + RENAME TO orders_status_enum; + + ALTER TABLE orders + ALTER COLUMN status + SET DEFAULT 'pending'; + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE orders + ALTER COLUMN status DROP DEFAULT; + + CREATE TYPE orders_status_enum_old AS ENUM ( + 'delivered', + 'pending', + 'shipped' + ); + + ALTER TABLE orders + ALTER COLUMN status + TYPE orders_status_enum_old + USING ( + CASE + WHEN status = 'closed' + THEN 'pending' + ELSE status::text + END + )::orders_status_enum_old; + + DROP TYPE orders_status_enum; + + ALTER TYPE orders_status_enum_old + RENAME TO orders_status_enum; + + ALTER TABLE orders + ALTER COLUMN status + SET DEFAULT 'pending'; + `); + } +} diff --git a/apps/backend/src/orders/order.controller.ts b/apps/backend/src/orders/order.controller.ts index f53f25a5d..c576db063 100644 --- a/apps/backend/src/orders/order.controller.ts +++ b/apps/backend/src/orders/order.controller.ts @@ -19,7 +19,11 @@ import { OrdersService } from './order.service'; import { Order } from './order.entity'; import { Pantry } from '../pantries/pantries.entity'; import { AllocationsService } from '../allocations/allocations.service'; -import { CheckOwnership, pipeNullable } from '../auth/ownership.decorator'; +import { + CheckOwnership, + OwnerIdResolver, + pipeNullable, +} from '../auth/ownership.decorator'; import { PantriesService } from '../pantries/pantries.service'; import { BulkUpdateTrackingCostDto } from './dtos/bulk-update-tracking-cost.dto'; import { OrderDetailsDto } from './dtos/order-details.dto'; @@ -35,6 +39,25 @@ import { Roles } from '../auth/roles.decorator'; import { Role } from '../users/types'; import { OrderStatus } from './types'; +const resolveOrderAuthorizedUserIds: OwnerIdResolver = ({ + entityId, + services, + user, +}) => { + if (user?.role === Role.VOLUNTEER) { + return pipeNullable( + () => services.get(OrdersService).findOne(entityId), + (order: Order) => [order.assigneeId], + ); + } + return pipeNullable( + () => services.get(OrdersService).findOrderFoodRequest(entityId), + (request: FoodRequestSummaryDto) => + services.get(PantriesService).findOne(request.pantry.pantryId), + (pantry: Pantry) => [pantry.pantryUser.id], + ); +}; + @Controller('orders') export class OrdersController { constructor( @@ -271,4 +294,16 @@ export class OrdersController { ): Promise { await this.ordersService.completeVolunteerAction(orderId, dto.action); } + + @CheckOwnership({ + idParam: 'orderId', + resolver: resolveOrderAuthorizedUserIds, + }) + @Roles(Role.VOLUNTEER) + @Patch('/:orderId/close') + async closeOrder( + @Param('orderId', ParseIntPipe) orderId: number, + ): Promise { + await this.ordersService.closeOrder(orderId); + } } diff --git a/apps/backend/src/orders/order.service.spec.ts b/apps/backend/src/orders/order.service.spec.ts index 2aee388f1..4c97575cf 100644 --- a/apps/backend/src/orders/order.service.spec.ts +++ b/apps/backend/src/orders/order.service.spec.ts @@ -30,7 +30,13 @@ import { AuthService } from '../auth/auth.service'; import { DonationService } from '../donations/donations.service'; import { PantriesService } from '../pantries/pantries.service'; import { CreateOrderDto } from './dtos/create-order.dto'; -import { DataSource, EntityManager, In } from 'typeorm'; +import { + DataSource, + EntityManager, + In, + ObjectLiteral, + Repository, +} from 'typeorm'; import { EmailsService } from '../emails/email.service'; import { Allocation } from '../allocations/allocations.entity'; import { mock } from 'jest-mock-extended'; @@ -1169,6 +1175,173 @@ ${request.pantry.shipmentAddressCity}, ${request.pantry.shipmentAddressState} ${ expect(donationItem1?.reservedQuantity).toBe(10); }); }); + + describe('closeOrder', () => { + const userId = 3; + let validCreateOrderDto: CreateOrderDto; + let parsedAllocations: Map; + + beforeEach(() => { + validCreateOrderDto = { + foodRequestId: 1, + manufacturerId: 1, + itemAllocations: { + 1: 10, + 2: 3, + }, + }; + + parsedAllocations = new Map([ + [1, 10], + [2, 3], + ]); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + // Creates a pending order (reserving items 1 and 2) and returns the + // post-create state so rollback tests can assert nothing changed. + const createPendingOrder = async () => { + const allocationRepo = testDataSource.getRepository(Allocation); + const donationItemRepo = testDataSource.getRepository(DonationItem); + + const createdOrder = await service.create( + validCreateOrderDto.foodRequestId, + validCreateOrderDto.manufacturerId, + parsedAllocations, + userId, + ); + + const allocationsBefore = await allocationRepo.find({ + where: { orderId: createdOrder.orderId }, + }); + const item1Before = (await donationItemRepo.findOne({ + where: { itemId: 1 }, + })) as DonationItem; + const item2Before = (await donationItemRepo.findOne({ + where: { itemId: 2 }, + })) as DonationItem; + + return { + orderId: createdOrder.orderId, + allocationRepo, + donationItemRepo, + allocationsBefore, + item1Before, + item2Before, + }; + }; + + it('sets the order status to CLOSED when everything succeeds', async () => { + const { orderId } = await createPendingOrder(); + + await service.closeOrder(orderId); + + expect((await service.findOne(orderId)).status).toBe(OrderStatus.CLOSED); + }); + + it('throws NotFoundException if the order does not exist', async () => { + const nonExistentOrderId = 999; + await expect(service.closeOrder(nonExistentOrderId)).rejects.toThrow( + new NotFoundException(`Order ${nonExistentOrderId} not found`), + ); + }); + + it('throws BadRequestException if the order is not pending', async () => { + const orderRepo = testDataSource.getRepository(Order); + const { orderId } = await createPendingOrder(); + + await orderRepo.update({ orderId }, { status: OrderStatus.SHIPPED }); + + await expect(service.closeOrder(orderId)).rejects.toThrow( + new BadRequestException(`Order ${orderId} must be pending`), + ); + + expect((await service.findOne(orderId)).status).not.toBe( + OrderStatus.CLOSED, + ); + }); + + it('rolls back all changes when matchAll fails', async () => { + const { + orderId, + allocationRepo, + donationItemRepo, + allocationsBefore, + item1Before, + item2Before, + } = await createPendingOrder(); + + jest + .spyOn((service as any).donationService as DonationService, 'matchAll') + .mockRejectedValueOnce(new Error('DB error')); + + await expect(service.closeOrder(orderId)).rejects.toThrow('DB error'); + + const orderAfter = await service.findOne(orderId); + expect(orderAfter.status).not.toBe(OrderStatus.CLOSED); + expect(orderAfter.status).toBe(OrderStatus.PENDING); + expect(await allocationRepo.find({ where: { orderId } })).toHaveLength( + allocationsBefore.length, + ); + + const item1After = (await donationItemRepo.findOne({ + where: { itemId: 1 }, + })) as DonationItem; + const item2After = (await donationItemRepo.findOne({ + where: { itemId: 2 }, + })) as DonationItem; + expect(item1After.reservedQuantity).toBe(item1Before.reservedQuantity); + expect(item2After.reservedQuantity).toBe(item2Before.reservedQuantity); + }); + + it('rolls back all changes when the order status update fails', async () => { + const { + orderId, + allocationRepo, + donationItemRepo, + allocationsBefore, + item1Before, + item2Before, + } = await createPendingOrder(); + + const originalUpdate = Repository.prototype.update; + jest + .spyOn(Repository.prototype, 'update') + .mockImplementation(function ( + this: Repository, + ...args: Parameters + ) { + if (this.metadata.target === Order) { + return Promise.reject(new Error('DB error')); + } + return originalUpdate.apply(this, args); + }); + + await expect(service.closeOrder(orderId)).rejects.toThrow('DB error'); + + jest.restoreAllMocks(); + + const orderAfter = await service.findOne(orderId); + expect(orderAfter.status).not.toBe(OrderStatus.CLOSED); + expect(orderAfter.status).toBe(OrderStatus.PENDING); + expect(await allocationRepo.find({ where: { orderId } })).toHaveLength( + allocationsBefore.length, + ); + + const item1After = (await donationItemRepo.findOne({ + where: { itemId: 1 }, + })) as DonationItem; + const item2After = (await donationItemRepo.findOne({ + where: { itemId: 2 }, + })) as DonationItem; + expect(item1After.reservedQuantity).toBe(item1Before.reservedQuantity); + expect(item2After.reservedQuantity).toBe(item2Before.reservedQuantity); + }); + }); + describe('getAllOrdersForVolunteer', () => { it('should return all orders across all pantries and assignees, with required actions for assigned orders', async () => { const volunteerId = 6; diff --git a/apps/backend/src/orders/order.service.ts b/apps/backend/src/orders/order.service.ts index 1018c940c..fdd5b052f 100644 --- a/apps/backend/src/orders/order.service.ts +++ b/apps/backend/src/orders/order.service.ts @@ -23,6 +23,7 @@ import { FoodRequestStatus } from '../foodRequests/types'; import { FoodManufacturersService } from '../foodManufacturers/manufacturers.service'; import { DonationItemsService } from '../donationItems/donationItems.service'; import { AllocationsService } from '../allocations/allocations.service'; +import { Allocation } from '../allocations/allocations.entity'; import { ApplicationStatus } from '../shared/types'; import { VolunteerOrder } from '../volunteers/types'; import { EmailsService } from '../emails/email.service'; @@ -749,4 +750,37 @@ ${request.pantry.shipmentAddressCity}, ${request.pantry.shipmentAddressState} ${ await this.repo.save(order); } + + async closeOrder(orderId: number): Promise { + validateId(orderId, 'Order'); + + const order = await this.repo.findOneBy({ orderId }); + + if (!order) { + throw new NotFoundException(`Order ${orderId} not found`); + } + + if (order.status !== OrderStatus.PENDING) { + throw new BadRequestException(`Order ${orderId} must be pending`); + } + + await this.dataSource.transaction(async (transactionManager) => { + // Capture which donations are affected before allocations are removed + const allocations = await transactionManager + .getRepository(Allocation) + .find({ where: { orderId }, relations: ['item'] }); + const donationIds = [ + ...new Set(allocations.map((allocation) => allocation.item.donationId)), + ]; + + await this.allocationsService.freeAllByOrder(orderId, transactionManager); + + // Donation should always have items matched to it + await this.donationService.matchAll(donationIds, transactionManager); + + await transactionManager + .getRepository(Order) + .update({ orderId }, { status: OrderStatus.CLOSED }); + }); + } } diff --git a/apps/backend/src/orders/types.ts b/apps/backend/src/orders/types.ts index 2966034d7..7eb899ca3 100644 --- a/apps/backend/src/orders/types.ts +++ b/apps/backend/src/orders/types.ts @@ -2,6 +2,7 @@ export enum OrderStatus { DELIVERED = 'delivered', PENDING = 'pending', SHIPPED = 'shipped', + CLOSED = 'closed', } export enum VolunteerAction { diff --git a/apps/frontend/src/components/dashboardCard.tsx b/apps/frontend/src/components/dashboardCard.tsx index ad8208d6d..3c09215ac 100644 --- a/apps/frontend/src/components/dashboardCard.tsx +++ b/apps/frontend/src/components/dashboardCard.tsx @@ -9,7 +9,7 @@ import { DONATION_STATUS_COLORS, USER_ICON_COLORS, } from '@utils/utils'; -import { OrderAssignee, OrderStatus, DonationStatus } from '../types/types'; +import { OrderAssignee, OpenOrderStatus, DonationStatus } from '../types/types'; export enum DashboardCardType { UPCOMING_DONATION, @@ -41,21 +41,21 @@ export interface DashboardCardBadge { color: string; } -export const ORDER_STATUS_BADGE: Record = { - [OrderStatus.PENDING]: { - label: ORDER_STATUS_LABELS[OrderStatus.PENDING], - bg: ORDER_STATUS_COLORS[OrderStatus.PENDING][0], - color: ORDER_STATUS_COLORS[OrderStatus.PENDING][1], +export const ORDER_STATUS_BADGE: Record = { + [OpenOrderStatus.PENDING]: { + label: ORDER_STATUS_LABELS[OpenOrderStatus.PENDING], + bg: ORDER_STATUS_COLORS[OpenOrderStatus.PENDING][0], + color: ORDER_STATUS_COLORS[OpenOrderStatus.PENDING][1], }, - [OrderStatus.SHIPPED]: { - label: ORDER_STATUS_LABELS[OrderStatus.SHIPPED], - bg: ORDER_STATUS_COLORS[OrderStatus.SHIPPED][0], - color: ORDER_STATUS_COLORS[OrderStatus.SHIPPED][1], + [OpenOrderStatus.SHIPPED]: { + label: ORDER_STATUS_LABELS[OpenOrderStatus.SHIPPED], + bg: ORDER_STATUS_COLORS[OpenOrderStatus.SHIPPED][0], + color: ORDER_STATUS_COLORS[OpenOrderStatus.SHIPPED][1], }, - [OrderStatus.DELIVERED]: { - label: ORDER_STATUS_LABELS[OrderStatus.DELIVERED], - bg: ORDER_STATUS_COLORS[OrderStatus.DELIVERED][0], - color: ORDER_STATUS_COLORS[OrderStatus.DELIVERED][1], + [OpenOrderStatus.DELIVERED]: { + label: ORDER_STATUS_LABELS[OpenOrderStatus.DELIVERED], + bg: ORDER_STATUS_COLORS[OpenOrderStatus.DELIVERED][0], + color: ORDER_STATUS_COLORS[OpenOrderStatus.DELIVERED][1], }, }; diff --git a/apps/frontend/src/components/forms/requestDetailsModal.tsx b/apps/frontend/src/components/forms/requestDetailsModal.tsx index 23c4e9e80..0e2f149bb 100644 --- a/apps/frontend/src/components/forms/requestDetailsModal.tsx +++ b/apps/frontend/src/components/forms/requestDetailsModal.tsx @@ -4,7 +4,7 @@ import { OrderDetails, FoodRequestSummaryDto, } from 'types/types'; -import { OrderStatus } from '../../types/types'; +import { OpenOrderStatus } from '../../types/types'; import { ORDER_STATUS_LABELS } from '@utils/utils'; import React, { useState, useEffect } from 'react'; import { @@ -194,7 +194,7 @@ const RequestDetailsModal: React.FC = ({ Fulfilled by {currentOrder.foodManufacturerName} - {currentOrder.status === OrderStatus.DELIVERED ? ( + {currentOrder.status === OpenOrderStatus.DELIVERED ? ( { // State to hold orders grouped by status const [statusOrders, setStatusOrders] = useState< - Record + Record >({ - [OrderStatus.SHIPPED]: [], - [OrderStatus.PENDING]: [], - [OrderStatus.DELIVERED]: [], + [OpenOrderStatus.SHIPPED]: [], + [OpenOrderStatus.PENDING]: [], + [OpenOrderStatus.DELIVERED]: [], }); // State to hold selected order for details modal const [selectedOrderId, setSelectedOrderId] = useState(null); // State to hold current page per status - const [currentPages, setCurrentPages] = useState>( - { - [OrderStatus.SHIPPED]: 1, - [OrderStatus.PENDING]: 1, - [OrderStatus.DELIVERED]: 1, - }, - ); + const [currentPages, setCurrentPages] = useState< + Record + >({ + [OpenOrderStatus.SHIPPED]: 1, + [OpenOrderStatus.PENDING]: 1, + [OpenOrderStatus.DELIVERED]: 1, + }); const [searchParams] = useSearchParams(); const [alertState, setAlertMessage] = useAlert(); @@ -78,19 +78,19 @@ const AdminOrderManagement: React.FC = () => { // sortAsc indicates whether the sorting is ascending (oldest first) or descending (newest first) // We store all these here to determine what orders to display for each status const [filterStates, setFilterStates] = useState< - Record + Record >({ - [OrderStatus.SHIPPED]: { + [OpenOrderStatus.SHIPPED]: { selectedPantries: [], searchPantry: '', sortAsc: false, }, - [OrderStatus.PENDING]: { + [OpenOrderStatus.PENDING]: { selectedPantries: [], searchPantry: '', sortAsc: false, }, - [OrderStatus.DELIVERED]: { + [OpenOrderStatus.DELIVERED]: { selectedPantries: [], searchPantry: '', sortAsc: false, @@ -105,10 +105,10 @@ const AdminOrderManagement: React.FC = () => { try { const data = await ApiClient.getAllOrders(); - const grouped: Record = { - [OrderStatus.SHIPPED]: [], - [OrderStatus.PENDING]: [], - [OrderStatus.DELIVERED]: [], + const grouped: Record = { + [OpenOrderStatus.SHIPPED]: [], + [OpenOrderStatus.PENDING]: [], + [OpenOrderStatus.DELIVERED]: [], }; for (const order of data) { @@ -126,10 +126,10 @@ const AdminOrderManagement: React.FC = () => { setStatusOrders(grouped); // Initialize current page for each status - const initialPages: Record = { - [OrderStatus.SHIPPED]: 1, - [OrderStatus.PENDING]: 1, - [OrderStatus.DELIVERED]: 1, + const initialPages: Record = { + [OpenOrderStatus.SHIPPED]: 1, + [OpenOrderStatus.PENDING]: 1, + [OpenOrderStatus.DELIVERED]: 1, }; setCurrentPages(initialPages); } catch { @@ -141,11 +141,11 @@ const AdminOrderManagement: React.FC = () => { }, [setAlertMessage]); // Helper to reset page for a specific status - const resetPageForStatus = (status: OrderStatus) => { + const resetPageForStatus = (status: OpenOrderStatus) => { setCurrentPages((prev) => ({ ...prev, [status]: 1 })); }; - const handlePageChange = (status: OrderStatus, page: number) => { + const handlePageChange = (status: OpenOrderStatus, page: number) => { setCurrentPages((prev) => ({ ...prev, [status]: page, @@ -165,7 +165,7 @@ const AdminOrderManagement: React.FC = () => { if (matchedOrder) { setSelectedOrderId(id); // Paginate the containing status to the page that holds this order. - for (const status of Object.values(OrderStatus)) { + for (const status of Object.values(OpenOrderStatus)) { const sorted = [...statusOrders[status]].sort((a, b) => b.createdAt.localeCompare(a.createdAt), ); @@ -198,7 +198,7 @@ const AdminOrderManagement: React.FC = () => { /> )} - {Object.values(OrderStatus).map((status) => { + {Object.values(OpenOrderStatus).map((status) => { const allOrders = statusOrders[status] || []; const filterState = filterStates[status]; @@ -277,7 +277,7 @@ const AdminOrderManagement: React.FC = () => { interface OrderStatusSectionProps { orders: OrderWithColor[]; - status: OrderStatus; + status: OpenOrderStatus; colors: string[]; onOrderSelect: (orderId: number | null) => void; totalOrders: number; diff --git a/apps/frontend/src/containers/pantryOrderManagement.tsx b/apps/frontend/src/containers/pantryOrderManagement.tsx index 5ee5339c2..057cb5f5b 100644 --- a/apps/frontend/src/containers/pantryOrderManagement.tsx +++ b/apps/frontend/src/containers/pantryOrderManagement.tsx @@ -24,7 +24,7 @@ import { USER_ICON_COLORS, } from '@utils/utils'; import ApiClient from '@api/apiClient'; -import { OrderStatus, OrderSummary } from '../types/types'; +import { OpenOrderStatus, OrderSummary } from '../types/types'; import OrderReceivedActionModal from '@components/forms/orderReceivedActionModal'; import OrderDetailsModal from '@components/forms/orderDetailsModal'; import { FloatingAlert } from '@components/floatingAlert'; @@ -38,11 +38,11 @@ const MAX_PER_STATUS = 5; const PantryOrderManagement: React.FC = () => { // State to hold orders grouped by status const [statusOrders, setStatusOrders] = useState< - Record + Record >({ - [OrderStatus.SHIPPED]: [], - [OrderStatus.PENDING]: [], - [OrderStatus.DELIVERED]: [], + [OpenOrderStatus.SHIPPED]: [], + [OpenOrderStatus.PENDING]: [], + [OpenOrderStatus.DELIVERED]: [], }); // State to hold selected order for details modal @@ -52,13 +52,13 @@ const PantryOrderManagement: React.FC = () => { useState(null); // State to hold current page per status - const [currentPages, setCurrentPages] = useState>( - { - [OrderStatus.SHIPPED]: 1, - [OrderStatus.PENDING]: 1, - [OrderStatus.DELIVERED]: 1, - }, - ); + const [currentPages, setCurrentPages] = useState< + Record + >({ + [OpenOrderStatus.SHIPPED]: 1, + [OpenOrderStatus.PENDING]: 1, + [OpenOrderStatus.DELIVERED]: 1, + }); const [searchParams] = useSearchParams(); const navigate = useNavigate(); @@ -74,15 +74,15 @@ const PantryOrderManagement: React.FC = () => { // sortAsc indicates whether the sorting is ascending (oldest first) or descending (newest first) // We store all these here to determine what orders to display for each status const [filterStates, setFilterStates] = useState< - Record + Record >({ - [OrderStatus.SHIPPED]: { + [OpenOrderStatus.SHIPPED]: { sortAsc: false, }, - [OrderStatus.PENDING]: { + [OpenOrderStatus.PENDING]: { sortAsc: false, }, - [OrderStatus.DELIVERED]: { + [OpenOrderStatus.DELIVERED]: { sortAsc: false, }, }); @@ -92,10 +92,10 @@ const PantryOrderManagement: React.FC = () => { const pantryId = await ApiClient.getCurrentUserPantryId(); const data = await ApiClient.getPantryOrders(pantryId); - const grouped: Record = { - [OrderStatus.SHIPPED]: [], - [OrderStatus.PENDING]: [], - [OrderStatus.DELIVERED]: [], + const grouped: Record = { + [OpenOrderStatus.SHIPPED]: [], + [OpenOrderStatus.PENDING]: [], + [OpenOrderStatus.DELIVERED]: [], }; for (const order of data) { @@ -109,10 +109,10 @@ const PantryOrderManagement: React.FC = () => { setStatusOrders(grouped); // Initialize current page for each status - const initialPages: Record = { - [OrderStatus.SHIPPED]: 1, - [OrderStatus.PENDING]: 1, - [OrderStatus.DELIVERED]: 1, + const initialPages: Record = { + [OpenOrderStatus.SHIPPED]: 1, + [OpenOrderStatus.PENDING]: 1, + [OpenOrderStatus.DELIVERED]: 1, }; setCurrentPages(initialPages); } catch { @@ -134,7 +134,7 @@ const PantryOrderManagement: React.FC = () => { if (match) { setSelectedOrderId(match.orderId); // Paginate the containing status to the page that holds this order. - for (const status of Object.values(OrderStatus)) { + for (const status of Object.values(OpenOrderStatus)) { const sorted = [...statusOrders[status]].sort((a, b) => b.createdAt.localeCompare(a.createdAt), ); @@ -153,11 +153,11 @@ const PantryOrderManagement: React.FC = () => { }, [searchParams, statusOrders, navigate]); // Helper to reset page for a specific status - const resetPageForStatus = (status: OrderStatus) => { + const resetPageForStatus = (status: OpenOrderStatus) => { setCurrentPages((prev) => ({ ...prev, [status]: 1 })); }; - const handlePageChange = (status: OrderStatus, page: number) => { + const handlePageChange = (status: OpenOrderStatus, page: number) => { setCurrentPages((prev) => ({ ...prev, [status]: page, @@ -187,7 +187,7 @@ const PantryOrderManagement: React.FC = () => { /> )} - {Object.values(OrderStatus).map((status) => { + {Object.values(OpenOrderStatus).map((status) => { const allOrders = statusOrders[status] || []; const filterState = filterStates[status]; @@ -262,7 +262,7 @@ const PantryOrderManagement: React.FC = () => { interface OrderStatusSectionProps { orders: OrderWithColor[]; - status: OrderStatus; + status: OpenOrderStatus; colors: [string, string]; onOrderSelect: (orderId: number | null) => void; onOrderSelectForAction: (order: OrderWithColor | null) => void; @@ -574,13 +574,13 @@ const OrderStatusSection: React.FC = ({ textAlign="right" color="neutral.700" bgColor={ - order.status !== OrderStatus.SHIPPED + order.status !== OpenOrderStatus.SHIPPED ? 'neutral.50' : 'white' } pr={0} > - {order.status === OrderStatus.SHIPPED && ( + {order.status === OpenOrderStatus.SHIPPED && ( + + + + ) : ( + + + - - - Request {foodRequest.requestId} - - - {' '} - {foodRequest.pantry.pantryName} - - - {foodRequest.status === FoodRequestStatus.CLOSED ? ( - - Closed - - ) : ( - - Active - - )} - + Order Details + + + Associated Request + + + + {!foodRequest && ( + + {' '} + No associated food request to display{' '} + + )} - - - 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.quantity} - - - ))} - - ), - )} - - Tracking - - {orderDetails?.trackingLink ? ( - - {orderDetails.trackingLink} - - ) : ( - - No tracking link available at this time + + {item.name} + + + + + + {item.quantity} + + + ))} + + ), + )} + + Tracking - )} - - + {orderDetails?.trackingLink ? ( + + {orderDetails.trackingLink} + + ) : ( + + No tracking link available at this time + + )} + + + )} diff --git a/apps/frontend/src/components/forms/volunteerCloseOrderModal.tsx b/apps/frontend/src/components/forms/volunteerCloseOrderModal.tsx new file mode 100644 index 000000000..91238f98d --- /dev/null +++ b/apps/frontend/src/components/forms/volunteerCloseOrderModal.tsx @@ -0,0 +1,126 @@ +import React from 'react'; +import { + Box, + Button, + VStack, + CloseButton, + Text, + Flex, + Dialog, +} from '@chakra-ui/react'; +import { AlertStatus } 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 { + orderId: number; + isOpen: boolean; + onClose: () => void; + onSuccess: () => void; +} + +const VolunteerCloseOrderModal: React.FC = ({ + orderId, + isOpen, + onClose, + onSuccess, +}) => { + useModalBodyCleanup(); + const [alertState, setAlertMessage] = useAlert(); + + const onDeleteOrder = async () => { + try { + await apiClient.closeOrder(orderId); + onClose(); + onSuccess(); + } catch { + setAlertMessage('Order could not be deleted.', AlertStatus.ERROR); + } + }; + + return ( + { + if (!e.open) onClose(); + }} + closeOnInteractOutside + > + {alertState && ( + + )} + + + + + + + + + + Confirm Action + + + + + + Are you sure you want to delete this order? This action cannot + be undone. + + + + Order #{orderId} + + + + + + + + + + + + ); +}; + +export default VolunteerCloseOrderModal; diff --git a/apps/frontend/src/containers/volunteerOrderManagement.tsx b/apps/frontend/src/containers/volunteerOrderManagement.tsx index bcc1acbbe..d7b6bc9a2 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, @@ -38,6 +38,7 @@ import { AlertStatus, } 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 +69,7 @@ const VolunteerOrderManagement: React.FC = () => { }); const [selectedOrderId, setSelectedOrderId] = useState(null); + const [deleteOrderId, setDeleteOrderId] = useState(null); const [actionModalOrder, setActionModalOrder] = useState(null); @@ -118,66 +120,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'); @@ -386,6 +388,22 @@ const VolunteerOrderManagement: React.FC = () => { setSelectedOrderId(null); navigate(ROUTES.VOLUNTEER_ORDER_MANAGEMENT, { replace: true }); }} + onSuccess={fetchOrders} + onDelete={() => setDeleteOrderId(selectedOrderId)} + /> + )} + + {deleteOrderId && ( + setDeleteOrderId(null)} + onSuccess={() => { + setDeleteOrderId(null); + setSelectedOrderId(null); + navigate(ROUTES.VOLUNTEER_ORDER_MANAGEMENT, { replace: true }); + fetchOrders(); + }} /> )} diff --git a/apps/frontend/src/types/types.ts b/apps/frontend/src/types/types.ts index 7b3c64a21..7937e6882 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', From 382ea3f64dcf1fa7cd88d54b8b636e0accad4850 Mon Sep 17 00:00:00 2001 From: Dalton Burkhart Date: Sun, 28 Jun 2026 16:21:47 -0700 Subject: [PATCH 5/8] Final commit --- apps/backend/src/orders/order.service.spec.ts | 23 +- apps/backend/src/orders/order.service.ts | 37 +- .../components/forms/orderDetailsModal.tsx | 572 +++++++++--------- .../components/forms/requestDetailsModal.tsx | 2 +- .../forms/volunteerCloseOrderModal.tsx | 129 ++-- .../containers/volunteerOrderManagement.tsx | 50 +- 6 files changed, 414 insertions(+), 399 deletions(-) diff --git a/apps/backend/src/orders/order.service.spec.ts b/apps/backend/src/orders/order.service.spec.ts index 8dfe99e5f..dab0df531 100644 --- a/apps/backend/src/orders/order.service.spec.ts +++ b/apps/backend/src/orders/order.service.spec.ts @@ -1579,26 +1579,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 bc1a3eb3b..3be53eb84 100644 --- a/apps/backend/src/orders/order.service.ts +++ b/apps/backend/src/orders/order.service.ts @@ -87,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 { @@ -111,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( diff --git a/apps/frontend/src/components/forms/orderDetailsModal.tsx b/apps/frontend/src/components/forms/orderDetailsModal.tsx index b9fef63f6..2b197bab8 100644 --- a/apps/frontend/src/components/forms/orderDetailsModal.tsx +++ b/apps/frontend/src/components/forms/orderDetailsModal.tsx @@ -26,6 +26,7 @@ import { OrderStatus, Role, User, + FoodType, } from '../../types/types'; import { TagGroup } from './tagGroup'; import { useGroupedItemsByFoodType } from '../../hooks/groupedItemsByFoodType'; @@ -35,12 +36,11 @@ import { useModalBodyCleanup } from '../../hooks/modalBodyCleanup'; import { EditButton, DeleteButton } from '@components/editDeleteButtons'; interface OrderDetailsModalProps { - orderId: number; + orderId: number | null; isOpen: boolean; onClose: () => void; - // Optionally used by volunteers for editing/deleting onSuccess?: () => void; - onDelete?: () => void; + onDelete?: (order: OrderDetails) => void; } const OrderDetailsModal: React.FC = ({ @@ -71,7 +71,7 @@ const OrderDetailsModal: React.FC = ({ const [alertState, setAlertMessage] = useAlert(); useEffect(() => { - if (isOpen) { + if (isOpen && orderId !== null) { const fetchRequestData = async () => { try { const foodRequestData = await ApiClient.getFoodRequestFromOrder( @@ -91,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); @@ -118,7 +118,7 @@ const OrderDetailsModal: React.FC = ({ const groupedManufacturerItems = useGroupedItemsByFoodType(manufacturerItems); - // This order's current allocation per item, keyed by itemId (OrderItemDetails.id) + // This order's current allocation per item, keyed by itemId const currentAllocations = useMemo(() => { const map: Record = {}; orderDetails?.items.forEach((item) => { @@ -128,6 +128,7 @@ const OrderDetailsModal: React.FC = ({ }, [orderDetails]); const handleEdit = async () => { + if (orderId === null) return; try { const items = await ApiClient.getOrderDonationItems(orderId); setManufacturerItems(items); @@ -157,6 +158,7 @@ const OrderDetailsModal: React.FC = ({ })); const handleSave = async () => { + if (orderId === null) return; try { await ApiClient.editAllocations(orderId, { allocations: allocationsBody, @@ -190,7 +192,10 @@ const OrderDetailsModal: React.FC = ({ open={isOpen} size="xl" onOpenChange={(e: { open: boolean }) => { - if (!e.open) onClose(); + if (!e.open) { + handleCancel(); + onClose(); + } }} closeOnInteractOutside > @@ -203,294 +208,303 @@ const OrderDetailsModal: React.FC = ({ /> )} - - - - - Order #{orderId} - - {!isEditing && - currentUser?.role === Role.VOLUNTEER && - orderDetails?.status === OrderStatus.PENDING && ( - <> - - onDelete?.()} /> - - )} - - - - Fulfilled by {orderDetails?.foodManufacturerName} - - - {isEditing ? ( - - - Add the amount of each product you would like in this order. - - {Object.entries(groupedManufacturerItems).map( - ([foodType, items]) => ( - - - {foodType} - - {items.map((item) => { - const maxSelectable = - item.quantity - - item.reservedQuantity + - (currentAllocations[item.itemId] ?? 0); - return ( - - - {item.itemName} - - - + {orderId !== null && ( + + + + + Order #{orderId} + + {!isEditing && + currentUser?.role === Role.VOLUNTEER && + orderDetails?.status === OrderStatus.PENDING && ( + <> + + orderDetails && onDelete?.(orderDetails)} + /> + + )} + + + + Fulfilled by {orderDetails?.foodManufacturerName} + - { - let value = Number(e.target.value); - if (Number.isNaN(value) || value < 0) value = 0; - if (value > maxSelectable) - value = maxSelectable; - setItemAllocations((prev) => ({ - ...prev, - [item.itemId]: value, - })); - }} - w="80px" - /> + {isEditing ? ( + + + {orderDetails?.foodManufacturerName} Stock + + + {Object.entries(groupedManufacturerItems).map( + ([foodType, items]) => ( + + + {foodType} + {foodRequest?.requestedFoodTypes.includes( + foodType as FoodType, + ) && ( + + Matching + + )} - ); - })} - - ), - )} - - - - - - ) : ( - - - - Order Details - - - Associated Request - - - - {!foodRequest && ( - - {' '} - No associated food request to display{' '} - - )} + {items.map((item) => ( + + + {item.itemName} + - {foodRequest && ( - + + setItemAllocations((prev) => ({ + ...prev, + [item.itemId]: Number(e.target.value), + })) + } + /> + + + ))} + + ), + )} + + + + + + + ) : ( + + + - - - Request {foodRequest.requestId} - - - {' '} - {foodRequest.pantry.pantryName} - - - {foodRequest.status === FoodRequestStatus.CLOSED ? ( - - Closed - - ) : ( - - Active - - )} - + Order Details + + + Associated Request + + + + {!foodRequest && ( + + {' '} + No associated food request to display{' '} + + )} - - - 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.quantity} - - - ))} - - ), - )} - - Tracking - - {orderDetails?.trackingLink ? ( - - {orderDetails.trackingLink} - - ) : ( - - No tracking link available at this time + + {item.name} + + + + + {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 38c011ab1..ed586a89b 100644 --- a/apps/frontend/src/components/forms/requestDetailsModal.tsx +++ b/apps/frontend/src/components/forms/requestDetailsModal.tsx @@ -4,7 +4,7 @@ import { OrderDetails, FoodRequestSummaryDto, } from 'types/types'; -import { ORDER_STATUS_COLORS, ORDER_STATUS_LABELS } from '@utils/utils'; +import { ORDER_STATUS_LABELS } from '@utils/utils'; import { FoodRequestStatus, AlertStatus, diff --git a/apps/frontend/src/components/forms/volunteerCloseOrderModal.tsx b/apps/frontend/src/components/forms/volunteerCloseOrderModal.tsx index 91238f98d..d8eace7ce 100644 --- a/apps/frontend/src/components/forms/volunteerCloseOrderModal.tsx +++ b/apps/frontend/src/components/forms/volunteerCloseOrderModal.tsx @@ -8,21 +8,21 @@ import { Flex, Dialog, } from '@chakra-ui/react'; -import { AlertStatus } from '../../types/types'; +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 { - orderId: number; + order: OrderDetails | null; isOpen: boolean; onClose: () => void; onSuccess: () => void; } const VolunteerCloseOrderModal: React.FC = ({ - orderId, + order, isOpen, onClose, onSuccess, @@ -31,8 +31,9 @@ const VolunteerCloseOrderModal: React.FC = ({ const [alertState, setAlertMessage] = useAlert(); const onDeleteOrder = async () => { + if (order === null) return; try { - await apiClient.closeOrder(orderId); + await apiClient.closeOrder(order.orderId); onClose(); onSuccess(); } catch { @@ -58,67 +59,73 @@ const VolunteerCloseOrderModal: React.FC = ({ /> )} - - - - - + {order !== null && ( + + + + + - - - Confirm Action - - - - - - Are you sure you want to delete this order? This action cannot - be undone. - - + + + Confirm Action + + + + - Order #{orderId} + Are you sure you want to delete this order? This action cannot + be undone. The respective food manufacturer will be notified + of this change. - - - - - - - - - + + Order #{order.orderId} + + + Fulfilled by {order.foodManufacturerName} + + + + + + + + + + + )} ); }; diff --git a/apps/frontend/src/containers/volunteerOrderManagement.tsx b/apps/frontend/src/containers/volunteerOrderManagement.tsx index d7b6bc9a2..09d2d9dcc 100644 --- a/apps/frontend/src/containers/volunteerOrderManagement.tsx +++ b/apps/frontend/src/containers/volunteerOrderManagement.tsx @@ -36,6 +36,7 @@ import { VolunteerAction, User, AlertStatus, + OrderDetails, } from '../types/types'; import OrderDetailsModal from '@components/forms/orderDetailsModal'; import VolunteerCloseOrderModal from '@components/forms/volunteerCloseOrderModal'; @@ -69,7 +70,7 @@ const VolunteerOrderManagement: React.FC = () => { }); const [selectedOrderId, setSelectedOrderId] = useState(null); - const [deleteOrderId, setDeleteOrderId] = useState(null); + const [deleteOrder, setDeleteOrder] = useState(null); const [actionModalOrder, setActionModalOrder] = useState(null); @@ -380,32 +381,27 @@ const VolunteerOrderManagement: React.FC = () => { /> )} - {selectedOrderId && ( - { - setSelectedOrderId(null); - navigate(ROUTES.VOLUNTEER_ORDER_MANAGEMENT, { replace: true }); - }} - onSuccess={fetchOrders} - onDelete={() => setDeleteOrderId(selectedOrderId)} - /> - )} - - {deleteOrderId && ( - setDeleteOrderId(null)} - onSuccess={() => { - setDeleteOrderId(null); - setSelectedOrderId(null); - navigate(ROUTES.VOLUNTEER_ORDER_MANAGEMENT, { replace: true }); - fetchOrders(); - }} - /> - )} + { + setSelectedOrderId(null); + navigate(ROUTES.VOLUNTEER_ORDER_MANAGEMENT, { replace: true }); + }} + onSuccess={fetchOrders} + onDelete={(order) => setDeleteOrder(order)} + /> + + setDeleteOrder(null)} + onSuccess={() => { + fetchOrders(); + setSelectedOrderId(null); + navigate(ROUTES.VOLUNTEER_ORDER_MANAGEMENT, { replace: true }); + }} + /> ); }; From 13444141c13136eaa3f558560e29da6e9ec76f6f Mon Sep 17 00:00:00 2001 From: Dalton Burkhart Date: Sun, 28 Jun 2026 16:41:02 -0700 Subject: [PATCH 6/8] Final commit --- .../src/allocations/allocations.service.spec.ts | 12 ++++++------ apps/backend/src/orders/order.controller.spec.ts | 2 +- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/apps/backend/src/allocations/allocations.service.spec.ts b/apps/backend/src/allocations/allocations.service.spec.ts index 3b24d518e..d85394bae 100644 --- a/apps/backend/src/allocations/allocations.service.spec.ts +++ b/apps/backend/src/allocations/allocations.service.spec.ts @@ -43,8 +43,8 @@ async function insertItem( reserved: number, ): Promise { const [{ item_id }] = await testDataSource.query( - `INSERT INTO donation_items (donation_id, item_name, quantity, reserved_quantity, food_type, details_confirmed) - VALUES ($1, 'Test Item', $2, $3, 'Granola', false) RETURNING item_id`, + `INSERT INTO donation_items (donation_id, item_name, quantity, reserved_quantity, oz_per_item, estimated_value, food_type, details_confirmed) + VALUES ($1, 'Test Item', $2, $3, 0, 0, 'Granola', false) RETURNING item_id`, [donationId, quantity, reserved], ); return item_id; @@ -296,12 +296,12 @@ describe('AllocationsService', () => { // Two donation items with known reserved quantities (donation 1 is seeded). const [{ item_id: itemAId }] = await testDataSource.query( - `INSERT INTO donation_items (donation_id, item_name, quantity, reserved_quantity, food_type, details_confirmed) - VALUES (1, 'Item A', 20, 5, 'Granola', false) RETURNING item_id`, + `INSERT INTO donation_items (donation_id, item_name, quantity, reserved_quantity, oz_per_item, estimated_value, food_type, details_confirmed) + VALUES (1, 'Item A', 20, 5, 0, 0, 'Granola', false) RETURNING item_id`, ); const [{ item_id: itemBId }] = await testDataSource.query( - `INSERT INTO donation_items (donation_id, item_name, quantity, reserved_quantity, food_type, details_confirmed) - VALUES (1, 'Item B', 20, 8, 'Granola', false) RETURNING item_id`, + `INSERT INTO donation_items (donation_id, item_name, quantity, reserved_quantity, oz_per_item, estimated_value, food_type, details_confirmed) + VALUES (1, 'Item B', 20, 8, 0, 0, 'Granola', false) RETURNING item_id`, ); // Two allocations against those items (order 1 is seeded). diff --git a/apps/backend/src/orders/order.controller.spec.ts b/apps/backend/src/orders/order.controller.spec.ts index 9db4cffec..e3ce66f9b 100644 --- a/apps/backend/src/orders/order.controller.spec.ts +++ b/apps/backend/src/orders/order.controller.spec.ts @@ -129,7 +129,7 @@ describe('OrdersController', () => { { itemId: 1, itemName: 'Peanut Butter (16oz)', - foodType: FoodType.SEED_BUTTERS, + foodType: FoodType.DAIRY_FREE_ALTERNATIVES, quantity: 100, reservedQuantity: 10, }, From 3a674d303205274cb218c9934b4854757a04cc56 Mon Sep 17 00:00:00 2001 From: Dalton Burkhart Date: Sun, 28 Jun 2026 16:46:39 -0700 Subject: [PATCH 7/8] Final commit --- apps/backend/src/orders/order.service.spec.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/backend/src/orders/order.service.spec.ts b/apps/backend/src/orders/order.service.spec.ts index dab0df531..544619398 100644 --- a/apps/backend/src/orders/order.service.spec.ts +++ b/apps/backend/src/orders/order.service.spec.ts @@ -347,7 +347,7 @@ describe('OrdersService', () => { const fullyReserved = { itemId: 999, itemName: 'Fully Reserved Item', - foodType: FoodType.SEED_BUTTERS, + foodType: FoodType.SPREADS_SEED_BUTTERS, quantity: 10, reservedQuantity: 10, donationId: 1, @@ -364,7 +364,7 @@ describe('OrdersService', () => { { itemId: 999, itemName: 'Fully Reserved Item', - foodType: FoodType.SEED_BUTTERS, + foodType: FoodType.SPREADS_SEED_BUTTERS, quantity: 10, reservedQuantity: 10, }, From 21fbea668d87e12e0a46e70d634a487585427ed1 Mon Sep 17 00:00:00 2001 From: Dalton Burkhart Date: Tue, 30 Jun 2026 23:02:01 -0700 Subject: [PATCH 8/8] Fixed ordering UI --- .../components/forms/orderDetailsModal.tsx | 55 +++++++++++++------ .../forms/volunteerCloseOrderModal.tsx | 8 +-- .../containers/volunteerOrderManagement.tsx | 10 ++-- 3 files changed, 47 insertions(+), 26 deletions(-) diff --git a/apps/frontend/src/components/forms/orderDetailsModal.tsx b/apps/frontend/src/components/forms/orderDetailsModal.tsx index 2b197bab8..5cde4cf9f 100644 --- a/apps/frontend/src/components/forms/orderDetailsModal.tsx +++ b/apps/frontend/src/components/forms/orderDetailsModal.tsx @@ -115,6 +115,8 @@ const OrderDetailsModal: React.FC = ({ const [itemAllocations, setItemAllocations] = useState< Record >({}); + // The item whose allocation box is currently being edited (focused). + const [editingItemId, setEditingItemId] = useState(null); const groupedManufacturerItems = useGroupedItemsByFoodType(manufacturerItems); @@ -280,29 +282,48 @@ const OrderDetailsModal: React.FC = ({ setEditingItemId(item.itemId)} > - - setItemAllocations((prev) => ({ - ...prev, - [item.itemId]: Number(e.target.value), - })) - } - /> + {editingItemId === item.itemId ? ( + + setItemAllocations((prev) => ({ + ...prev, + [item.itemId]: Number(e.target.value), + })) + } + onBlur={() => setEditingItemId(null)} + /> + ) : ( + + item.quantity - item.reservedQuantity + ? 'red.core' + : 'neutral.800' + } + > + {itemAllocations[item.itemId] ?? 0} of{' '} + {item.quantity - item.reservedQuantity} + + )} ))} diff --git a/apps/frontend/src/components/forms/volunteerCloseOrderModal.tsx b/apps/frontend/src/components/forms/volunteerCloseOrderModal.tsx index d8eace7ce..f23ca0bf7 100644 --- a/apps/frontend/src/components/forms/volunteerCloseOrderModal.tsx +++ b/apps/frontend/src/components/forms/volunteerCloseOrderModal.tsx @@ -30,14 +30,14 @@ const VolunteerCloseOrderModal: React.FC = ({ useModalBodyCleanup(); const [alertState, setAlertMessage] = useAlert(); - const onDeleteOrder = async () => { + const onCloseOrder = async () => { if (order === null) return; try { await apiClient.closeOrder(order.orderId); onClose(); onSuccess(); } catch { - setAlertMessage('Order could not be deleted.', AlertStatus.ERROR); + setAlertMessage('Order could not be closed.', AlertStatus.ERROR); } }; @@ -116,9 +116,9 @@ const VolunteerCloseOrderModal: React.FC = ({ px={5} flexShrink={0} textAlign="center" - onClick={onDeleteOrder} + onClick={onCloseOrder} > - Delete + Close diff --git a/apps/frontend/src/containers/volunteerOrderManagement.tsx b/apps/frontend/src/containers/volunteerOrderManagement.tsx index 09d2d9dcc..2f0cac565 100644 --- a/apps/frontend/src/containers/volunteerOrderManagement.tsx +++ b/apps/frontend/src/containers/volunteerOrderManagement.tsx @@ -70,7 +70,7 @@ const VolunteerOrderManagement: React.FC = () => { }); const [selectedOrderId, setSelectedOrderId] = useState(null); - const [deleteOrder, setDeleteOrder] = useState(null); + const [closeOrder, setCloseOrder] = useState(null); const [actionModalOrder, setActionModalOrder] = useState(null); @@ -389,13 +389,13 @@ const VolunteerOrderManagement: React.FC = () => { navigate(ROUTES.VOLUNTEER_ORDER_MANAGEMENT, { replace: true }); }} onSuccess={fetchOrders} - onDelete={(order) => setDeleteOrder(order)} + onDelete={(order) => setCloseOrder(order)} /> setDeleteOrder(null)} + order={closeOrder} + isOpen={closeOrder !== null} + onClose={() => setCloseOrder(null)} onSuccess={() => { fetchOrders(); setSelectedOrderId(null);