diff --git a/apps/backend/src/donations/donations.controller.ts b/apps/backend/src/donations/donations.controller.ts index 3bd34c73f..7a7c6375c 100644 --- a/apps/backend/src/donations/donations.controller.ts +++ b/apps/backend/src/donations/donations.controller.ts @@ -64,11 +64,6 @@ export class DonationsController { } @Roles(Role.FOODMANUFACTURER) - @CheckOwnership({ - idParam: 'foodManufacturerId', - idSource: 'body', - resolver: resolveCreateDonationAuthorizedUserIds, - }) @Post() @ApiBody({ description: 'Details for creating a donation', @@ -116,7 +111,6 @@ export class DonationsController { }, }, }) - @Roles(Role.FOODMANUFACTURER) async createDonation( @Req() req: AuthenticatedRequest, @Body() body: CreateDonationDto, diff --git a/apps/frontend/src/api/apiClient.ts b/apps/frontend/src/api/apiClient.ts index 2f09a76c8..eb762a849 100644 --- a/apps/frontend/src/api/apiClient.ts +++ b/apps/frontend/src/api/apiClient.ts @@ -8,12 +8,10 @@ import { NavigateFunction } from 'react-router-dom'; import { ROUTES } from '../routes'; import { User, - Order, FoodRequest, FoodManufacturer, DonationItem, Donation, - Allocation, CreateFoodRequestBody, Pantry, PantryApplicationDto, @@ -45,6 +43,7 @@ import { PendingApplication, UpdateFoodRequestBody, DonationReminderDto, + ReplaceDonationItemDto, } from 'types/types'; const defaultBaseUrl = @@ -443,6 +442,17 @@ export class ApiClient { ); } + public async editDonationItems( + donationId: number, + items: ReplaceDonationItemDto[], + ): Promise { + await this.axiosInstance.patch(`/api/donations/${donationId}/item`, items); + } + + public async deleteDonation(donationId: number): Promise { + await this.axiosInstance.delete(`/api/donations/${donationId}`); + } + public async updateFoodManufacturerApplicationData( manufacturerId: number, data: UpdateFoodManufacturerApplicationDto, diff --git a/apps/frontend/src/components/forms/donationDetailsModal.tsx b/apps/frontend/src/components/forms/donationDetailsModal.tsx index 198e72fdf..3dd999839 100644 --- a/apps/frontend/src/components/forms/donationDetailsModal.tsx +++ b/apps/frontend/src/components/forms/donationDetailsModal.tsx @@ -1,48 +1,188 @@ -import React, { useState, useEffect } from 'react'; -import { Box, Text, VStack, Dialog, CloseButton } from '@chakra-ui/react'; +import React, { useState, useEffect, useCallback } from 'react'; +import { + Box, + Text, + VStack, + Dialog, + CloseButton, + HStack, + Table, + Button, + Input, + NativeSelect, + NativeSelectIndicator, + Checkbox, + Flex, +} from '@chakra-ui/react'; import ApiClient from '@api/apiClient'; -import { Donation, DonationItem, FoodType } from 'types/types'; +import { + Donation, + DonationItem, + FoodType, + AlertStatus, + ReplaceDonationItemDto, + DonationStatus, + Role, + User, +} from '../../types/types'; import { formatDate } from '@utils/utils'; import { FloatingAlert } from '@components/floatingAlert'; import { useAlert } from '../../hooks/alert'; import { useModalBodyCleanup } from '../../hooks/modalBodyCleanup'; -import { AlertStatus } from '../../types/types'; +import { EditButton, DeleteButton } from '@components/editDeleteButtons'; +import { Minus } from 'lucide-react'; interface DonationDetailsModalProps { donation: Donation; isOpen: boolean; onClose: () => void; + onSuccess?: () => void; + onDelete?: () => void; } +interface DonationRow { + id: number; + foodItem: string; + foodType: FoodType | ''; + numItems: string; + ozPerItem: string; + valuePerItem: string; + foodRescue: boolean; +} + +const mapItemsToRows = (items: DonationItem[]): DonationRow[] => + items.map((item) => ({ + id: item.itemId, + foodItem: item.itemName, + foodType: item.foodType, + numItems: String(item.quantity), + ozPerItem: String(item.ozPerItem), + valuePerItem: String(item.estimatedValue), + foodRescue: item.foodRescue, + })); + const DonationDetailsModal: React.FC = ({ donation, isOpen, onClose, + onSuccess, + onDelete, }) => { useModalBodyCleanup(); + const [currentUser, setCurrentUser] = useState(null); + const [items, setItems] = useState([]); + const [rows, setRows] = useState([]); + const [alertState, setAlertMessage] = useAlert(); + const [isEditing, setIsEditing] = useState(false); + const donationId = donation.donationId; + const hasAllocations = items.some((i) => i.reservedQuantity > 0); + useEffect(() => { - if (!isOpen) return; + const fetchUser = async () => { + try { + const user = await ApiClient.getMe(); + setCurrentUser(user); + } catch { + setCurrentUser(null); + } + }; + fetchUser(); + }, []); + + const placeholderStyles = { + color: 'neutral.300', + fontFamily: 'inter', + fontSize: 'sm', + fontWeight: '400', + }; - const fetchData = async () => { + const deleteRow = (id: number) => { + const newRows = rows.filter((r) => r.id !== id); + setRows(newRows); + }; + + const handleChange = (id: number, field: string, value: string | boolean) => { + const newRows = rows.map((row) => + row.id === id ? { ...row, [field]: value } : row, + ); + setRows(newRows); + }; + + const addRow = () => { + setRows([ + ...rows, + { + id: Date.now(), + foodItem: '', + foodType: '', + numItems: '', + ozPerItem: '', + valuePerItem: '', + foodRescue: false, + }, + ]); + }; + + const handleCancel = () => { + setRows(mapItemsToRows(items)); + setIsEditing(false); + }; + + const loadItems = useCallback(async () => { + try { + const itemsData = await ApiClient.getDonationItemsByDonationId( + donationId, + ); + setItems(itemsData); + setRows(mapItemsToRows(itemsData)); + } catch { + setAlertMessage('Error fetching donation details', AlertStatus.ERROR); + } + }, [donationId, setAlertMessage]); + + const handleUpdate = () => { + const existingIds = new Set(items.map((i) => i.itemId)); + const body: ReplaceDonationItemDto[] = rows.map((r) => ({ + ...(existingIds.has(r.id) ? { itemId: r.id } : {}), + itemName: r.foodItem, + quantity: parseInt(r.numItems), + ozPerItem: parseFloat(r.ozPerItem), + estimatedValue: parseFloat(r.valuePerItem), + foodType: r.foodType as FoodType, + foodRescue: r.foodRescue, + })); + + const updateData = async () => { try { - const itemsData = await ApiClient.getDonationItemsByDonationId( - donationId, + await ApiClient.editDonationItems(donationId, body); + await loadItems(); + onSuccess?.(); + setAlertMessage( + 'Successfully updated donation items.', + AlertStatus.INFO, ); - - setItems(itemsData); + setIsEditing(false); } catch { - setAlertMessage('Error fetching donation details', AlertStatus.ERROR); + setAlertMessage( + 'Donation items could not be updated.', + AlertStatus.ERROR, + ); } }; - fetchData(); - }, [isOpen, donationId, setAlertMessage]); + updateData(); + }; + + useEffect(() => { + if (!isOpen) return; + loadItems(); + }, [isOpen, loadItems]); // Group items by food type const groupedItems = items.reduce((acc, item) => { @@ -71,16 +211,33 @@ const DonationDetailsModal: React.FC = ({ - + - - Donation #{donationId} Stock - + + + Donation #{donationId} Stock + + {currentUser?.role === Role.FOODMANUFACTURER && + donation.status === DonationStatus.AVAILABLE && + !hasAllocations && ( + <> + setIsEditing(true)} + > + {onDelete && ( + + )} + + )} + {donation.foodManufacturer?.foodManufacturerName} @@ -89,56 +246,316 @@ const DonationDetailsModal: React.FC = ({ - - {Object.entries(groupedItems).map(([foodType, typeItems]) => ( - - + + - {foodType} - - - - {typeItems.map((item, index) => ( - - - {item.itemName} - + + + + + Food Item + + * + + + + Food Type + + * + + + + Quantity + + * + + + + Oz. per item + + * + + + + Donation Value + + * + + + + Food Rescue + + * + + + + + + + {rows.map((row) => ( + + + + + + + + handleChange(row.id, 'foodItem', e.target.value) + } + /> + + + + + + handleChange( + row.id, + 'foodType', + e.target.value, + ) + } + > + {Object.values(FoodType).map((type) => ( + + ))} + + + + + + + + handleChange(row.id, 'numItems', e.target.value) + } + /> + + + + + handleChange( + row.id, + 'ozPerItem', + e.target.value, + ) + } + /> + + + + + handleChange( + row.id, + 'valuePerItem', + e.target.value, + ) + } + /> + + + + handleChange(row.id, 'foodRescue', !!e.checked) + } + > + + + + + + + + ))} + + + + + + + + + + + + ) : ( + + {Object.entries(groupedItems).map(([foodType, typeItems]) => ( + + + {foodType} + + + + {typeItems.map((item, _) => ( - - {item.quantity - item.reservedQuantity} of{' '} - {item.quantity} Remaining - + + {item.itemName} + + + + + {item.quantity - item.reservedQuantity} of{' '} + {item.quantity} Remaining + + - - ))} - - - ))} - + ))} + + + ))} + + )} diff --git a/apps/frontend/src/components/forms/fmDeleteDonationModal.tsx b/apps/frontend/src/components/forms/fmDeleteDonationModal.tsx new file mode 100644 index 000000000..05e2da2e2 --- /dev/null +++ b/apps/frontend/src/components/forms/fmDeleteDonationModal.tsx @@ -0,0 +1,133 @@ +import React from 'react'; +import { + Box, + Button, + VStack, + CloseButton, + Text, + Flex, + Dialog, +} from '@chakra-ui/react'; +import { AlertStatus, Donation } 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 FMDeleteDonationActionModalProps { + // allow null donation prop to close modal + // prevents unresponsive UI from unmounting the modal before removing body lock + donation: Donation | null; + isOpen: boolean; + onClose: () => void; + onSuccess: () => void; +} + +const FMDeleteDonationActionModal: React.FC< + FMDeleteDonationActionModalProps +> = ({ donation, isOpen, onClose, onSuccess }) => { + useModalBodyCleanup(); + const [alertState, setAlertMessage] = useAlert(); + + const onDeleteDonation = async () => { + if (!donation) return; + try { + await apiClient.deleteDonation(donation.donationId); + onClose(); + onSuccess(); + } catch { + setAlertMessage('Donation could not be deleted.', AlertStatus.ERROR); + } + }; + + return ( + { + if (!e.open) onClose(); + }} + closeOnInteractOutside + > + {alertState && ( + + )} + + + {donation && ( + + + + + + + + + Confirm Action + + + + + + Are you sure you want to delete this donation? This action + cannot be undone. + + + + Donation #{donation.donationId} + + + Submitted {formatDate(donation.dateDonated)} + + + + + + + + + + + )} + + ); +}; + +export default FMDeleteDonationActionModal; diff --git a/apps/frontend/src/containers/foodManufacturerDonationManagement.tsx b/apps/frontend/src/containers/foodManufacturerDonationManagement.tsx index 4e4780874..990274321 100644 --- a/apps/frontend/src/containers/foodManufacturerDonationManagement.tsx +++ b/apps/frontend/src/containers/foodManufacturerDonationManagement.tsx @@ -10,19 +10,25 @@ import { Pagination, Table, } from '@chakra-ui/react'; +import { ChevronRight, ChevronLeft, Mail } from 'lucide-react'; +import { capitalize, formatDate, DONATION_STATUS_COLORS } from '@utils/utils'; +import { + AlertStatus, + Donation, + DonationDetails, + DonationStatus, +} from '../types/types'; import { FloatingAlert } from '@components/floatingAlert'; import DonationDetailsModal from '@components/forms/donationDetailsModal'; import FmCompleteRequiredActionsModal from '@components/forms/fmCompleteRequiredActionsModal'; import NewDonationFormModal from '@components/forms/newDonationFormModal'; import ResubmitDonationModal from '@components/forms/resubmitDonationModal'; import SectionEmptyState from '@components/sectionEmptyState'; -import { capitalize, DONATION_STATUS_COLORS, formatDate } from '@utils/utils'; -import { ChevronLeft, ChevronRight, Mail } from 'lucide-react'; import React, { useEffect, useState } from 'react'; import { useNavigate, useSearchParams } from 'react-router-dom'; import { useAlert } from '../hooks/alert'; +import FMDeleteDonationActionModal from '@components/forms/fmDeleteDonationModal'; import { ROUTES } from '../routes'; -import { AlertStatus, DonationDetails, DonationStatus } from '../types/types'; const MAX_PER_STATUS = 5; @@ -33,8 +39,7 @@ const FoodManufacturerDonationManagement: React.FC = () => { searchParams.get('resubmitDonationId'); const [isResubmitOpen, setIsResubmitOpen] = useState(false); const [loading, setLoading] = useState(true); - const [errorAlertState, setErrorMessage] = useAlert(); - const [successAlertState, setSuccessMessage] = useAlert(); + const [alertState, setAlertMessage] = useAlert(); const [isLogDonationOpen, setIsLogDonationOpen] = useState(false); const [manufacturerId, setManufacturerId] = useState(null); const [selectedActionDonation, setSelectedActionDonation] = @@ -56,13 +61,12 @@ const FoodManufacturerDonationManagement: React.FC = () => { [DonationStatus.FULFILLED]: 1, }); - // State to hold selected donation for details modal - const [selectedDonationId, setSelectedDonationId] = useState( - null, - ); + const [selectedViewDetailsDonation, setSelectedViewDetailsDonation] = + useState(null); + const [deleteDonation, setDeleteDonation] = useState(null); // Fetch all donations on component mount and sorts them into their appropriate status lists - const fetchDonations = async (fmId: number) => { + const fetchDonations = async () => { try { const data = await ApiClient.getAllDonationsByFoodManufacturer(); @@ -111,7 +115,7 @@ const FoodManufacturerDonationManagement: React.FC = () => { return grouped; } catch (error) { - setErrorMessage('Error fetching donations', AlertStatus.ERROR); + setAlertMessage('Error fetching donations', AlertStatus.ERROR); return; } }; @@ -137,10 +141,10 @@ const FoodManufacturerDonationManagement: React.FC = () => { try { const fmId = await ApiClient.getCurrentUserFoodManufacturerId(); setManufacturerId(fmId); - const grouped = await fetchDonations(fmId); + const grouped = await fetchDonations(); if (grouped) openResubmitFromQueryParam(grouped); } catch { - setErrorMessage( + setAlertMessage( 'Error initializing donation management', AlertStatus.ERROR, ); @@ -156,8 +160,15 @@ const FoodManufacturerDonationManagement: React.FC = () => { if (!donationIdParam) return; const id = Number(donationIdParam); - setSelectedDonationId(id); - }, [searchParams, setErrorMessage]); + + // match the ID to a donation from any of the three donation status buckets + const match = Object.values(statusDonations) + .flat() + .find((d) => d.donation.donationId === id); + if (match) { + setSelectedViewDetailsDonation(match.donation); + } + }, [searchParams, statusDonations]); const handleResubmitClose = () => { setIsResubmitOpen(false); @@ -177,19 +188,11 @@ const FoodManufacturerDonationManagement: React.FC = () => { return ( - {errorAlertState && ( - - )} - {successAlertState && ( + {alertState && ( )} @@ -232,7 +235,7 @@ const FoodManufacturerDonationManagement: React.FC = () => { {manufacturerId !== null && ( fetchDonations(manufacturerId)} + onDonationSuccess={() => fetchDonations()} isOpen={isLogDonationOpen} onClose={() => setIsLogDonationOpen(false)} /> @@ -242,7 +245,7 @@ const FoodManufacturerDonationManagement: React.FC = () => { fetchDonations(manufacturerId)} + onSuccess={() => fetchDonations()} donations={Object.values(statusDonations).flat()} foodManufacturerId={manufacturerId} initialDonationId={ @@ -261,8 +264,8 @@ const FoodManufacturerDonationManagement: React.FC = () => { onClose={() => setSelectedActionDonation(null)} onSuccess={() => { setSelectedActionDonation(null); - if (manufacturerId !== null) fetchDonations(manufacturerId); - setSuccessMessage( + if (manufacturerId !== null) fetchDonations(); + setAlertMessage( 'Your details have been saved. Actions are complete once all shipment and item details are confirmed.', AlertStatus.INFO, ); @@ -270,6 +273,35 @@ const FoodManufacturerDonationManagement: React.FC = () => { /> )} + { + setDeleteDonation(null); + }} + onSuccess={() => { + setAlertMessage( + 'Successfully deleted donation items.', + AlertStatus.INFO, + ); + fetchDonations(); + setDeleteDonation(null); + setSelectedViewDetailsDonation(null); + }} + /> + + {selectedViewDetailsDonation && ( + setSelectedViewDetailsDonation(null)} + onSuccess={() => fetchDonations()} + onDelete={() => { + setDeleteDonation(selectedViewDetailsDonation); + }} + /> + )} + {Object.values(DonationStatus).map((status) => { const allDonationsByStatus = statusDonations[status] || []; @@ -285,16 +317,11 @@ const FoodManufacturerDonationManagement: React.FC = () => { donations={displayedDonations} status={status} colors={DONATION_STATUS_COLORS[status]} - selectedDonationId={selectedDonationId} - onDonationSelect={setSelectedDonationId} + onDonationSelect={setSelectedViewDetailsDonation} totalDonations={allDonationsByStatus.length} currentPage={currentPage} onPageChange={(page) => handlePageChange(status, page)} onActionSelect={setSelectedActionDonation} - onDonationClose={() => { - setSelectedDonationId(null); - navigate(ROUTES.FM_DONATION_MANAGEMENT, { replace: true }); - }} /> ); @@ -307,13 +334,11 @@ interface DonationStatusSectionProps { donations: DonationDetails[]; status: DonationStatus; colors: string[]; - onDonationSelect: (donationId: number | null) => void; - selectedDonationId: number | null; + onDonationSelect: (donation: Donation | null) => void; totalDonations: number; currentPage: number; onPageChange: (page: number) => void; onActionSelect: (donation: DonationDetails | null) => void; - onDonationClose: () => void; } const DonationStatusSection: React.FC = ({ @@ -323,10 +348,8 @@ const DonationStatusSection: React.FC = ({ onDonationSelect, totalDonations, currentPage, - selectedDonationId, onPageChange, onActionSelect, - onDonationClose, }) => { const totalPages = Math.ceil(totalDonations / MAX_PER_STATUS); @@ -428,17 +451,10 @@ const DonationStatusSection: React.FC = ({ onDonationSelect(donation.donationId)} + onClick={() => onDonationSelect(donation)} > {donation.donationId} - {selectedDonationId === donation.donationId && ( - - )}