diff --git a/apps/frontend/src/components/dashboardCard.tsx b/apps/frontend/src/components/dashboardCard.tsx index 140c0f4aa..5eaa9b74e 100644 --- a/apps/frontend/src/components/dashboardCard.tsx +++ b/apps/frontend/src/components/dashboardCard.tsx @@ -30,7 +30,7 @@ export const CARD_TYPE_ICON: Record = { const CARD_TYPE_DATE_LABEL: Record = { [DashboardCardType.ACTION]: 'Applied', [DashboardCardType.ORDER]: 'Requested', - [DashboardCardType.UPCOMING_DONATION]: 'Scheduled', + [DashboardCardType.UPCOMING_DONATION]: 'Scheduled to send', [DashboardCardType.RECENT_DONATION]: 'Donated', [DashboardCardType.FOOD_REQUEST]: 'Requested', }; diff --git a/apps/frontend/src/components/foodRequestManagement.tsx b/apps/frontend/src/components/foodRequestManagement.tsx index 62ddb0805..b9c5a9020 100644 --- a/apps/frontend/src/components/foodRequestManagement.tsx +++ b/apps/frontend/src/components/foodRequestManagement.tsx @@ -14,6 +14,7 @@ import { capitalize, formatDate } from '@utils/utils'; import { FloatingAlert } from '@components/floatingAlert'; import { FoodRequestStatus, FoodRequestSummaryDto } from '../types/types'; import PageEmptyState from '@components/pageEmptyState'; +import SectionEmptyState from '@components/sectionEmptyState'; import { PaginationControl } from '@components/pagination'; import RequestDetailsModal from '@components/forms/requestDetailsModal'; import PantryDeleteRequestActionModal from '@components/forms/pantryDeleteRequestModal'; @@ -56,15 +57,18 @@ const RequestManagement: React.FC = ({ useState(null); const [alertState, setAlertMessage] = useAlert(); + const [fetchFailed, setFetchFailed] = useState(false); const navigate = useNavigate(); const location = useLocation(); const loadRequests = useCallback(async () => { + setFetchFailed(false); try { const data = await fetchData(); setRequests(data); } catch { + setFetchFailed(true); setAlertMessage('Error fetching requests', AlertStatus.ERROR); } }, [fetchData, setAlertMessage]); @@ -172,7 +176,16 @@ const RequestManagement: React.FC = ({ timeout={6000} /> )} - {requests.length === 0 ? ( + {fetchFailed ? ( + <> + + + + + + ) : requests.length === 0 ? ( ) : ( <> diff --git a/apps/frontend/src/components/forms/addNewVolunteerModal.tsx b/apps/frontend/src/components/forms/addNewVolunteerModal.tsx index aa146fd76..bb72d2ec9 100644 --- a/apps/frontend/src/components/forms/addNewVolunteerModal.tsx +++ b/apps/frontend/src/components/forms/addNewVolunteerModal.tsx @@ -109,6 +109,8 @@ const NewVolunteerModal: React.FC = ({ return ( e.open ? setIsOpen(true) : closeAndReset() diff --git a/apps/frontend/src/components/forms/assignVolunteersModal.tsx b/apps/frontend/src/components/forms/assignVolunteersModal.tsx index 120fab770..c8e1e5693 100644 --- a/apps/frontend/src/components/forms/assignVolunteersModal.tsx +++ b/apps/frontend/src/components/forms/assignVolunteersModal.tsx @@ -131,6 +131,8 @@ const AssignVolunteersModal: React.FC = ({ return ( { diff --git a/apps/frontend/src/components/forms/changePasswordModal.tsx b/apps/frontend/src/components/forms/changePasswordModal.tsx index c74a34012..bacdab565 100644 --- a/apps/frontend/src/components/forms/changePasswordModal.tsx +++ b/apps/frontend/src/components/forms/changePasswordModal.tsx @@ -37,6 +37,7 @@ const ChangePasswordModal: React.FC = ({ const [showConfirmPassword, setShowConfirmPassword] = useState(false); const [showOldPassword, setShowOldPassword] = useState(false); const [alertState, setAlertMessage] = useAlert(); + const [isSaving, setIsSaving] = useState(false); const handleChangePassword = async () => { if (password.length < 8) { @@ -52,6 +53,7 @@ const ChangePasswordModal: React.FC = ({ return; } + setIsSaving(true); try { await updatePassword({ oldPassword, @@ -77,6 +79,8 @@ const ChangePasswordModal: React.FC = ({ AlertStatus.ERROR, ); } + } finally { + setIsSaving(false); } }; @@ -103,6 +107,8 @@ const ChangePasswordModal: React.FC = ({ return ( { if (!e.open) { @@ -221,7 +227,10 @@ const ChangePasswordModal: React.FC = ({ textStyle="p2" fontWeight={600} mt={8} - disabled={!confirmPassword || !password || !oldPassword} + loading={isSaving} + disabled={ + !confirmPassword || !password || !oldPassword || isSaving + } > Change Password diff --git a/apps/frontend/src/components/forms/completeRequiredActionsModal.tsx b/apps/frontend/src/components/forms/completeRequiredActionsModal.tsx index ab85d4c95..39ccb88e3 100644 --- a/apps/frontend/src/components/forms/completeRequiredActionsModal.tsx +++ b/apps/frontend/src/components/forms/completeRequiredActionsModal.tsx @@ -64,6 +64,8 @@ const CompleteRequiredActionsModal: React.FC< return ( { diff --git a/apps/frontend/src/components/forms/confirmActionModal.tsx b/apps/frontend/src/components/forms/confirmActionModal.tsx index 2bebeb0d7..ee99a64ea 100644 --- a/apps/frontend/src/components/forms/confirmActionModal.tsx +++ b/apps/frontend/src/components/forms/confirmActionModal.tsx @@ -20,6 +20,8 @@ const ConfirmActionModal: React.FC = ({ return ( !e.open && onClose()} > diff --git a/apps/frontend/src/components/forms/confirmFoodManufacturerDecisionModal.tsx b/apps/frontend/src/components/forms/confirmFoodManufacturerDecisionModal.tsx index b4bdbba66..b5094a2b1 100644 --- a/apps/frontend/src/components/forms/confirmFoodManufacturerDecisionModal.tsx +++ b/apps/frontend/src/components/forms/confirmFoodManufacturerDecisionModal.tsx @@ -24,6 +24,8 @@ const ConfirmFoodManufacturerDecisionModal: React.FC< useModalBodyCleanup(); return ( !e.open && onClose()} > diff --git a/apps/frontend/src/components/forms/confirmPantryDecisionModal.tsx b/apps/frontend/src/components/forms/confirmPantryDecisionModal.tsx index e9dd0e073..8f4a26d58 100644 --- a/apps/frontend/src/components/forms/confirmPantryDecisionModal.tsx +++ b/apps/frontend/src/components/forms/confirmPantryDecisionModal.tsx @@ -22,6 +22,8 @@ const ConfirmPantryDecisionModal: React.FC = ({ useModalBodyCleanup(); return ( !e.open && onClose()} > diff --git a/apps/frontend/src/components/forms/createNewOrderModal.tsx b/apps/frontend/src/components/forms/createNewOrderModal.tsx index b9a1d3d8c..c2fe9b6f8 100644 --- a/apps/frontend/src/components/forms/createNewOrderModal.tsx +++ b/apps/frontend/src/components/forms/createNewOrderModal.tsx @@ -166,6 +166,8 @@ const CreateNewOrderModal: React.FC = ({ return ( { diff --git a/apps/frontend/src/components/forms/donationDetailsModal.tsx b/apps/frontend/src/components/forms/donationDetailsModal.tsx index 241fb8774..cef00b984 100644 --- a/apps/frontend/src/components/forms/donationDetailsModal.tsx +++ b/apps/frontend/src/components/forms/donationDetailsModal.tsx @@ -115,6 +115,8 @@ const DonationDetailsModal: React.FC = ({ return ( { if (!e.open) { diff --git a/apps/frontend/src/components/forms/editableFMApplication.tsx b/apps/frontend/src/components/forms/editableFMApplication.tsx index 835500242..f11a7595f 100644 --- a/apps/frontend/src/components/forms/editableFMApplication.tsx +++ b/apps/frontend/src/components/forms/editableFMApplication.tsx @@ -116,7 +116,7 @@ const EditableFMApplication: React.FC = ({ AlertStatus.ERROR, ); } - }, [foodManufacturerId]); + }, [foodManufacturerId, setAlertMessage]); useEffect(() => { // Fetch the application when we don't have one loaded already diff --git a/apps/frontend/src/components/forms/editablePantryApplication.tsx b/apps/frontend/src/components/forms/editablePantryApplication.tsx index 31c79325b..ba6905574 100644 --- a/apps/frontend/src/components/forms/editablePantryApplication.tsx +++ b/apps/frontend/src/components/forms/editablePantryApplication.tsx @@ -291,7 +291,7 @@ const EditablePantryApplication: React.FC = ({ } finally { setIsLoading(false); } - }, []); + }, [setAlertMessage]); useEffect(() => { if (!initialApplication) { diff --git a/apps/frontend/src/components/forms/fmCompleteRequiredActionsModal.tsx b/apps/frontend/src/components/forms/fmCompleteRequiredActionsModal.tsx index 760331583..824bc71f8 100644 --- a/apps/frontend/src/components/forms/fmCompleteRequiredActionsModal.tsx +++ b/apps/frontend/src/components/forms/fmCompleteRequiredActionsModal.tsx @@ -37,7 +37,7 @@ interface FmCompleteRequiredActionsModalProps { donation: DonationDetails; isOpen: boolean; onClose: () => void; - onSuccess: () => void; + onSuccess: (allOrdersComplete: boolean) => void; } interface OrderFormData { @@ -160,16 +160,16 @@ const FmCompleteRequiredActionsModal: React.FC< const [isSubmitting, setIsSubmitting] = useState(false); const [alertState, setAlertMessage] = useAlert(); - // True once every relevant item has both ozPerItem and estimatedValue filled in + // True once every relevant item has both ozPerItem and estimatedValue set to at least 0.01 const isSubmitEnabled = useMemo( () => donation.relevantDonationItems.length > 0 && donation.relevantDonationItems.every( (item) => - itemFormData[item.itemId].ozPerItem !== '' && - itemFormData[item.itemId].estimatedValue !== '', + parseFloat(itemFormData[item.itemId].ozPerItem) >= 0.01 && + parseFloat(itemFormData[item.itemId].estimatedValue) >= 0.01, ), - [itemFormData], + [itemFormData, donation.relevantDonationItems], ); // The order currently shown in the shipping stage based on the current page @@ -284,7 +284,13 @@ const FmCompleteRequiredActionsModal: React.FC< }); } - onSuccess(); + // Whether every pending order now has both a shipping cost and a tracking + // link set, i.e. will flip to SHIPPED once this save lands on the backend + const allOrdersComplete = orders.every((order) => { + const { trackingLink, shippingCost } = orderFormData[order.orderId]; + return trackingLink.trim() !== '' && shippingCost !== ''; + }); + onSuccess(allOrdersComplete); } catch (error) { const rawMsg = axios.isAxiosError(error) && error.response?.data?.message; const msg = Array.isArray(rawMsg) ? rawMsg[0] : rawMsg; @@ -312,6 +318,8 @@ const FmCompleteRequiredActionsModal: React.FC< return ( { diff --git a/apps/frontend/src/components/forms/fmDeleteDonationModal.tsx b/apps/frontend/src/components/forms/fmDeleteDonationModal.tsx index da85d57a6..5b27733c0 100644 --- a/apps/frontend/src/components/forms/fmDeleteDonationModal.tsx +++ b/apps/frontend/src/components/forms/fmDeleteDonationModal.tsx @@ -1,4 +1,4 @@ -import React, { useRef } from 'react'; +import React, { useRef, useState } from 'react'; import { Box, Button, @@ -29,24 +29,30 @@ const FMDeleteDonationActionModal: React.FC< > = ({ donation, isOpen, onClose, onSuccess }) => { useModalBodyCleanup(); const [alertState, setAlertMessage] = useAlert(); + const [isDeleting, setIsDeleting] = useState(false); const donationRef = useRef(donation); if (donation) donationRef.current = donation; const displayDonation = donation ?? donationRef.current; const onDeleteDonation = async () => { - if (!donation) return; + if (!donation || isDeleting) return; + setIsDeleting(true); try { await apiClient.deleteDonation(donation.donationId); onClose(); onSuccess(); } catch { setAlertMessage('Donation could not be deleted.', AlertStatus.ERROR); + } finally { + setIsDeleting(false); } }; return ( { @@ -107,6 +113,7 @@ const FMDeleteDonationActionModal: React.FC< textAlign="center" lineHeight="28px" onClick={onClose} + disabled={isDeleting} > Cancel @@ -121,6 +128,8 @@ const FMDeleteDonationActionModal: React.FC< flexShrink={0} textAlign="center" onClick={onDeleteDonation} + loading={isDeleting} + disabled={isDeleting} > Delete diff --git a/apps/frontend/src/components/forms/newDonationFormModal.tsx b/apps/frontend/src/components/forms/newDonationFormModal.tsx index d79f6fda3..e696b9d44 100644 --- a/apps/frontend/src/components/forms/newDonationFormModal.tsx +++ b/apps/frontend/src/components/forms/newDonationFormModal.tsx @@ -99,6 +99,8 @@ const NewDonationFormModal: React.FC = ({ return ( { diff --git a/apps/frontend/src/components/forms/orderDetailsModal.tsx b/apps/frontend/src/components/forms/orderDetailsModal.tsx index 3b85143ab..2c868f99f 100644 --- a/apps/frontend/src/components/forms/orderDetailsModal.tsx +++ b/apps/frontend/src/components/forms/orderDetailsModal.tsx @@ -117,6 +117,7 @@ const OrderDetailsModal: React.FC = ({ >({}); // The item whose allocation box is currently being edited (focused). const [editingItemId, setEditingItemId] = useState(null); + const [isSaving, setIsSaving] = useState(false); const groupedManufacturerItems = useGroupedItemsByFoodType(manufacturerItems); @@ -161,6 +162,7 @@ const OrderDetailsModal: React.FC = ({ const handleSave = async () => { if (orderId === null) return; + setIsSaving(true); try { await ApiClient.editAllocations(orderId, { allocations: allocationsBody, @@ -172,6 +174,8 @@ const OrderDetailsModal: React.FC = ({ setAlertMessage('Successfully updated order.', AlertStatus.INFO); } catch { setAlertMessage('Order could not be updated.', AlertStatus.ERROR); + } finally { + setIsSaving(false); } }; @@ -191,6 +195,8 @@ const OrderDetailsModal: React.FC = ({ return ( { @@ -457,6 +463,7 @@ const OrderDetailsModal: React.FC = ({ background="bg" color="neutral.800" borderColor="neutral.200" + disabled={isSaving} > Cancel @@ -464,6 +471,8 @@ const OrderDetailsModal: React.FC = ({ onClick={handleSave} bg="blue.hover" color="white" + loading={isSaving} + disabled={isSaving} > Update Order diff --git a/apps/frontend/src/components/forms/orderReceivedActionModal.tsx b/apps/frontend/src/components/forms/orderReceivedActionModal.tsx index 00fe75319..02658cf43 100644 --- a/apps/frontend/src/components/forms/orderReceivedActionModal.tsx +++ b/apps/frontend/src/components/forms/orderReceivedActionModal.tsx @@ -90,6 +90,8 @@ const OrderReceivedActionModal: React.FC = ({ return ( { diff --git a/apps/frontend/src/components/forms/pantryApplicationModal.tsx b/apps/frontend/src/components/forms/pantryApplicationModal.tsx index 2c5cad669..a5256752a 100644 --- a/apps/frontend/src/components/forms/pantryApplicationModal.tsx +++ b/apps/frontend/src/components/forms/pantryApplicationModal.tsx @@ -19,6 +19,8 @@ const PantryApplicationModal: React.FC = ({ const pantryUser = pantry.pantryUser; return ( { if (!e.open) onClose(); diff --git a/apps/frontend/src/components/forms/pantryDeleteRequestModal.tsx b/apps/frontend/src/components/forms/pantryDeleteRequestModal.tsx index 14a8ac8be..efec81e7e 100644 --- a/apps/frontend/src/components/forms/pantryDeleteRequestModal.tsx +++ b/apps/frontend/src/components/forms/pantryDeleteRequestModal.tsx @@ -1,4 +1,4 @@ -import React from 'react'; +import React, { useState } from 'react'; import { Box, Button, @@ -27,19 +27,26 @@ const PantryDeleteRequestActionModal: React.FC< > = ({ request, isOpen, onClose, onSuccess }) => { useModalBodyCleanup(); const [alertState, setAlertMessage] = useAlert(); + const [isDeleting, setIsDeleting] = useState(false); const onCloseRequest = async () => { + if (isDeleting) return; + setIsDeleting(true); try { await apiClient.deleteFoodRequest(request.requestId); onClose(); onSuccess(); } catch { setAlertMessage('Food request could not be deleted.', AlertStatus.ERROR); + } finally { + setIsDeleting(false); } }; return ( { @@ -98,6 +105,7 @@ const PantryDeleteRequestActionModal: React.FC< textAlign="center" lineHeight="28px" onClick={onClose} + disabled={isDeleting} > Cancel @@ -112,6 +120,8 @@ const PantryDeleteRequestActionModal: React.FC< flexShrink={0} textAlign="center" onClick={onCloseRequest} + loading={isDeleting} + disabled={isDeleting} > Delete diff --git a/apps/frontend/src/components/forms/promoteVolunteerModal.tsx b/apps/frontend/src/components/forms/promoteVolunteerModal.tsx index 53d574391..2717cd8dc 100644 --- a/apps/frontend/src/components/forms/promoteVolunteerModal.tsx +++ b/apps/frontend/src/components/forms/promoteVolunteerModal.tsx @@ -18,6 +18,8 @@ const PromoteVolunteerModal: React.FC = ({ return ( !e.open && onClose()} > diff --git a/apps/frontend/src/components/forms/requestDetailsModal.tsx b/apps/frontend/src/components/forms/requestDetailsModal.tsx index d805c00ed..555567731 100644 --- a/apps/frontend/src/components/forms/requestDetailsModal.tsx +++ b/apps/frontend/src/components/forms/requestDetailsModal.tsx @@ -148,6 +148,7 @@ const RequestDetailsModal: React.FC = ({ }; const [isEditing, setIsEditing] = useState(false); + const [isSaving, setIsSaving] = useState(false); const handleCancel = () => { setRequestedSize(request.requestedSize); @@ -160,6 +161,7 @@ const RequestDetailsModal: React.FC = ({ }; const handleUpdate = async () => { + setIsSaving(true); try { await apiClient.updateFoodRequest(request.requestId, { requestedSize, @@ -174,6 +176,8 @@ const RequestDetailsModal: React.FC = ({ setIsEditing(false); } catch { setAlertMessage('Food request could not be updated.', AlertStatus.ERROR); + } finally { + setIsSaving(false); } }; @@ -188,6 +192,8 @@ const RequestDetailsModal: React.FC = ({ /> )} { @@ -473,6 +479,7 @@ const RequestDetailsModal: React.FC = ({ background="bg" color="neutral.800" borderColor="neutral.200" + disabled={isSaving} > Cancel @@ -481,8 +488,10 @@ const RequestDetailsModal: React.FC = ({ disabled={ selectedFoodTypes.length === 0 || locationCity.trim() === '' || - locationState.trim() === '' + locationState.trim() === '' || + isSaving } + loading={isSaving} bg="blue.hover" color="white" > diff --git a/apps/frontend/src/components/forms/requestFormModal.tsx b/apps/frontend/src/components/forms/requestFormModal.tsx index e27af19b6..855aec6bd 100644 --- a/apps/frontend/src/components/forms/requestFormModal.tsx +++ b/apps/frontend/src/components/forms/requestFormModal.tsx @@ -114,6 +114,8 @@ const FoodRequestFormModal: React.FC = ({ return ( { diff --git a/apps/frontend/src/components/forms/resubmitDonationModal.tsx b/apps/frontend/src/components/forms/resubmitDonationModal.tsx index 1fc49e2c8..835b23e71 100644 --- a/apps/frontend/src/components/forms/resubmitDonationModal.tsx +++ b/apps/frontend/src/components/forms/resubmitDonationModal.tsx @@ -85,6 +85,15 @@ const ResubmitDonationModal: React.FC = ({ [setAlertMessage], ); + const handleSelect = useCallback( + (donationId: number) => { + setSelectedDonationId(donationId); + fetchItemsForDonation(donationId); + onSelect(donationId); + }, + [fetchItemsForDonation, onSelect], + ); + useEffect(() => { if ( isOpen && @@ -93,13 +102,7 @@ const ResubmitDonationModal: React.FC = ({ ) { handleSelect(initialDonationId); } - }, [isOpen, initialDonationId, selectedDonationId, fetchItemsForDonation]); - - const handleSelect = (donationId: number) => { - setSelectedDonationId(donationId); - fetchItemsForDonation(donationId); - onSelect(donationId); - }; + }, [isOpen, initialDonationId, selectedDonationId, handleSelect]); const handleClose = () => { setSelectedDonationId(null); @@ -139,6 +142,8 @@ const ResubmitDonationModal: React.FC = ({ return ( { diff --git a/apps/frontend/src/components/forms/volunteerCloseOrderModal.tsx b/apps/frontend/src/components/forms/volunteerCloseOrderModal.tsx index ff7934092..f910d7811 100644 --- a/apps/frontend/src/components/forms/volunteerCloseOrderModal.tsx +++ b/apps/frontend/src/components/forms/volunteerCloseOrderModal.tsx @@ -1,4 +1,4 @@ -import React from 'react'; +import React, { useState } from 'react'; import { Box, Button, @@ -29,20 +29,26 @@ const VolunteerCloseOrderModal: React.FC = ({ }) => { useModalBodyCleanup(); const [alertState, setAlertMessage] = useAlert(); + const [isDeleting, setIsDeleting] = useState(false); const onCloseOrder = async () => { - if (order === null) return; + if (order === null || isDeleting) return; + setIsDeleting(true); try { await apiClient.closeOrder(order.orderId); onClose(); onSuccess(); } catch { setAlertMessage('Order could not be closed.', AlertStatus.ERROR); + } finally { + setIsDeleting(false); } }; return ( { @@ -102,6 +108,7 @@ const VolunteerCloseOrderModal: React.FC = ({ textAlign="center" lineHeight="28px" onClick={onClose} + disabled={isDeleting} > Cancel @@ -116,6 +123,8 @@ const VolunteerCloseOrderModal: React.FC = ({ flexShrink={0} textAlign="center" onClick={onCloseOrder} + loading={isDeleting} + disabled={isDeleting} > Close diff --git a/apps/frontend/src/components/forms/volunteerCloseRequestModal.tsx b/apps/frontend/src/components/forms/volunteerCloseRequestModal.tsx index 9057b2853..693eab2c7 100644 --- a/apps/frontend/src/components/forms/volunteerCloseRequestModal.tsx +++ b/apps/frontend/src/components/forms/volunteerCloseRequestModal.tsx @@ -43,6 +43,8 @@ const VolunteerCloseRequestActionModal: React.FC< return ( { diff --git a/apps/frontend/src/components/forms/volunteerRequestActionRequiredModal.tsx b/apps/frontend/src/components/forms/volunteerRequestActionRequiredModal.tsx index 39733d811..8fa6f239b 100644 --- a/apps/frontend/src/components/forms/volunteerRequestActionRequiredModal.tsx +++ b/apps/frontend/src/components/forms/volunteerRequestActionRequiredModal.tsx @@ -31,6 +31,8 @@ const VolunteerRequestActionRequiredModal: React.FC< return ( { diff --git a/apps/frontend/src/components/sectionEmptyState.tsx b/apps/frontend/src/components/sectionEmptyState.tsx index 96a91773c..1dedd1680 100644 --- a/apps/frontend/src/components/sectionEmptyState.tsx +++ b/apps/frontend/src/components/sectionEmptyState.tsx @@ -17,9 +17,6 @@ const SectionEmptyState: React.FC = ({ entity, subtitle }) => { py={10} gap={2} > - - Nothing to see here! - {message} diff --git a/apps/frontend/src/containers/adminDashboard.tsx b/apps/frontend/src/containers/adminDashboard.tsx index a1a0e3b81..fe7779522 100644 --- a/apps/frontend/src/containers/adminDashboard.tsx +++ b/apps/frontend/src/containers/adminDashboard.tsx @@ -1,5 +1,5 @@ import ApiClient from '@api/apiClient'; -import { Box, Heading, Text } from '@chakra-ui/react'; +import { Box, Button, Heading, Text } from '@chakra-ui/react'; import DashboardCard, { DashboardCardType, DONATION_STATUS_BADGE, @@ -33,66 +33,82 @@ const AdminDashboard: React.FC = () => { const [recentDonations, setRecentDonations] = useState([]); const [currentUser, setCurrentUser] = useState(null); const [stats, setStats] = useState | null>(null); + const [pendingApplicationsFailed, setPendingApplicationsFailed] = + useState(false); + const [recentOrdersFailed, setRecentOrdersFailed] = useState(false); + const [recentDonationsFailed, setRecentDonationsFailed] = useState(false); + const [statsFetchFailed, setStatsFetchFailed] = useState(false); - useEffect(() => { - const fetchMe = async () => { - let user: User; - try { - user = await ApiClient.getMe(); - setCurrentUser(user); - } catch { - setAlertMessage('Error fetching user data', AlertStatus.ERROR); - return; - } - + const fetchStats = React.useCallback( + async (userId: number) => { + setStatsFetchFailed(false); try { - const userStats = await ApiClient.getUserStats(user.id); + const userStats = await ApiClient.getUserStats(userId); setStats(userStats); } catch { + setStatsFetchFailed(true); setAlertMessage( 'Error fetching dashboard statistics', AlertStatus.ERROR, ); } - }; + }, + [setAlertMessage], + ); - const fetchPendingApplications = async () => { - try { - const applications = await ApiClient.getRecentPendingApplications(); - setPendingApplications(applications); - } catch { - setAlertMessage( - 'Error fetching pending applications', - AlertStatus.ERROR, - ); - } - }; + const fetchPendingApplications = React.useCallback(async () => { + setPendingApplicationsFailed(false); + try { + const applications = await ApiClient.getRecentPendingApplications(); + setPendingApplications(applications); + } catch { + setPendingApplicationsFailed(true); + setAlertMessage('Error fetching pending applications', AlertStatus.ERROR); + } + }, [setAlertMessage]); - const fetchRecentOrders = async () => { - try { - const allOrders = await ApiClient.getAllOrders(); - const sortedOrders = allOrders.sort( - (a: OrderSummary, b: OrderSummary) => - new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime(), - ); - setRecentOrders(sortedOrders.slice(0, 2)); - } catch { - setAlertMessage('Error fetching recent orders', AlertStatus.ERROR); - } - }; + const fetchRecentOrders = React.useCallback(async () => { + setRecentOrdersFailed(false); + try { + const allOrders = await ApiClient.getAllOrders(); + const sortedOrders = allOrders.sort( + (a: OrderSummary, b: OrderSummary) => + new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime(), + ); + setRecentOrders(sortedOrders.slice(0, 2)); + } catch { + setRecentOrdersFailed(true); + setAlertMessage('Error fetching recent orders', AlertStatus.ERROR); + } + }, [setAlertMessage]); - const fetchRecentDonations = async () => { + const fetchRecentDonations = React.useCallback(async () => { + setRecentDonationsFailed(false); + try { + const allDonations = await ApiClient.getAllDonations(); + const sortedDonations = allDonations.sort( + (a: Donation, b: Donation) => + new Date(b.dateDonated).getTime() - new Date(a.dateDonated).getTime(), + ); + setRecentDonations(sortedDonations.slice(0, 2)); + } catch { + setRecentDonationsFailed(true); + setAlertMessage('Error fetching recent donations', AlertStatus.ERROR); + } + }, [setAlertMessage]); + + useEffect(() => { + const fetchMe = async () => { + let user: User; try { - const allDonations = await ApiClient.getAllDonations(); - const sortedDonations = allDonations.sort( - (a: Donation, b: Donation) => - new Date(b.dateDonated).getTime() - - new Date(a.dateDonated).getTime(), - ); - setRecentDonations(sortedDonations.slice(0, 2)); + user = await ApiClient.getMe(); + setCurrentUser(user); } catch { - setAlertMessage('Error fetching recent donations', AlertStatus.ERROR); + setAlertMessage('Error fetching user data', AlertStatus.ERROR); + return; } + + await fetchStats(user.id); }; const load = async () => { @@ -109,14 +125,23 @@ const AdminDashboard: React.FC = () => { }; load(); - }, [setAlertMessage]); + }, [ + setAlertMessage, + fetchStats, + fetchPendingApplications, + fetchRecentOrders, + fetchRecentDonations, + ]); if (loading) return null; const isPageEmpty = pendingApplications.length === 0 && + !pendingApplicationsFailed && recentOrders.length === 0 && - recentDonations.length === 0; + !recentOrdersFailed && + recentDonations.length === 0 && + !recentDonationsFailed; return ( @@ -132,7 +157,24 @@ const AdminDashboard: React.FC = () => { Welcome, {currentUser?.firstName} {currentUser?.lastName} - {stats && } + {statsFetchFailed ? ( + + + + + + + ) : ( + stats && + )} {isPageEmpty ? ( { Pending Actions - {pendingApplications.length === 0 ? ( + {pendingApplicationsFailed ? ( + + + + + + + ) : pendingApplications.length === 0 ? ( @@ -194,7 +248,19 @@ const AdminDashboard: React.FC = () => { Recent Orders - {recentOrders.length === 0 ? ( + {recentOrdersFailed ? ( + + + + + + + ) : recentOrders.length === 0 ? ( @@ -235,7 +301,19 @@ const AdminDashboard: React.FC = () => { Recent Donations - {recentDonations.length === 0 ? ( + {recentDonationsFailed ? ( + + + + + + + ) : recentDonations.length === 0 ? ( diff --git a/apps/frontend/src/containers/adminDonation.tsx b/apps/frontend/src/containers/adminDonation.tsx index eb2eae308..adf54c415 100644 --- a/apps/frontend/src/containers/adminDonation.tsx +++ b/apps/frontend/src/containers/adminDonation.tsx @@ -1,4 +1,4 @@ -import React, { useState, useEffect } from 'react'; +import React, { useState, useEffect, useCallback } from 'react'; import { ArrowDownUp, Funnel, Search } from 'lucide-react'; import { Box, @@ -20,6 +20,7 @@ import { useSearchParams, useNavigate } from 'react-router-dom'; import { ROUTES } from '../routes'; import FMDeleteDonationActionModal from '@components/forms/fmDeleteDonationModal'; import PageEmptyState from '@components/pageEmptyState'; +import SectionEmptyState from '@components/sectionEmptyState'; import { PaginationControl } from '@components/pagination'; const AdminDonation: React.FC = () => { @@ -40,19 +41,22 @@ const AdminDonation: React.FC = () => { const [deleteDonation, setDeleteDonation] = useState(null); const [alertState, setAlertMessage] = useAlert(); + const [fetchFailed, setFetchFailed] = useState(false); - const fetchDonations = async () => { + const fetchDonations = useCallback(async () => { + setFetchFailed(false); try { const data = await ApiClient.getAllDonations(); setDonations(data); } catch { + setFetchFailed(true); setAlertMessage('Error fetching donations', AlertStatus.ERROR); } - }; + }, [setAlertMessage]); useEffect(() => { fetchDonations(); - }, []); + }, [fetchDonations]); useEffect(() => { setCurrentPage(1); @@ -187,7 +191,16 @@ const AdminDonation: React.FC = () => { timeout={6000} /> )} - {donations.length === 0 ? ( + {fetchFailed ? ( + <> + + + + + + ) : donations.length === 0 ? ( ) : ( <> diff --git a/apps/frontend/src/containers/adminDonationStats.tsx b/apps/frontend/src/containers/adminDonationStats.tsx index 1902cde5c..3359706d9 100644 --- a/apps/frontend/src/containers/adminDonationStats.tsx +++ b/apps/frontend/src/containers/adminDonationStats.tsx @@ -1,4 +1,4 @@ -import React, { useState, useEffect, useRef } from 'react'; +import React, { useState, useEffect, useRef, useCallback } from 'react'; import { ChevronDown, Funnel, Search } from 'lucide-react'; import { Box, @@ -14,6 +14,7 @@ import ApiClient from '@api/apiClient'; import { FloatingAlert } from '@components/floatingAlert'; import { useAlert } from '../hooks/alert'; import { PaginationControl } from '@components/pagination'; +import SectionEmptyState from '@components/sectionEmptyState'; const AdminDonationStats: React.FC = () => { // Individual and combined pantry stats to be displayed @@ -35,65 +36,88 @@ const AdminDonationStats: React.FC = () => { const totalStatsRequestIdRef = useRef(0); const pantryStatsRequestIdRef = useRef(0); - useEffect(() => { - const fetchInitialData = async () => { - try { - const names = await ApiClient.getApprovedPantryNames(); - setPantryNameOptions(names); - } catch { - setAlertMessage('Error fetching pantry names', AlertStatus.ERROR); - } + const [initialDataFailed, setInitialDataFailed] = useState(false); + const [totalStatsFailed, setTotalStatsFailed] = useState(false); + const [pantryStatsFailed, setPantryStatsFailed] = useState(false); - try { - const years = await ApiClient.getPantryOrderYears(); - setAvailableYears(years); - } catch { - setAlertMessage('Error fetching available years', AlertStatus.ERROR); - } - }; - fetchInitialData(); + const fetchInitialData = useCallback(async () => { + setInitialDataFailed(false); + try { + const names = await ApiClient.getApprovedPantryNames(); + setPantryNameOptions(names); + } catch { + setInitialDataFailed(true); + setAlertMessage('Error fetching pantry names', AlertStatus.ERROR); + } + + try { + const years = await ApiClient.getPantryOrderYears(); + setAvailableYears(years); + } catch { + setInitialDataFailed(true); + setAlertMessage('Error fetching available years', AlertStatus.ERROR); + } }, [setAlertMessage]); useEffect(() => { + fetchInitialData(); + }, [fetchInitialData]); + + const fetchTotalStats = useCallback(async () => { const requestId = ++totalStatsRequestIdRef.current; - const fetchTotalStats = async () => { - try { - const stats = await ApiClient.getTotalStats( - selectedYears.length ? selectedYears : undefined, - ); - if (requestId === totalStatsRequestIdRef.current) { - setTotalStats(stats); - } - } catch { - if (requestId === totalStatsRequestIdRef.current) { - setAlertMessage('Error fetching total stats', AlertStatus.ERROR); - } + setTotalStatsFailed(false); + try { + const stats = await ApiClient.getTotalStats( + selectedYears.length ? selectedYears : undefined, + ); + if (requestId === totalStatsRequestIdRef.current) { + setTotalStats(stats); } - }; - fetchTotalStats(); + } catch { + if (requestId === totalStatsRequestIdRef.current) { + setTotalStatsFailed(true); + setAlertMessage('Error fetching total stats', AlertStatus.ERROR); + } + } }, [setAlertMessage, selectedYears]); useEffect(() => { + fetchTotalStats(); + }, [fetchTotalStats]); + + const fetchPantryStats = useCallback(async () => { const requestId = ++pantryStatsRequestIdRef.current; - const fetchStats = async () => { - try { - const stats = await ApiClient.getPantryStats({ - pantryNames: selectedPantries.length ? selectedPantries : undefined, - years: selectedYears.length ? selectedYears : undefined, - page: currentPage, - }); - if (requestId === pantryStatsRequestIdRef.current) { - setPantryStats(stats); - } - } catch { - if (requestId === pantryStatsRequestIdRef.current) { - setAlertMessage('Error fetching pantry stats', AlertStatus.ERROR); - } + setPantryStatsFailed(false); + try { + const stats = await ApiClient.getPantryStats({ + pantryNames: selectedPantries.length ? selectedPantries : undefined, + years: selectedYears.length ? selectedYears : undefined, + page: currentPage, + }); + if (requestId === pantryStatsRequestIdRef.current) { + setPantryStats(stats); } - }; - fetchStats(); + } catch { + if (requestId === pantryStatsRequestIdRef.current) { + setPantryStatsFailed(true); + setAlertMessage('Error fetching pantry stats', AlertStatus.ERROR); + } + } }, [setAlertMessage, selectedPantries, selectedYears, currentPage]); + useEffect(() => { + fetchPantryStats(); + }, [fetchPantryStats]); + + const dataFetchFailed = + initialDataFailed || totalStatsFailed || pantryStatsFailed; + + const handleRetry = () => { + if (initialDataFailed) fetchInitialData(); + if (totalStatsFailed) fetchTotalStats(); + if (pantryStatsFailed) fetchPantryStats(); + }; + const handlePantryNameFilterChange = (name: string, checked: boolean) => { // For simplicity, reset the page setCurrentPage(1); @@ -146,429 +170,444 @@ const AdminDonationStats: React.FC = () => { timeout={6000} /> )} - - - - - {isFilterOpen && ( - <> - setIsFilterOpen(false)} - zIndex={10} - /> - + + + + + + ) : ( + <> + + + + + {isFilterOpen && ( + <> + setIsFilterOpen(false)} + zIndex={10} /> - - - {pantryNameOptions - .filter((name) => - name.toLowerCase().includes(searchPantry.toLowerCase()), - ) - .map((name) => ( - - handlePantryNameFilterChange(name, !!e.checked) - } - size="md" - > - - - {name} - - ))} - - - - )} - - - - - {isYearFilterOpen && ( - <> - setIsYearFilterOpen(false)} - zIndex={10} - /> - + + + {isYearFilterOpen && ( + <> + setIsYearFilterOpen(false)} + zIndex={10} + /> + + - - - {year} - - ))} - - - - )} - - - - - - - Pantry - - - Total Items - - - Total Weight (oz) - - - Total Weight (lbs) - - - Fair Market Value of Food Donation - - - Shipping/ -
- Delivery Expenses -
- - Shipping Paid by SSF - - - Total Value - - - % Food Rescue - - - Lbs Food Rescue - -
-
- - - - All Pantries - - - {totalStats?.totalItems ?? 0} - - - {(totalStats?.totalOz ?? 0).toFixed(2)} - - - {(totalStats?.totalLbs ?? 0).toFixed(2)} - - - ${(totalStats?.totalDonatedFoodValue ?? 0).toFixed(2)} - - - ${(totalStats?.totalShippingCost ?? 0).toFixed(2)} - - - ${(totalStats?.totalShippingCostPaidBySsf ?? 0).toFixed(2)} - - - ${(totalStats?.totalValue ?? 0).toFixed(2)} - - - {(totalStats?.percentageFoodRescueItems ?? 0).toFixed(2)}% - - - {(totalStats?.foodRescueLbs ?? 0).toFixed(2)} - - - {pantryStats.map((stat) => ( - - - {stat.pantryName} - - - {stat.totalItems} - - - {stat.totalOz.toFixed(2)} - - - {stat.totalLbs.toFixed(2)} - - - ${stat.totalDonatedFoodValue.toFixed(2)} - - - ${stat.totalShippingCost.toFixed(2)} - - - ${stat.totalShippingCostPaidBySsf.toFixed(2)} - - - ${stat.totalValue.toFixed(2)} - - - {stat.percentageFoodRescueItems.toFixed(2)}% - - - {stat.foodRescueLbs.toFixed(2)} - - - ))} - -
+ {[...availableYears].map((year) => ( + + handleYearFilterChange(year, !!e.checked) + } + size="md" + > + + + {year} + + ))} + +
+ + )} +
+
+ + + + + Pantry + + + Total Items + + + Total Weight (oz) + + + Total Weight (lbs) + + + Fair Market Value of Food Donation + + + Shipping/ +
+ Delivery Expenses +
+ + Shipping Paid by SSF + + + Total Value + + + % Food Rescue + + + Lbs Food Rescue + +
+
+ + + + All Pantries + + + {totalStats?.totalItems ?? 0} + + + {(totalStats?.totalOz ?? 0).toFixed(2)} + + + {(totalStats?.totalLbs ?? 0).toFixed(2)} + + + ${(totalStats?.totalDonatedFoodValue ?? 0).toFixed(2)} + + + ${(totalStats?.totalShippingCost ?? 0).toFixed(2)} + + + ${(totalStats?.totalShippingCostPaidBySsf ?? 0).toFixed(2)} + + + ${(totalStats?.totalValue ?? 0).toFixed(2)} + + + {(totalStats?.percentageFoodRescueItems ?? 0).toFixed(2)}% + + + {(totalStats?.foodRescueLbs ?? 0).toFixed(2)} + + + {pantryStats.map((stat) => ( + + + {stat.pantryName} + + + {stat.totalItems} + + + {stat.totalOz.toFixed(2)} + + + {stat.totalLbs.toFixed(2)} + + + ${stat.totalDonatedFoodValue.toFixed(2)} + + + ${stat.totalShippingCost.toFixed(2)} + + + ${stat.totalShippingCostPaidBySsf.toFixed(2)} + + + ${stat.totalValue.toFixed(2)} + + + {stat.percentageFoodRescueItems.toFixed(2)}% + + + {stat.foodRescueLbs.toFixed(2)} + + + ))} + +
- - - + + + + + )}
); }; diff --git a/apps/frontend/src/containers/adminFoodManufacturerManagement.tsx b/apps/frontend/src/containers/adminFoodManufacturerManagement.tsx index 7484dffea..1ddba8a1b 100644 --- a/apps/frontend/src/containers/adminFoodManufacturerManagement.tsx +++ b/apps/frontend/src/containers/adminFoodManufacturerManagement.tsx @@ -1,4 +1,4 @@ -import { useEffect, useState } from 'react'; +import { useCallback, useEffect, useState } from 'react'; import { Table, Text, @@ -20,6 +20,7 @@ import { useAlert } from '../hooks/alert'; import { useNavigate } from 'react-router-dom'; import { ROUTES } from '../routes'; import { PaginationControl } from '@components/pagination'; +import SectionEmptyState from '@components/sectionEmptyState'; const AdminFoodManufacturerManagement: React.FC = () => { const navigate = useNavigate(); @@ -37,18 +38,22 @@ const AdminFoodManufacturerManagement: React.FC = () => { const pageSize = 10; - const fetchFoodManufacturers = async () => { + const [fetchFailed, setFetchFailed] = useState(false); + + const fetchFoodManufacturers = useCallback(async () => { + setFetchFailed(false); try { const approved = await ApiClient.getApprovedFoodManufacturers(); setFoodManufacturers(approved); } catch { + setFetchFailed(true); setAlertMessage('Error fetching food manufacturers', AlertStatus.ERROR); } - }; + }, [setAlertMessage]); useEffect(() => { fetchFoodManufacturers(); - }, [setAlertMessage]); + }, [fetchFoodManufacturers]); useEffect(() => { setCurrentPage(1); @@ -207,96 +212,109 @@ const AdminFoodManufacturerManagement: React.FC = () => { )} - - - - - Food Manufacturer - - - Food Rescue - - - Action - - - - - {paginatedFMs?.map((fm) => ( - - - - navigate( - ROUTES.FOOD_MANUFACTURER_MANAGEMENT_DETAILS.replace( - ':foodManufacturerId', - fm.foodManufacturerId.toString(), - ), - ) - } - > - {fm.foodManufacturerName} - - - - - {fm.donateWastedFood === DonateWastedFood.ALWAYS - ? 'Yes' - : fm.donateWastedFood === DonateWastedFood.SOMETIMES - ? 'Sometimes' - : 'No'} - - - - - navigate( - `${ROUTES.ADMIN_DONATION}?foodManufacturerId=${fm.foodManufacturerId}`, - ) - } - > - View Donations - - - - ))} - - - - - + {fetchFailed ? ( + <> + + + + + + ) : ( + <> + + + + + Food Manufacturer + + + Food Rescue + + + Action + + + + + {paginatedFMs?.map((fm) => ( + + + + navigate( + ROUTES.FOOD_MANUFACTURER_MANAGEMENT_DETAILS.replace( + ':foodManufacturerId', + fm.foodManufacturerId.toString(), + ), + ) + } + > + {fm.foodManufacturerName} + + + + + {fm.donateWastedFood === DonateWastedFood.ALWAYS + ? 'Yes' + : fm.donateWastedFood === DonateWastedFood.SOMETIMES + ? 'Sometimes' + : 'No'} + + + + + navigate( + `${ROUTES.ADMIN_DONATION}?foodManufacturerId=${fm.foodManufacturerId}`, + ) + } + > + View Donations + + + + ))} + + + + + + + )}
); diff --git a/apps/frontend/src/containers/adminOrderManagement.tsx b/apps/frontend/src/containers/adminOrderManagement.tsx index 89b26df76..a6fa45ddc 100644 --- a/apps/frontend/src/containers/adminOrderManagement.tsx +++ b/apps/frontend/src/containers/adminOrderManagement.tsx @@ -25,6 +25,7 @@ import { useAlert } from '../hooks/alert'; import { useSearchParams, useNavigate } from 'react-router-dom'; import { ROUTES } from '../routes'; import { PaginationControl } from '@components/pagination'; +import SectionEmptyState from '@components/sectionEmptyState'; // Extending the OrderSummary type to include assignee color for display type OrderWithColor = OrderSummary & { assigneeColor?: string }; @@ -96,8 +97,11 @@ const AdminOrderManagement: React.FC = () => { const MAX_PER_STATUS = 5; + const [fetchFailed, setFetchFailed] = useState(false); + // Fetches all orders and sorts them into their appropriate status lists const fetchOrders = useCallback(async () => { + setFetchFailed(false); try { const data = await ApiClient.getAllOrders(); @@ -136,6 +140,7 @@ const AdminOrderManagement: React.FC = () => { return next; }); } catch { + setFetchFailed(true); setAlertMessage('Error fetching orders', AlertStatus.ERROR); } }, [setAlertMessage]); @@ -240,68 +245,79 @@ const AdminOrderManagement: React.FC = () => { /> )} - {Object.values(OrderStatus).map((status) => { - const allOrders = statusOrders[status] || []; - const filterState = filterStates[status]; - - // Get pantry options through all orders in the status - const pantryOptions = [ - ...new Set(allOrders.map((o) => o.request.pantry.pantryName)), - ].sort((a, b) => a.localeCompare(b)); - - // Apply filters and sorting to all orders - const filteredOrders = allOrders - .filter( - (o) => - filterState.selectedPantries.length === 0 || - filterState.selectedPantries.includes( - o.request.pantry.pantryName, - ), - ) - .sort((a, b) => - filterState.sortAsc - ? a.createdAt.localeCompare(b.createdAt) - : b.createdAt.localeCompare(a.createdAt), + {fetchFailed ? ( + <> + + + + + + ) : ( + Object.values(OrderStatus).map((status) => { + const allOrders = statusOrders[status] || []; + const filterState = filterStates[status]; + + // Get pantry options through all orders in the status + const pantryOptions = [ + ...new Set(allOrders.map((o) => o.request.pantry.pantryName)), + ].sort((a, b) => a.localeCompare(b)); + + // Apply filters and sorting to all orders + const filteredOrders = allOrders + .filter( + (o) => + filterState.selectedPantries.length === 0 || + filterState.selectedPantries.includes( + o.request.pantry.pantryName, + ), + ) + .sort((a, b) => + filterState.sortAsc + ? a.createdAt.localeCompare(b.createdAt) + : b.createdAt.localeCompare(a.createdAt), + ); + + const totalFiltered = filteredOrders.length; + const currentPage = currentPages[status] || 1; + const displayedOrders = filteredOrders.slice( + (currentPage - 1) * MAX_PER_STATUS, + currentPage * MAX_PER_STATUS, ); - const totalFiltered = filteredOrders.length; - const currentPage = currentPages[status] || 1; - const displayedOrders = filteredOrders.slice( - (currentPage - 1) * MAX_PER_STATUS, - currentPage * MAX_PER_STATUS, - ); - - return ( - - handlePageChange(status, page)} - pantryOptions={pantryOptions} - filterState={filterState} - onFilterChange={(newState: FilterState) => - // Update filter state for the specific status - setFilterStates((prev) => { - const prevSelected = prev[status]?.selectedPantries || []; - const prevKey = [...prevSelected].sort().join(','); - const newKey = [...newState.selectedPantries] - .sort() - .join(','); - // Reset page if selected pantries changed - if (prevKey !== newKey) { - resetPageForStatus(status); - } - return { ...prev, [status]: newState }; - }) - } - /> - - ); - })} + return ( + + handlePageChange(status, page)} + pantryOptions={pantryOptions} + filterState={filterState} + onFilterChange={(newState: FilterState) => + // Update filter state for the specific status + setFilterStates((prev) => { + const prevSelected = prev[status]?.selectedPantries || []; + const prevKey = [...prevSelected].sort().join(','); + const newKey = [...newState.selectedPantries] + .sort() + .join(','); + // Reset page if selected pantries changed + if (prevKey !== newKey) { + resetPageForStatus(status); + } + return { ...prev, [status]: newState }; + }) + } + /> + + ); + }) + )} { const navigate = useNavigate(); @@ -45,18 +46,22 @@ const AdminPantryManagement: React.FC = () => { const pageSize = 10; - const fetchPantries = async () => { + const [fetchFailed, setFetchFailed] = useState(false); + + const fetchPantries = useCallback(async () => { + setFetchFailed(false); try { const allApprovedPantries = await ApiClient.getApprovedPantries(); setPantries(allApprovedPantries); } catch { + setFetchFailed(true); setAlertMessage('Error fetching pantries', AlertStatus.ERROR); } - }; + }, [setAlertMessage]); useEffect(() => { fetchPantries(); - }, [setAlertMessage]); + }, [fetchPantries]); // Pre-fill pantry filter from the volunteerId url param. The param is kept on // success so the filter is reapplied on reload/back/refresh, and only cleared @@ -257,7 +262,16 @@ const AdminPantryManagement: React.FC = () => { )} - {filteredPantries.length === 0 ? ( + {fetchFailed ? ( + <> + + + + + + ) : filteredPantries.length === 0 ? ( { const [searchParams, setSearchParams] = useSearchParams(); const [alertState, setAlertMessage] = useAlert(); - useEffect(() => { - const fetchFoodManufacturers = async () => { - try { - const data = await ApiClient.getAllPendingFoodManufacturers(); - setFoodManufacturers(data); - setHasError(false); - } catch { - setHasError(true); - setAlertMessage('Error fetching food manufacturers', AlertStatus.ERROR); - } - }; + const fetchFoodManufacturers = useCallback(async () => { + try { + const data = await ApiClient.getAllPendingFoodManufacturers(); + setFoodManufacturers(data); + setHasError(false); + } catch { + setHasError(true); + setAlertMessage('Error fetching food manufacturers', AlertStatus.ERROR); + } + }, [setAlertMessage]); + useEffect(() => { fetchFoodManufacturers(); - }, [setAlertMessage]); + }, [fetchFoodManufacturers]); useEffect(() => { setCurrentPage(1); @@ -184,6 +184,9 @@ const ApproveFoodManufacturers: React.FC = () => { Something went wrong while loading applications. Please try again later. +
) : ( diff --git a/apps/frontend/src/containers/approvePantries.tsx b/apps/frontend/src/containers/approvePantries.tsx index 8d21a5971..5d7292454 100644 --- a/apps/frontend/src/containers/approvePantries.tsx +++ b/apps/frontend/src/containers/approvePantries.tsx @@ -1,4 +1,4 @@ -import React, { useEffect, useState } from 'react'; +import React, { useCallback, useEffect, useState } from 'react'; import { useNavigate, useSearchParams } from 'react-router-dom'; import { Table, @@ -36,20 +36,20 @@ const ApprovePantries: React.FC = () => { const [searchParams, setSearchParams] = useSearchParams(); const [alertState, setAlertMessage] = useAlert(); - useEffect(() => { - const fetchPantries = async () => { - try { - const data = await ApiClient.getAllPendingPantries(); - setPantries(data); - setHasError(false); - } catch { - setHasError(true); - setAlertMessage('Error fetching pantries', AlertStatus.ERROR); - } - }; + const fetchPantries = useCallback(async () => { + try { + const data = await ApiClient.getAllPendingPantries(); + setPantries(data); + setHasError(false); + } catch { + setHasError(true); + setAlertMessage('Error fetching pantries', AlertStatus.ERROR); + } + }, [setAlertMessage]); + useEffect(() => { fetchPantries(); - }, [setAlertMessage]); + }, [fetchPantries]); useEffect(() => { setCurrentPage(1); @@ -175,6 +175,9 @@ const ApprovePantries: React.FC = () => { Something went wrong while loading applications. Please try again later. + ) : ( diff --git a/apps/frontend/src/containers/foodManufacturerApplicationDetails.tsx b/apps/frontend/src/containers/foodManufacturerApplicationDetails.tsx index af9676ed5..3d86f74cf 100644 --- a/apps/frontend/src/containers/foodManufacturerApplicationDetails.tsx +++ b/apps/frontend/src/containers/foodManufacturerApplicationDetails.tsx @@ -32,6 +32,7 @@ interface EmptyStateProps { title: string; subtitle?: string; isLoading?: boolean; + onRetry?: () => void; } const EmptyState: React.FC = ({ @@ -39,6 +40,7 @@ const EmptyState: React.FC = ({ title, subtitle, isLoading = false, + onRetry, }) => { return ( @@ -71,18 +73,33 @@ const EmptyState: React.FC = ({ )} {!isLoading && ( - + + {onRetry && ( + + )} + + )} @@ -106,6 +123,7 @@ const FoodManufacturerApplicationDetails: React.FC = () => { const [showApproveModal, setShowApproveModal] = useState(false); const [showDenyModal, setShowDenyModal] = useState(false); const [isEditing, setIsEditing] = useState(false); + const [fetchFailed, setFetchFailed] = useState(false); const fieldContentStyles = { textStyle: 'p2', @@ -132,6 +150,7 @@ const FoodManufacturerApplicationDetails: React.FC = () => { const fetchApplicationDetails = useCallback(async () => { try { setLoading(true); + setFetchFailed(false); if (!id) { setAlertMessage('Application ID not provided.', AlertStatus.ERROR); return; @@ -146,6 +165,7 @@ const FoodManufacturerApplicationDetails: React.FC = () => { } catch (err: unknown) { if (err instanceof AxiosError) { if (err.response?.status !== 404 && err.response?.status !== 400) { + setFetchFailed(true); setAlertMessage( 'Could not load application details.', AlertStatus.ERROR, @@ -155,7 +175,7 @@ const FoodManufacturerApplicationDetails: React.FC = () => { } finally { setLoading(false); } - }, [id]); + }, [id, setAlertMessage]); useEffect(() => { fetchApplicationDetails(); @@ -216,6 +236,7 @@ const FoodManufacturerApplicationDetails: React.FC = () => { } title={alertState?.message ?? 'Application not found.'} + onRetry={fetchFailed ? fetchApplicationDetails : undefined} /> ); } diff --git a/apps/frontend/src/containers/foodManufacturerDashboard.tsx b/apps/frontend/src/containers/foodManufacturerDashboard.tsx index c18b0905f..066d2e44f 100644 --- a/apps/frontend/src/containers/foodManufacturerDashboard.tsx +++ b/apps/frontend/src/containers/foodManufacturerDashboard.tsx @@ -1,5 +1,5 @@ import ApiClient from '@api/apiClient'; -import { Box, Heading, Text } from '@chakra-ui/react'; +import { Box, Button, Heading, Text } from '@chakra-ui/react'; import DashboardCard, { DashboardCardType } from '@components/dashboardCard'; import { FloatingAlert } from '@components/floatingAlert'; import PageEmptyState from '@components/pageEmptyState'; @@ -14,83 +14,121 @@ import { Donation, DonationDetails, DonationReminderDto, - FoodManufacturer, + ManufacturerSummary, User, } from '../types/types'; +const formatManufacturerNames = (names: string[]): string => { + if (names.length <= 1) return names[0] ?? ''; + if (names.length === 2) return `${names[0]} and ${names[1]}`; + return `${names.slice(0, -1).join(', ')}, and ${names[names.length - 1]}`; +}; + const FoodManufacturerDashboard: React.FC = () => { const navigate = useNavigate(); const [errorAlertState, setErrorMessage] = useAlert(); const [loading, setLoading] = useState(true); - const [foodManufacturer, setFoodManufacturer] = - useState(null); + const [foodManufacturers, setFoodManufacturers] = useState< + ManufacturerSummary[] + >([]); const [upcomingReminders, setUpcomingReminders] = useState< DonationReminderDto[] >([]); const [recentDonations, setRecentDonations] = useState([]); + const [donationsFetchFailed, setDonationsFetchFailed] = useState(false); + const [remindersFetchFailed, setRemindersFetchFailed] = useState(false); const [stats, setStats] = useState | null>(null); + const [statsFetchFailed, setStatsFetchFailed] = useState(false); + const [currentUser, setCurrentUser] = useState(null); - useEffect(() => { - const fetchFmData = async () => { - let currentUser: User; + const fetchUserAndManufacturers = + React.useCallback(async (): Promise => { try { - currentUser = await ApiClient.getMe(); - const fmId = await ApiClient.getCurrentUserFoodManufacturerId(); - const fm = await ApiClient.getFoodManufacturer(fmId); - setFoodManufacturer(fm); + const user = await ApiClient.getMe(); + setCurrentUser(user); + const fms = await ApiClient.getMyFoodManufacturers(); + setFoodManufacturers(fms); + return user; } catch { setErrorMessage('Error fetching dashboard data', AlertStatus.ERROR); - return; - } finally { - setLoading(false); + return null; } + }, [setErrorMessage]); + const fetchStats = React.useCallback( + async (userId: number) => { + setStatsFetchFailed(false); try { - const userStats = await ApiClient.getUserStats(currentUser.id); + const userStats = await ApiClient.getUserStats(userId); setStats(userStats); } catch { + setStatsFetchFailed(true); setErrorMessage( 'Error fetching dashboard statistics', AlertStatus.ERROR, ); } + }, + [setErrorMessage], + ); - const [reminders, donations] = await Promise.allSettled([ - ApiClient.getNextTwoDonationReminders(), - ApiClient.getAllDonationsByFoodManufacturer(), - ]); + const fetchReminders = React.useCallback(async () => { + setRemindersFetchFailed(false); + try { + const reminders = await ApiClient.getNextTwoDonationReminders(); + setUpcomingReminders(reminders); + } catch { + setRemindersFetchFailed(true); + setErrorMessage('Error fetching upcoming donations.', AlertStatus.ERROR); + } + }, [setErrorMessage]); - if (reminders.status === 'fulfilled') { - setUpcomingReminders(reminders.value); - } else { - setErrorMessage( - 'Error fetching upcoming donations.', - AlertStatus.ERROR, - ); - } + const fetchRecentDonations = React.useCallback(async () => { + setDonationsFetchFailed(false); + try { + const data = await ApiClient.getAllDonationsByFoodManufacturer(); + const sorted = data + .map((d: DonationDetails) => d.donation) + .sort( + (a: Donation, b: Donation) => + new Date(b.dateDonated).getTime() - + new Date(a.dateDonated).getTime(), + ) + .slice(0, 2); + setRecentDonations(sorted); + } catch { + setDonationsFetchFailed(true); + setErrorMessage('Error fetching recent donations.', AlertStatus.ERROR); + } + }, [setErrorMessage]); - if (donations.status === 'fulfilled') { - const sorted = donations.value - .map((d: DonationDetails) => d.donation) - .sort( - (a: Donation, b: Donation) => - new Date(b.dateDonated).getTime() - - new Date(a.dateDonated).getTime(), - ) - .slice(0, 2); - setRecentDonations(sorted); - } else { - setErrorMessage('Error fetching recent donations.', AlertStatus.ERROR); - } + useEffect(() => { + const load = async () => { + const user = await fetchUserAndManufacturers(); + setLoading(false); + if (!user) return; + await Promise.allSettled([ + fetchStats(user.id), + fetchReminders(), + fetchRecentDonations(), + ]); }; - fetchFmData(); - }, [setErrorMessage]); + load(); + }, [ + fetchUserAndManufacturers, + fetchStats, + fetchReminders, + fetchRecentDonations, + ]); if (loading) return null; const isPageEmpty = - upcomingReminders.length === 0 && recentDonations.length === 0; + upcomingReminders.length === 0 && + !remindersFetchFailed && + recentDonations.length === 0 && + !donationsFetchFailed; return ( @@ -103,10 +141,30 @@ const FoodManufacturerDashboard: React.FC = () => { /> )} - Welcome, {foodManufacturer?.foodManufacturerName} + Welcome,{' '} + {formatManufacturerNames( + foodManufacturers.map((fm) => fm.foodManufacturerName), + )} - {stats && } + {statsFetchFailed ? ( + + + + + + + ) : ( + stats && + )} {isPageEmpty ? ( { ) : ( <> - Upcoming Donations + Upcoming Email Reminders for Donations - {upcomingReminders.length === 0 ? ( + {remindersFetchFailed ? ( + + + + + + + ) : upcomingReminders.length === 0 ? ( @@ -141,10 +211,10 @@ const FoodManufacturerDashboard: React.FC = () => { subtitle={ reminder.donation.foodManufacturer?.foodManufacturerName } - linkText="View Donation Requirements" + linkText="Submit Donation" onLinkClick={() => navigate( - `${ROUTES.FM_DONATION_MANAGEMENT}?donationId=${reminder.donation.donationId}`, + `${ROUTES.FM_DONATION_MANAGEMENT}?resubmitDonationId=${reminder.donation.donationId}`, ) } /> @@ -155,7 +225,19 @@ const FoodManufacturerDashboard: React.FC = () => { Recent Donations - {recentDonations.length === 0 ? ( + {donationsFetchFailed ? ( + + + + + + + ) : recentDonations.length === 0 ? ( diff --git a/apps/frontend/src/containers/foodManufacturerDonationManagement.tsx b/apps/frontend/src/containers/foodManufacturerDonationManagement.tsx index 0af1a6626..8d0223dc0 100644 --- a/apps/frontend/src/containers/foodManufacturerDonationManagement.tsx +++ b/apps/frontend/src/containers/foodManufacturerDonationManagement.tsx @@ -14,7 +14,7 @@ import FmCompleteRequiredActionsModal from '@components/forms/fmCompleteRequired import NewDonationFormModal from '@components/forms/newDonationFormModal'; import ResubmitDonationModal from '@components/forms/resubmitDonationModal'; import SectionEmptyState from '@components/sectionEmptyState'; -import React, { useEffect, useState } from 'react'; +import React, { useCallback, useEffect, useState } from 'react'; import { useNavigate, useSearchParams } from 'react-router-dom'; import { useAlert } from '../hooks/alert'; import FMDeleteDonationActionModal from '@components/forms/fmDeleteDonationModal'; @@ -57,99 +57,125 @@ const FoodManufacturerDonationManagement: React.FC = () => { const [selectedViewDetailsDonation, setSelectedViewDetailsDonation] = useState(null); const [deleteDonation, setDeleteDonation] = useState(null); + const [initFailed, setInitFailed] = useState(false); + const [donationsFetchFailed, setDonationsFetchFailed] = useState(false); // Fetch all donations on component mount and sorts them into their appropriate status lists - const fetchDonations = async () => { - try { - const data = await ApiClient.getAllDonationsByFoodManufacturer(); - - const grouped: Record = { - [DonationStatus.AVAILABLE]: [], - [DonationStatus.FULFILLED]: [], - [DonationStatus.MATCHED]: [], - }; - - data.forEach((donationDetail: DonationDetails) => { - grouped[donationDetail.donation.status].push(donationDetail); - }); - - (Object.keys(grouped) as DonationStatus[]).forEach((status) => { - grouped[status].sort( - (a, b) => - new Date(a.donation.dateDonated).getTime() - - new Date(b.donation.dateDonated).getTime(), - ); - }); - - setStatusDonations(grouped); - - const initialPages: Record = { - [DonationStatus.AVAILABLE]: 1, - [DonationStatus.FULFILLED]: 1, - [DonationStatus.MATCHED]: 1, - }; - - // Paginate the containing status to the page that holds this donation. - const donationIdParam = searchParams.get('donationId'); - if (donationIdParam) { - const id = Number(donationIdParam); - for (const status of Object.values(DonationStatus)) { - const idx = grouped[status].findIndex( - (d) => d.donation.donationId === id, + const fetchDonations = useCallback( + async (resetPages = false) => { + try { + const data = await ApiClient.getAllDonationsByFoodManufacturer(); + setDonationsFetchFailed(false); + + const grouped: Record = { + [DonationStatus.AVAILABLE]: [], + [DonationStatus.FULFILLED]: [], + [DonationStatus.MATCHED]: [], + }; + + data.forEach((donationDetail: DonationDetails) => { + grouped[donationDetail.donation.status].push(donationDetail); + }); + + (Object.keys(grouped) as DonationStatus[]).forEach((status) => { + grouped[status].sort( + (a, b) => + new Date(a.donation.dateDonated).getTime() - + new Date(b.donation.dateDonated).getTime(), ); - if (idx >= 0) { - initialPages[status] = Math.floor(idx / MAX_PER_STATUS) + 1; - break; + }); + + setStatusDonations(grouped); + + setCurrentPages((prev) => { + const clamped = { ...prev }; + (Object.keys(grouped) as DonationStatus[]).forEach((status) => { + const totalPages = Math.max( + 1, + Math.ceil(grouped[status].length / MAX_PER_STATUS), + ); + clamped[status] = Math.min(clamped[status], totalPages); + }); + return clamped; + }); + + if (resetPages) { + const initialPages: Record = { + [DonationStatus.AVAILABLE]: 1, + [DonationStatus.FULFILLED]: 1, + [DonationStatus.MATCHED]: 1, + }; + + // Paginate the containing status to the page that holds this donation. + const donationIdParam = searchParams.get('donationId'); + if (donationIdParam) { + const id = Number(donationIdParam); + for (const status of Object.values(DonationStatus)) { + const idx = grouped[status].findIndex( + (d) => d.donation.donationId === id, + ); + if (idx >= 0) { + initialPages[status] = Math.floor(idx / MAX_PER_STATUS) + 1; + break; + } + } } - } - } - setCurrentPages(initialPages); + setCurrentPages(initialPages); + } - return grouped; - } catch (error) { - setAlertMessage('Error fetching donations', AlertStatus.ERROR); - return; - } - }; + return grouped; + } catch { + setDonationsFetchFailed(true); + setAlertMessage('Error fetching donations', AlertStatus.ERROR); + return; + } + }, + [searchParams, setAlertMessage], + ); - const openResubmitFromQueryParam = ( - grouped: Record, - ) => { - if (!resubmitDonationId) return; - const id = parseInt(resubmitDonationId, 10); - const allDonations: DonationDetails[] = Object.values(grouped).flat(); - const exists = allDonations.some((d) => d.donation.donationId === id); - if (exists) { - setIsResubmitOpen(true); - } else { - navigate(ROUTES.FM_DONATION_MANAGEMENT); - } - }; + const openResubmitFromQueryParam = useCallback( + (grouped: Record) => { + if (!resubmitDonationId) return; + const id = parseInt(resubmitDonationId, 10); + const allDonations: DonationDetails[] = Object.values(grouped).flat(); + const exists = allDonations.some((d) => d.donation.donationId === id); + if (exists) { + setIsResubmitOpen(true); + } else { + navigate(ROUTES.FM_DONATION_MANAGEMENT); + } + }, + [resubmitDonationId, navigate], + ); // On page load, get the food manufacturer id, fetch its donations, // and open the resubmit modal if the URL specifies one. + const init = useCallback(async () => { + setLoading(true); + setInitFailed(false); + try { + const fmId = await ApiClient.getCurrentUserFoodManufacturerId(); + setManufacturerId(fmId); + const grouped = await fetchDonations(true); + if (grouped) openResubmitFromQueryParam(grouped); + } catch { + setInitFailed(true); + setAlertMessage( + 'Error initializing donation management', + AlertStatus.ERROR, + ); + } finally { + setLoading(false); + } + }, [fetchDonations, openResubmitFromQueryParam, setAlertMessage]); + useEffect(() => { - const init = async () => { - try { - const fmId = await ApiClient.getCurrentUserFoodManufacturerId(); - setManufacturerId(fmId); - const grouped = await fetchDonations(); - if (grouped) openResubmitFromQueryParam(grouped); - } catch { - setAlertMessage( - 'Error initializing donation management', - AlertStatus.ERROR, - ); - } finally { - setLoading(false); - } - }; init(); - }, []); + }, [init]); useEffect(() => { - if (loading) return; + if (loading || donationsFetchFailed) return; const donationIdParam = searchParams.get('donationId'); if (!donationIdParam) return; @@ -161,8 +187,18 @@ const FoodManufacturerDonationManagement: React.FC = () => { .find((d) => d.donation.donationId === id); if (match) { setSelectedViewDetailsDonation(match.donation); - } else navigate(ROUTES.FM_DONATION_MANAGEMENT); - }, [searchParams, statusDonations, loading]); + } else { + setAlertMessage('Donation not found.', AlertStatus.ERROR); + navigate(ROUTES.FM_DONATION_MANAGEMENT, { replace: true }); + } + }, [ + searchParams, + statusDonations, + loading, + donationsFetchFailed, + navigate, + setAlertMessage, + ]); const handleResubmitClose = () => { setIsResubmitOpen(false); @@ -180,6 +216,30 @@ const FoodManufacturerDonationManagement: React.FC = () => { if (loading) return null; + if (initFailed) { + return ( + + {alertState && ( + + )} + + Donation Management + + + + + + + ); + } + const allDonations = Object.values(statusDonations).flat(); // Only show the manufacturer column when the representative's donations span @@ -267,7 +327,13 @@ const FoodManufacturerDonationManagement: React.FC = () => { foodManufacturerId={manufacturerId} isOpen={isResubmitOpen} onClose={handleResubmitClose} - onSuccess={() => fetchDonations()} + onSuccess={() => { + fetchDonations(); + setAlertMessage( + 'Donation resubmitted successfully.', + AlertStatus.INFO, + ); + }} donations={Object.values(statusDonations).flat()} initialDonationId={ resubmitDonationId ? parseInt(resubmitDonationId, 10) : null @@ -283,11 +349,13 @@ const FoodManufacturerDonationManagement: React.FC = () => { donation={selectedActionDonation} isOpen={true} onClose={() => setSelectedActionDonation(null)} - onSuccess={() => { + onSuccess={(allOrdersComplete) => { setSelectedActionDonation(null); if (manufacturerId !== null) fetchDonations(); setAlertMessage( - 'Your details have been saved. Actions are complete once all shipment and item details are confirmed.', + allOrdersComplete + ? 'Your details have been saved and all required actions are complete.' + : 'Your details have been saved, but shipping cost and/or tracking link are still missing for one or more orders. Please complete them soon.', AlertStatus.INFO, ); }} diff --git a/apps/frontend/src/containers/pantryApplicationDetails.tsx b/apps/frontend/src/containers/pantryApplicationDetails.tsx index a053c78dc..2b6357353 100644 --- a/apps/frontend/src/containers/pantryApplicationDetails.tsx +++ b/apps/frontend/src/containers/pantryApplicationDetails.tsx @@ -33,6 +33,7 @@ interface EmptyStateProps { title: string; subtitle?: string; isLoading?: boolean; + onRetry?: () => void; } const EmptyState: React.FC = ({ @@ -40,6 +41,7 @@ const EmptyState: React.FC = ({ title, subtitle, isLoading = false, + onRetry, }) => { return ( @@ -72,16 +74,31 @@ const EmptyState: React.FC = ({ )} {!isLoading && ( - + + {onRetry && ( + + )} + + )} @@ -107,6 +124,7 @@ const PantryApplicationDetails: React.FC = () => { const [showDenyModal, setShowDenyModal] = useState(false); const [isEditing, setIsEditing] = useState(false); const [isAdmin, setIsAdmin] = useState(false); + const [fetchFailed, setFetchFailed] = useState(false); const fieldContentStyles = { textStyle: 'p2', @@ -133,6 +151,7 @@ const PantryApplicationDetails: React.FC = () => { const fetchApplicationDetails = useCallback(async () => { try { setLoading(true); + setFetchFailed(false); if (!id) { setAlertMessage('Application ID not provided.', AlertStatus.ERROR); return; @@ -147,6 +166,7 @@ const PantryApplicationDetails: React.FC = () => { } catch (err: unknown) { if (err instanceof AxiosError) { if (err.response?.status !== 404 && err.response?.status !== 400) { + setFetchFailed(true); setAlertMessage( 'Could not load application details.', AlertStatus.ERROR, @@ -156,7 +176,7 @@ const PantryApplicationDetails: React.FC = () => { } finally { setLoading(false); } - }, [id]); + }, [id, setAlertMessage]); useEffect(() => { fetchApplicationDetails(); @@ -223,6 +243,7 @@ const PantryApplicationDetails: React.FC = () => { } title={alertState?.message ?? 'Application not found.'} + onRetry={fetchFailed ? fetchApplicationDetails : undefined} /> ); } diff --git a/apps/frontend/src/containers/pantryDashboard.tsx b/apps/frontend/src/containers/pantryDashboard.tsx index 68aed6a2f..29c904e9b 100644 --- a/apps/frontend/src/containers/pantryDashboard.tsx +++ b/apps/frontend/src/containers/pantryDashboard.tsx @@ -1,5 +1,5 @@ import ApiClient from '@api/apiClient'; -import { Box, Heading, Text } from '@chakra-ui/react'; +import { Box, Button, Heading, Text } from '@chakra-ui/react'; import DashboardCard, { DashboardCardType, ORDER_STATUS_BADGE, @@ -18,6 +18,7 @@ import { FoodRequestSummaryDto, OrderSummary, PantryWithUser, + User, } from '../types/types'; const PantryDashboard: React.FC = () => { @@ -31,6 +32,63 @@ const PantryDashboard: React.FC = () => { >([]); const [recentOrders, setRecentOrders] = useState([]); const [stats, setStats] = useState | null>(null); + const [recentFoodRequestsFailed, setRecentFoodRequestsFailed] = + useState(false); + const [recentOrdersFailed, setRecentOrdersFailed] = useState(false); + const [statsFetchFailed, setStatsFetchFailed] = useState(false); + const [currentUser, setCurrentUser] = useState(null); + + const fetchStats = React.useCallback( + async (userId: number) => { + setStatsFetchFailed(false); + try { + const userStats = await ApiClient.getUserStats(userId); + setStats(userStats); + } catch { + setStatsFetchFailed(true); + setAlertMessage( + 'Error fetching dashboard statistics', + AlertStatus.ERROR, + ); + } + }, + [setAlertMessage], + ); + + const fetchFoodRequests = React.useCallback(async () => { + setRecentFoodRequestsFailed(false); + try { + const pantryFoodRequests = await ApiClient.getPantryRequests(); + const sortedFoodRequests = pantryFoodRequests + .filter( + (fr: FoodRequestSummaryDto) => fr.status === FoodRequestStatus.ACTIVE, + ) + .sort( + (a: FoodRequestSummaryDto, b: FoodRequestSummaryDto) => + new Date(b.requestedAt).getTime() - + new Date(a.requestedAt).getTime(), + ); + setRecentFoodRequests(sortedFoodRequests.slice(0, 2)); + } catch { + setRecentFoodRequestsFailed(true); + setAlertMessage('Error fetching food requests', AlertStatus.ERROR); + } + }, [setAlertMessage]); + + const fetchOrders = React.useCallback(async () => { + setRecentOrdersFailed(false); + try { + const pantryOrders = await ApiClient.getPantryOrders(); + const sortedOrders = pantryOrders.sort( + (a: OrderSummary, b: OrderSummary) => + new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime(), + ); + setRecentOrders(sortedOrders.slice(0, 4)); + } catch { + setRecentOrdersFailed(true); + setAlertMessage('Error fetching orders', AlertStatus.ERROR); + } + }, [setAlertMessage]); useEffect(() => { const fetchDashboardData = async () => { @@ -46,45 +104,12 @@ const PantryDashboard: React.FC = () => { } }; - const fetchFoodRequests = async () => { - try { - const pantryFoodRequests = await ApiClient.getPantryRequests(); - const sortedFoodRequests = pantryFoodRequests - .filter( - (fr: FoodRequestSummaryDto) => - fr.status === FoodRequestStatus.ACTIVE, - ) - .sort( - (a: FoodRequestSummaryDto, b: FoodRequestSummaryDto) => - new Date(b.requestedAt).getTime() - - new Date(a.requestedAt).getTime(), - ); - setRecentFoodRequests(sortedFoodRequests.slice(0, 2)); - } catch { - setAlertMessage('Error fetching food requests', AlertStatus.ERROR); - } - }; - - const fetchOrders = async () => { - try { - const pantryOrders = await ApiClient.getPantryOrders(); - const sortedOrders = pantryOrders.sort( - (a: OrderSummary, b: OrderSummary) => - new Date(b.createdAt).getTime() - - new Date(a.createdAt).getTime(), - ); - setRecentOrders(sortedOrders.slice(0, 4)); - } catch { - setAlertMessage('Error fetching orders', AlertStatus.ERROR); - } - }; - await Promise.all([fetchPantry(), fetchFoodRequests(), fetchOrders()]); try { const user = await ApiClient.getMe(); - const userStats = await ApiClient.getUserStats(user.id); - setStats(userStats); + setCurrentUser(user); + await fetchStats(user.id); } catch { setAlertMessage( 'Error fetching dashboard statistics', @@ -98,12 +123,15 @@ const PantryDashboard: React.FC = () => { } }; fetchDashboardData(); - }, [setAlertMessage]); + }, [setAlertMessage, fetchStats, fetchFoodRequests, fetchOrders]); if (loading) return null; const isPageEmpty = - recentFoodRequests.length === 0 && recentOrders.length === 0; + recentFoodRequests.length === 0 && + !recentFoodRequestsFailed && + recentOrders.length === 0 && + !recentOrdersFailed; return ( @@ -119,7 +147,24 @@ const PantryDashboard: React.FC = () => { Welcome, {pantry?.pantryName} - {stats && } + {statsFetchFailed ? ( + + + + + + + ) : ( + stats && + )} {isPageEmpty ? ( { Recent Food Requests - {recentFoodRequests.length === 0 ? ( + {recentFoodRequestsFailed ? ( + + + + + + + ) : recentFoodRequests.length === 0 ? ( @@ -164,7 +221,19 @@ const PantryDashboard: React.FC = () => { Recent Orders - {recentOrders.length === 0 ? ( + {recentOrdersFailed ? ( + + + + + + + ) : recentOrders.length === 0 ? ( diff --git a/apps/frontend/src/containers/pantryOrderManagement.tsx b/apps/frontend/src/containers/pantryOrderManagement.tsx index fcb137b11..455e4b786 100644 --- a/apps/frontend/src/containers/pantryOrderManagement.tsx +++ b/apps/frontend/src/containers/pantryOrderManagement.tsx @@ -17,6 +17,7 @@ import { useAlert } from '../hooks/alert'; import { useSearchParams, useNavigate } from 'react-router-dom'; import { ROUTES } from '../routes'; import { PaginationControl } from '@components/pagination'; +import SectionEmptyState from '@components/sectionEmptyState'; type OrderWithColor = OrderSummary & { assigneeColor?: string }; const MAX_PER_STATUS = 5; @@ -77,7 +78,10 @@ const PantryOrderManagement: React.FC = () => { }, }); + const [fetchFailed, setFetchFailed] = useState(false); + const fetchOrders = useCallback(async () => { + setFetchFailed(false); try { const data = await ApiClient.getPantryOrders(); @@ -107,6 +111,7 @@ const PantryOrderManagement: React.FC = () => { }; setCurrentPages(initialPages); } catch { + setFetchFailed(true); setAlertMessage('Failed to fetch orders', AlertStatus.ERROR); } }, [setAlertMessage]); @@ -180,48 +185,60 @@ const PantryOrderManagement: React.FC = () => { /> )} - {Object.values(OrderStatus).map((status) => { - const allOrders = statusOrders[status] || []; - const filterState = filterStates[status]; - - // Apply filters and sorting to all orders - const filteredOrders = allOrders.sort((a, b) => - filterState.sortAsc - ? a.createdAt.localeCompare(b.createdAt) - : b.createdAt.localeCompare(a.createdAt), - ); - - const totalFiltered = filteredOrders.length; - const currentPage = currentPages[status] || 1; - const displayedOrders = filteredOrders.slice( - (currentPage - 1) * MAX_PER_STATUS, - currentPage * MAX_PER_STATUS, - ); - - return ( - - handlePageChange(status, page)} - filterState={filterState} - onFilterChange={(newState: FilterState) => - // Update filter state for the specific status - setFilterStates((prev) => { - const prevSort = prev[status]?.sortAsc; - if (prevSort !== newState.sortAsc) resetPageForStatus(status); - return { ...prev, [status]: newState }; - }) - } - /> + {fetchFailed ? ( + <> + + + - ); - })} + + ) : ( + Object.values(OrderStatus).map((status) => { + const allOrders = statusOrders[status] || []; + const filterState = filterStates[status]; + + // Apply filters and sorting to all orders + const filteredOrders = allOrders.sort((a, b) => + filterState.sortAsc + ? a.createdAt.localeCompare(b.createdAt) + : b.createdAt.localeCompare(a.createdAt), + ); + + const totalFiltered = filteredOrders.length; + const currentPage = currentPages[status] || 1; + const displayedOrders = filteredOrders.slice( + (currentPage - 1) * MAX_PER_STATUS, + currentPage * MAX_PER_STATUS, + ); + + return ( + + handlePageChange(status, page)} + filterState={filterState} + onFilterChange={(newState: FilterState) => + // Update filter state for the specific status + setFilterStates((prev) => { + const prevSort = prev[status]?.sortAsc; + if (prevSort !== newState.sortAsc) + resetPageForStatus(status); + return { ...prev, [status]: newState }; + }) + } + /> + + ); + }) + )} {selectedOrderId && ( { const navigate = useNavigate(); @@ -39,18 +41,22 @@ const VolunteerManagement: React.FC = () => { const pageSize = 8; - const fetchVolunteers = async () => { + const [fetchFailed, setFetchFailed] = useState(false); + + const fetchVolunteers = useCallback(async () => { + setFetchFailed(false); try { const allVolunteers = await ApiClient.getVolunteers(); setVolunteers(allVolunteers); } catch { + setFetchFailed(true); setAlertMessage('Error fetching volunteers', AlertStatus.ERROR); } - }; + }, [setAlertMessage]); useEffect(() => { fetchVolunteers(); - }, [setAlertMessage]); + }, [fetchVolunteers]); useEffect(() => { setCurrentPage(1); @@ -174,172 +180,190 @@ const VolunteerManagement: React.FC = () => { /> - - - - - Users - - - Status - - - Email - - - Actions - - - - - - {paginatedVolunteers?.map((volunteer) => ( - - - - - {getInitials(volunteer.firstName, volunteer.lastName)} - - {volunteer.firstName} {volunteer.lastName} - {volunteer.role === Role.ADMIN && ( + {fetchFailed ? ( + <> + + + + + + ) : ( + <> + + + + + Users + + + Status + + + Email + + + Actions + + + + + + {paginatedVolunteers?.map((volunteer) => ( + + + + + {getInitials(volunteer.firstName, volunteer.lastName)} + + {volunteer.firstName} {volunteer.lastName} + {volunteer.role === Role.ADMIN && ( + + Admin + + )} + + + - Admin + {volunteer.active ? 'Active' : 'Deactivated'} - )} - - - - - {volunteer.active ? 'Active' : 'Deactivated'} - - - {volunteer.email} - - {volunteer.role === Role.VOLUNTEER && ( - - navigate( - `${ROUTES.PANTRY_MANAGEMENT}?volunteerId=${volunteer.id}`, - ) - } - > - View Assigned Pantries - - )} - - - - - - - - - - - - {volunteer.role === Role.VOLUNTEER && - volunteer.active && ( + + {volunteer.email} + + {volunteer.role === Role.VOLUNTEER && ( + + navigate( + `${ROUTES.PANTRY_MANAGEMENT}?volunteerId=${volunteer.id}`, + ) + } + > + View Assigned Pantries + + )} + + + + + + + + + + + + {volunteer.role === Role.VOLUNTEER && + volunteer.active && ( + { + setSelectedVolunteer(volunteer); + setIsPromoteModalOpen(true); + }} + > + Promote to Admin + + )} { setSelectedVolunteer(volunteer); - setIsPromoteModalOpen(true); + setIsConfirmModalOpen(true); }} > - Promote to Admin + {volunteer.active ? 'Deactivate' : 'Activate'} - )} - { - setSelectedVolunteer(volunteer); - setIsConfirmModalOpen(true); - }} - > - {volunteer.active ? 'Deactivate' : 'Activate'} - - - - - - - - ))} - - - - - + + + + + + + ))} + + + + + + + )} {selectedVolunteer && ( diff --git a/apps/frontend/src/containers/volunteerAssignedPantries.tsx b/apps/frontend/src/containers/volunteerAssignedPantries.tsx index 5ed0f16dc..335c3591a 100644 --- a/apps/frontend/src/containers/volunteerAssignedPantries.tsx +++ b/apps/frontend/src/containers/volunteerAssignedPantries.tsx @@ -1,4 +1,4 @@ -import React, { useState, useEffect, useMemo } from 'react'; +import React, { useState, useEffect, useMemo, useCallback } from 'react'; import { Funnel, CircleCheck, Search } from 'lucide-react'; import { Box, @@ -18,6 +18,7 @@ import { FloatingAlert } from '@components/floatingAlert'; import { useNavigate } from 'react-router-dom'; import { useAlert } from '../hooks/alert'; import { ROUTES } from '../routes'; +import SectionEmptyState from '@components/sectionEmptyState'; const AssignedPantries: React.FC = () => { const navigate = useNavigate(); @@ -29,35 +30,39 @@ const AssignedPantries: React.FC = () => { ); const [pantrySearch, setPantrySearch] = useState(''); const [alertState, setAlertMessage] = useAlert(); + const [fetchFailed, setFetchFailed] = useState(false); - useEffect(() => { - const fetchAssignedPantries = async () => { - let user: User; - let userId: number; - try { - user = await ApiClient.getMe(); - userId = user.id; - } catch { - setAlertMessage( - 'Authentication error. Please log in and try again.', - AlertStatus.ERROR, - ); - setIsLoading(false); - return; - } + const fetchAssignedPantries = useCallback(async () => { + setFetchFailed(false); + let user: User; + let userId: number; + try { + user = await ApiClient.getMe(); + userId = user.id; + } catch { + setFetchFailed(true); + setAlertMessage( + 'Authentication error. Please log in and try again.', + AlertStatus.ERROR, + ); + setIsLoading(false); + return; + } - try { - const data = await ApiClient.getVolunteerPantries(userId); - setPantries(data); - } catch { - setAlertMessage('Error fetching assigned pantries', AlertStatus.ERROR); - } finally { - setIsLoading(false); - } - }; + try { + const data = await ApiClient.getVolunteerPantries(userId); + setPantries(data); + } catch { + setFetchFailed(true); + setAlertMessage('Error fetching assigned pantries', AlertStatus.ERROR); + } finally { + setIsLoading(false); + } + }, [setAlertMessage]); + useEffect(() => { fetchAssignedPantries(); - }, [setAlertMessage]); + }, [fetchAssignedPantries]); const isRefrigeratorFriendly = (pantry: Pantry): boolean => { return ( @@ -121,6 +126,15 @@ const AssignedPantries: React.FC = () => { + ) : fetchFailed ? ( + <> + + + + + ) : ( <> {!hasNoAssignedPantries && ( diff --git a/apps/frontend/src/containers/volunteerDashboard.tsx b/apps/frontend/src/containers/volunteerDashboard.tsx index 8eba47e2a..f1ba5e14b 100644 --- a/apps/frontend/src/containers/volunteerDashboard.tsx +++ b/apps/frontend/src/containers/volunteerDashboard.tsx @@ -1,5 +1,5 @@ import ApiClient from '@api/apiClient'; -import { Box, Heading, Text } from '@chakra-ui/react'; +import { Box, Button, Heading, Text } from '@chakra-ui/react'; import DashboardCard, { DashboardCardType, ORDER_STATUS_BADGE, @@ -31,52 +31,86 @@ const VolunteerDashboard: React.FC = () => { >([]); const [recentOrders, setRecentOrders] = useState([]); const [stats, setStats] = useState | null>(null); + const [recentFoodRequestsFailed, setRecentFoodRequestsFailed] = + useState(false); + const [recentOrdersFailed, setRecentOrdersFailed] = useState(false); + const [statsFetchFailed, setStatsFetchFailed] = useState(false); - useEffect(() => { - const fetchDashboardData = async () => { + const fetchStats = React.useCallback( + async (userId: number) => { + setStatsFetchFailed(false); try { - const currentUser = await ApiClient.getMe(); - setUser(currentUser); + const userStats = await ApiClient.getUserStats(userId); + setStats(userStats); + } catch { + setStatsFetchFailed(true); + setAlertMessage( + 'Error fetching dashboard statistics', + AlertStatus.ERROR, + ); + } + }, + [setAlertMessage], + ); - try { - const userStats = await ApiClient.getUserStats(currentUser.id); - setStats(userStats); - } catch { - setAlertMessage( - 'Error fetching dashboard statistics', - AlertStatus.ERROR, - ); - } + const fetchFoodRequests = React.useCallback(async () => { + setRecentFoodRequestsFailed(false); + try { + const requests = await ApiClient.getVolunteerAssignedRequests(); + const sorted = requests + .filter( + (r: FoodRequestSummaryDto) => r.status === FoodRequestStatus.ACTIVE, + ) + .sort( + (a: FoodRequestSummaryDto, b: FoodRequestSummaryDto) => + new Date(b.requestedAt).getTime() - + new Date(a.requestedAt).getTime(), + ); + setRecentFoodRequests(sorted.slice(0, 2)); + } catch { + setRecentFoodRequestsFailed(true); + setAlertMessage('Error fetching food requests', AlertStatus.ERROR); + } + }, [setAlertMessage]); - const [requests, orders] = await Promise.all([ - ApiClient.getVolunteerAssignedRequests(), - ApiClient.getVolunteerRecentOrders(), - ]); + const fetchOrders = React.useCallback(async () => { + setRecentOrdersFailed(false); + try { + const orders = await ApiClient.getVolunteerRecentOrders(); + setRecentOrders(orders); + } catch { + setRecentOrdersFailed(true); + setAlertMessage('Error fetching orders', AlertStatus.ERROR); + } + }, [setAlertMessage]); - const sorted = requests - .filter( - (r: FoodRequestSummaryDto) => r.status === FoodRequestStatus.ACTIVE, - ) - .sort( - (a: FoodRequestSummaryDto, b: FoodRequestSummaryDto) => - new Date(b.requestedAt).getTime() - - new Date(a.requestedAt).getTime(), - ); - setRecentFoodRequests(sorted.slice(0, 2)); - setRecentOrders(orders); + useEffect(() => { + const fetchDashboardData = async () => { + let currentUser: User; + try { + currentUser = await ApiClient.getMe(); + setUser(currentUser); } catch { setAlertMessage('Error fetching dashboard data', AlertStatus.ERROR); - } finally { setLoading(false); + return; } + + await fetchStats(currentUser.id); + + await Promise.all([fetchFoodRequests(), fetchOrders()]); + setLoading(false); }; fetchDashboardData(); - }, [setAlertMessage]); + }, [setAlertMessage, fetchStats, fetchFoodRequests, fetchOrders]); if (loading || !user) return null; const isPageEmpty = - recentFoodRequests.length === 0 && recentOrders.length === 0; + recentFoodRequests.length === 0 && + !recentFoodRequestsFailed && + recentOrders.length === 0 && + !recentOrdersFailed; return ( @@ -92,7 +126,24 @@ const VolunteerDashboard: React.FC = () => { Welcome, {user.firstName} {user.lastName} - {stats && } + {statsFetchFailed ? ( + + + + + + + ) : ( + stats && + )} {isPageEmpty ? ( { Recent Food Requests - {recentFoodRequests.length === 0 ? ( + {recentFoodRequestsFailed ? ( + + + + + + + ) : recentFoodRequests.length === 0 ? ( @@ -139,7 +202,19 @@ const VolunteerDashboard: React.FC = () => { My Orders - {recentOrders.length === 0 ? ( + {recentOrdersFailed ? ( + + + + + + + ) : recentOrders.length === 0 ? ( diff --git a/apps/frontend/src/containers/volunteerOrderManagement.tsx b/apps/frontend/src/containers/volunteerOrderManagement.tsx index ff61683b0..2ab82c220 100644 --- a/apps/frontend/src/containers/volunteerOrderManagement.tsx +++ b/apps/frontend/src/containers/volunteerOrderManagement.tsx @@ -35,6 +35,7 @@ import { useAlert } from '../hooks/alert'; import { useNavigate, useSearchParams } from 'react-router-dom'; import { ROUTES } from '../routes'; import { PaginationControl } from '@components/pagination'; +import SectionEmptyState from '@components/sectionEmptyState'; type VolunteerOrderWithColor = VolunteerOrder & { assigneeColor?: string }; @@ -111,7 +112,10 @@ const VolunteerOrderManagement: React.FC = () => { const MAX_PER_STATUS = 5; + const [fetchFailed, setFetchFailed] = useState(false); + const fetchOrders = useCallback(async () => { + setFetchFailed(false); let user: User; let userId: number; try { @@ -119,6 +123,7 @@ const VolunteerOrderManagement: React.FC = () => { userId = user.id; setCurrentUser(user); } catch { + setFetchFailed(true); setAlertMessage( 'Authentication error. Please log in and try again.', AlertStatus.ERROR, @@ -161,6 +166,7 @@ const VolunteerOrderManagement: React.FC = () => { }; setCurrentPages(initialPages); } catch { + setFetchFailed(true); setAlertMessage('Error fetching assigned orders', AlertStatus.ERROR); } finally { setIsLoading(false); @@ -302,66 +308,77 @@ const VolunteerOrderManagement: React.FC = () => { /> )} - {Object.values(OrderStatus).map((status) => { - const allOrders = statusOrders[status] || []; - const filterState = filterStates[status]; - - const pantryOptions = [ - ...new Set(allOrders.map((o) => o.pantryName)), - ].sort((a, b) => a.localeCompare(b)); - - const filteredOrders = allOrders - .filter( - (o) => - filterState.selectedPantries.length === 0 || - filterState.selectedPantries.includes(o.pantryName), - ) - .sort((a, b) => - filterState.sortAsc - ? new Date(a.createdAt).getTime() - - new Date(b.createdAt).getTime() - : new Date(b.createdAt).getTime() - - new Date(a.createdAt).getTime(), + {fetchFailed ? ( + <> + + + + + + ) : ( + Object.values(OrderStatus).map((status) => { + const allOrders = statusOrders[status] || []; + const filterState = filterStates[status]; + + const pantryOptions = [ + ...new Set(allOrders.map((o) => o.pantryName)), + ].sort((a, b) => a.localeCompare(b)); + + const filteredOrders = allOrders + .filter( + (o) => + filterState.selectedPantries.length === 0 || + filterState.selectedPantries.includes(o.pantryName), + ) + .sort((a, b) => + filterState.sortAsc + ? new Date(a.createdAt).getTime() - + new Date(b.createdAt).getTime() + : new Date(b.createdAt).getTime() - + new Date(a.createdAt).getTime(), + ); + + const totalFiltered = filteredOrders.length; + const currentPage = currentPages[status] || 1; + const displayedOrders = filteredOrders.slice( + (currentPage - 1) * MAX_PER_STATUS, + currentPage * MAX_PER_STATUS, ); - const totalFiltered = filteredOrders.length; - const currentPage = currentPages[status] || 1; - const displayedOrders = filteredOrders.slice( - (currentPage - 1) * MAX_PER_STATUS, - currentPage * MAX_PER_STATUS, - ); - - return ( - - handlePageChange(status, page)} - pantryOptions={pantryOptions} - filterState={filterState} - onFilterChange={(newState: FilterState) => - setFilterStates((prev) => { - const prevSelected = prev[status]?.selectedPantries || []; - const prevKey = [...prevSelected].sort().join(','); - const newKey = [...newState.selectedPantries] - .sort() - .join(','); - if (prevKey !== newKey) { - resetPageForStatus(status); - } - return { ...prev, [status]: newState }; - }) - } - onOpenActionModal={setActionModalOrder} - currentUser={currentUser} - /> - - ); - })} + return ( + + handlePageChange(status, page)} + pantryOptions={pantryOptions} + filterState={filterState} + onFilterChange={(newState: FilterState) => + setFilterStates((prev) => { + const prevSelected = prev[status]?.selectedPantries || []; + const prevKey = [...prevSelected].sort().join(','); + const newKey = [...newState.selectedPantries] + .sort() + .join(','); + if (prevKey !== newKey) { + resetPageForStatus(status); + } + return { ...prev, [status]: newState }; + }) + } + onOpenActionModal={setActionModalOrder} + currentUser={currentUser} + /> + + ); + }) + )} {actionModalOrder && ( { - setAlertState({ message, status, id: idRef.current++ }); + setAlertState((prev) => { + if (prev && prev.status === status) { + const lines = prev.message.split('\n'); + if (lines.includes(message)) { + return { ...prev, id: idRef.current++ }; + } + return { + message: `${prev.message}\n${message}`, + status, + id: idRef.current++, + }; + } + return { message, status, id: idRef.current++ }; + }); }, [], );