diff --git a/apps/backend/src/foodRequests/request.service.ts b/apps/backend/src/foodRequests/request.service.ts index 4bf7b8468..3e57b10a1 100644 --- a/apps/backend/src/foodRequests/request.service.ts +++ b/apps/backend/src/foodRequests/request.service.ts @@ -317,9 +317,9 @@ export class RequestsService { validateId(requestId, 'Request'); if ( - dto.requestedSize == undefined && - dto.requestedFoodTypes == undefined && - dto.additionalInformation == undefined + dto.requestedSize === undefined && + dto.requestedFoodTypes === undefined && + dto.additionalInformation === undefined ) { throw new BadRequestException( 'At least one field must be provided to update request', diff --git a/apps/frontend/src/api/apiClient.ts b/apps/frontend/src/api/apiClient.ts index 5e822dfb1..3755ab257 100644 --- a/apps/frontend/src/api/apiClient.ts +++ b/apps/frontend/src/api/apiClient.ts @@ -43,6 +43,7 @@ import { BulkUpdateTrackingCostDto, UpdateDonationItemDetailsDto, PendingApplication, + UpdateFoodRequestBody, DonationReminderDto, } from 'types/types'; @@ -104,6 +105,17 @@ export class ApiClient { .then((response) => response.data); } + public async updateFoodRequest( + requestId: number, + body: UpdateFoodRequestBody, + ): Promise { + await this.axiosInstance.patch(`/api/requests/${requestId}`, body); + } + + public async deleteFoodRequest(requestId: number): Promise { + await this.axiosInstance.delete(`/api/requests/${requestId}`); + } + public async closeFoodRequest(requestId: number): Promise { await this.axiosInstance.patch(`/api/requests/${requestId}/close`, {}); } diff --git a/apps/frontend/src/components/foodRequestManagement.tsx b/apps/frontend/src/components/foodRequestManagement.tsx index 7d0bce590..6eedb44dd 100644 --- a/apps/frontend/src/components/foodRequestManagement.tsx +++ b/apps/frontend/src/components/foodRequestManagement.tsx @@ -16,6 +16,7 @@ import { capitalize, formatDate } from '@utils/utils'; import { FloatingAlert } from '@components/floatingAlert'; import { FoodRequestStatus, FoodRequestSummaryDto } from '../types/types'; import RequestDetailsModal from '@components/forms/requestDetailsModal'; +import PantryDeleteRequestActionModal from '@components/forms/pantryDeleteRequestModal'; import VolunteerCloseRequestActionModal from '@components/forms/volunteerCloseRequestModal'; import VolunteerRequestActionRequiredModal from '@components/forms/volunteerRequestActionRequiredModal'; import CreateNewOrderModal from '@components/forms/createNewOrderModal'; @@ -43,6 +44,8 @@ const RequestManagement: React.FC = ({ >([]); const [selectedViewDetailsRequest, setSelectedViewDetailsRequest] = useState(null); + const [deleteRequest, setDeleteRequest] = + useState(null); const [selectedActionRequest, setSelectedActionRequest] = useState(null); const [selectedCloseRequestAction, setSelectedCloseRequestAction] = @@ -392,7 +395,7 @@ const RequestManagement: React.FC = ({ ))} - {selectedViewDetailsRequest && ( + {selectedViewDetailsRequest && !deleteRequest && ( = ({ navigate(location.pathname, { replace: true }); } }} + onSuccess={loadRequests} + onDelete={() => setDeleteRequest(selectedViewDetailsRequest)} + /> + )} + + {deleteRequest && ( + setDeleteRequest(null)} + onSuccess={() => { + setSuccessMessage('Successfully deleted food request.'); + loadRequests(); + setSelectedViewDetailsRequest(null); + }} /> )} diff --git a/apps/frontend/src/components/forms/pantryDeleteRequestModal.tsx b/apps/frontend/src/components/forms/pantryDeleteRequestModal.tsx new file mode 100644 index 000000000..be993a659 --- /dev/null +++ b/apps/frontend/src/components/forms/pantryDeleteRequestModal.tsx @@ -0,0 +1,133 @@ +import React from 'react'; +import { + Box, + Button, + VStack, + CloseButton, + Text, + Flex, + Dialog, +} from '@chakra-ui/react'; +import { FoodRequestSummaryDto } from 'types/types'; +import { formatDate } from '@utils/utils'; +import apiClient from '@api/apiClient'; +import { useAlert } from '../../hooks/alert'; +import { FloatingAlert } from '@components/floatingAlert'; +import { useModalBodyCleanup } from '../../hooks/modalBodyCleanup'; + +interface DeleteRequestActionModalProps { + request: FoodRequestSummaryDto; + isOpen: boolean; + onClose: () => void; + onSuccess: () => void; +} + +const PantryDeleteRequestActionModal: React.FC< + DeleteRequestActionModalProps +> = ({ request, isOpen, onClose, onSuccess }) => { + useModalBodyCleanup(); + const [alertState, setAlertMessage] = useAlert(); + + const onCloseRequest = async () => { + try { + await apiClient.deleteFoodRequest(request.requestId); + onClose(); + onSuccess(); + } catch { + setAlertMessage('Food request could not be deleted.'); + } + }; + + const cancelButton = ( + + ); + + const deleteButton = ( + + ); + + return ( + { + if (!e.open) onClose(); + }} + closeOnInteractOutside + > + {alertState && ( + + )} + + + + + + + + + + Confirm Action + + + + + + Are you sure you want to delete this food request? This action + cannot be undone. + + + + Request #{request.requestId} + + + Submitted {formatDate(request.requestedAt)} + + + + {cancelButton} + {deleteButton} + + + + + + + ); +}; + +export default PantryDeleteRequestActionModal; diff --git a/apps/frontend/src/components/forms/requestDetailsModal.tsx b/apps/frontend/src/components/forms/requestDetailsModal.tsx index 23c4e9e80..d9ecbfd15 100644 --- a/apps/frontend/src/components/forms/requestDetailsModal.tsx +++ b/apps/frontend/src/components/forms/requestDetailsModal.tsx @@ -3,10 +3,11 @@ import { OrderItemDetailsGroupedByFoodType, OrderDetails, FoodRequestSummaryDto, + UpdateFoodRequestBody, } from 'types/types'; -import { OrderStatus } from '../../types/types'; +import { OrderStatus, RequestSize, FoodType } from '../../types/types'; import { ORDER_STATUS_LABELS } from '@utils/utils'; -import React, { useState, useEffect } from 'react'; +import React, { useState, useEffect, useRef } from 'react'; import { Flex, Box, @@ -20,30 +21,53 @@ import { Pagination, ButtonGroup, IconButton, + HStack, + Button, + Textarea, } from '@chakra-ui/react'; -import { ChevronRight, ChevronLeft } from 'lucide-react'; +import { + ChevronRight, + ChevronLeft, + Pencil, + Trash2, + ChevronDownIcon, +} from 'lucide-react'; import { TagGroup } from './tagGroup'; import { useGroupedItemsByFoodType } from '../../hooks/groupedItemsByFoodType'; import { useModalBodyCleanup } from '../../hooks/modalBodyCleanup'; +import { useAlert } from '../../hooks/alert'; +import { FloatingAlert } from '../floatingAlert'; interface RequestDetailsModalProps { request: FoodRequestSummaryDto; isOpen: boolean; onClose: () => void; + onSuccess: () => void; + onDelete: () => void; } const RequestDetailsModal: React.FC = ({ request, isOpen, onClose, + onSuccess, + onDelete, }) => { useModalBodyCleanup(); + const [alertState, setAlertMessage] = useAlert(); + const [orderDetailsList, setOrderDetailsList] = useState([]); const [currentPage, setCurrentPage] = useState(1); - const requestedSize = request.requestedSize; - const selectedFoodTypes = request.requestedFoodTypes; - const additionalNotes = request.additionalInformation; + const [requestedSize, setRequestedSize] = useState( + request.requestedSize, + ); + const [selectedFoodTypes, setSelectedFoodTypes] = useState( + request.requestedFoodTypes, + ); + const [additionalNotes, setAdditionalNotes] = useState( + request.additionalInformation ?? '', + ); useEffect(() => { const fetchRequestOrderDetails = async () => { @@ -88,240 +112,526 @@ const RequestDetailsModal: React.FC = ({ fontWeight: '500', }; - return ( - { - if (!e.open) onClose(); - }} - closeOnInteractOutside + const [isEditing, setIsEditing] = useState(false); + + const handleCancel = () => { + setRequestedSize(request.requestedSize); + setSelectedFoodTypes(request.requestedFoodTypes); + setAdditionalNotes(request.additionalInformation ?? ''); + setIsEditing(false); + }; + + // if the user makes back-to-back changes without closing the modal, the request values stay static + // comparing against dynamic baseline values allows for these edge cases + // e.g. user edits A -> B -> A + // without baseline, the last change would be considered no update and only B would persist + const baseline = useRef({ + requestedSize: request.requestedSize, + requestedFoodTypes: request.requestedFoodTypes, + additionalInformation: request.additionalInformation ?? '', + }); + + const handleUpdate = async () => { + const changed: UpdateFoodRequestBody = {}; + if (requestedSize !== baseline.current.requestedSize) + changed.requestedSize = requestedSize; + const foodTypesChanged = + selectedFoodTypes.length !== baseline.current.requestedFoodTypes.length || + !selectedFoodTypes.every((t) => + baseline.current.requestedFoodTypes.includes(t), + ); + if (foodTypesChanged) changed.requestedFoodTypes = selectedFoodTypes; + if (additionalNotes !== (baseline.current.additionalInformation ?? '')) + changed.additionalInformation = + additionalNotes === '' ? null : additionalNotes; + + // allow user to exit the edit view even if they make no updates + // NOTE: not sure if this is the design choice we want to go with + if (Object.keys(changed).length === 0) { + setIsEditing(false); + return; + } + + try { + await apiClient.updateFoodRequest(request.requestId, changed); + onSuccess(); + baseline.current = { + requestedSize, + requestedFoodTypes: selectedFoodTypes, + additionalInformation: additionalNotes, + }; + setAlertMessage('Successfully updated food request.'); + setIsEditing(false); + } catch (e) { + setAlertMessage('Food request could not be updated.'); + } + }; + + const editButton = ( + setIsEditing(true)} > - - - - - - Food Request #{request.requestId} - - - - - {pantryName} - - - - - - Request Details - - - Associated Orders - - - - - - Size of Shipment - - - - {requestedSize} - - - - - - - - Food Type(s) - - - - - - - - - - Additional Information - - - - {additionalNotes} - - - - - - {!currentOrder && ( - - {' '} - No associated orders to display{' '} - - )} - {currentOrder && ( - - - - Order {currentOrder.orderId} - - - {' '} - Fulfilled by {currentOrder.foodManufacturerName} - + + + ); + + const deleteButton = ( + + + + ); + + return ( + <> + {/* + * TODO: hard-coded as 'info' for now because I worked on another ticket that refactored alertState to have a status + */} + {alertState && ( + + )} + { + if (!e.open) onClose(); + }} + closeOnInteractOutside + > + + + + + + Food Request #{request.requestId} + + {!isEditing && ( + <> + {editButton} + {deleteButton} + + )} + + + + {pantryName} + + {isEditing ? ( + <> + + + + Size of Shipment - {currentOrder.status === OrderStatus.DELIVERED ? ( - - {ORDER_STATUS_LABELS[currentOrder.status]} - - ) : ( - + + + + + + + + setRequestedSize(val.value) + } + > + {Object.values(RequestSize).map((option, idx) => ( + - {item.name} - - - - - + ))} + + + + + + + + + + Food Type(s) + + + + + + + + + {Object.values(FoodType).map((allergen) => { + const isChecked = + selectedFoodTypes.includes(allergen); + return ( + + setSelectedFoodTypes((prev) => + checked + ? [...prev, allergen as FoodType] + : prev.filter((i) => i !== allergen), + ) + } + display="flex" + alignItems="center" > - {item.quantity} - - - ))} - - ), - )} - - Tracking - - - No tracking link available at this time - - - )} - - {orderDetailsList.length > 0 && ( - - setCurrentPage(page)} + + + + {allergen} + + + ); + })} + + + + + setSelectedFoodTypes((prev) => + prev.filter((i) => i !== value), + ) + } + /> + + + + + + Additional Information + + +