diff --git a/apps/frontend/src/api/apiClient.ts b/apps/frontend/src/api/apiClient.ts index ff731e2e2..5e822dfb1 100644 --- a/apps/frontend/src/api/apiClient.ts +++ b/apps/frontend/src/api/apiClient.ts @@ -40,10 +40,10 @@ import { VolunteerAction, ApprovedPantryResponse, UpdatePantryVolunteersDto, - FoodRequestWithoutRelations, BulkUpdateTrackingCostDto, UpdateDonationItemDetailsDto, PendingApplication, + DonationReminderDto, } from 'types/types'; const defaultBaseUrl = @@ -434,6 +434,14 @@ export class ApiClient { .then((response) => response.data); } + public async getNextTwoDonationReminders( + foodManufacturerId: number, + ): Promise { + return this.axiosInstance + .get(`/api/manufacturers/${foodManufacturerId}/next-two-reminders`) + .then((response) => response.data); + } + public async bulkUpdateTrackingCostInfo( data: BulkUpdateTrackingCostDto, ): Promise { diff --git a/apps/frontend/src/app.tsx b/apps/frontend/src/app.tsx index c7735e1d4..614df457a 100644 --- a/apps/frontend/src/app.tsx +++ b/apps/frontend/src/app.tsx @@ -36,6 +36,7 @@ import AdminRequestManagement from '@containers/adminRequestManagement'; import PantryDashboard from '@containers/pantryDashboard'; import VolunteerDashboard from '@containers/volunteerDashboard'; import AdminDashboard from '@containers/adminDashboard'; +import FoodManufacturerDashboard from '@containers/foodManufacturerDashboard'; Amplify.configure(CognitoAuthConfig); @@ -112,6 +113,14 @@ const router = createBrowserRouter([ ), }, + { + path: ROUTES.FM_DASHBOARD, + element: ( + + + + ), + }, { path: ROUTES.APPROVE_PANTRIES, element: ( diff --git a/apps/frontend/src/components/Navbar.tsx b/apps/frontend/src/components/Navbar.tsx index 11b9fe8c1..dc3eb3101 100644 --- a/apps/frontend/src/components/Navbar.tsx +++ b/apps/frontend/src/components/Navbar.tsx @@ -272,9 +272,9 @@ const Navbar: React.FC = () => { // Should be changed once other dashboards are implmented const ROLE_DASHBOARD_ROUTE: Record = { [Role.ADMIN]: ROUTES.ADMIN_DASHBOARD, + [Role.FOODMANUFACTURER]: ROUTES.FM_DASHBOARD, [Role.VOLUNTEER]: ROUTES.VOLUNTEER_DASHBOARD, [Role.PANTRY]: ROUTES.PANTRY_DASHBOARD, - [Role.FOODMANUFACTURER]: ROUTES.HOME, }; return ( diff --git a/apps/frontend/src/components/foodRequestManagement.tsx b/apps/frontend/src/components/foodRequestManagement.tsx index 1db69e7df..7d0bce590 100644 --- a/apps/frontend/src/components/foodRequestManagement.tsx +++ b/apps/frontend/src/components/foodRequestManagement.tsx @@ -21,7 +21,6 @@ import VolunteerRequestActionRequiredModal from '@components/forms/volunteerRequ import CreateNewOrderModal from '@components/forms/createNewOrderModal'; import { useAlert } from '../hooks/alert'; import { useNavigate, useLocation } from 'react-router-dom'; -import { ROUTES } from '../routes'; interface RequestManagementProps { fetchRequests: () => Promise; @@ -80,10 +79,21 @@ const RequestManagement: React.FC = ({ if (match) { setSelectedViewDetailsRequest(match); + + // Paginate to the page that contains the deeplinked request + const sortedAtLoad = [...requests].sort((a, b) => + b.requestedAt.localeCompare(a.requestedAt), + ); + const idx = sortedAtLoad.findIndex( + (r) => r.requestId === initialRequestId, + ); + if (idx >= 0) { + setCurrentPage(Math.floor(idx / itemsPerPage) + 1); + } } else { - navigate(ROUTES.REQUEST_FORM, { replace: true }); + navigate(location.pathname, { replace: true }); } - }, [initialRequestId, requests, navigate]); + }, [initialRequestId, requests, navigate, location]); const pantryOptions = [ ...new Set( diff --git a/apps/frontend/src/containers/adminDonation.tsx b/apps/frontend/src/containers/adminDonation.tsx index 62d547bf2..c0fa1afe5 100644 --- a/apps/frontend/src/containers/adminDonation.tsx +++ b/apps/frontend/src/containers/adminDonation.tsx @@ -60,12 +60,21 @@ const AdminDonation: React.FC = () => { if (!donationIdFromUrl) return; if (donations.length === 0) return; + const id = Number(donationIdFromUrl); const matchedDonation = donations.find( - (donation) => donation.donationId === Number(donationIdFromUrl), + (donation) => donation.donationId === id, ); if (matchedDonation) { setSelectedDonation(matchedDonation); + // Paginate to the page that contains the deeplinked donation + const sortedAtLoad = [...donations].sort((a, b) => + b.dateDonated.localeCompare(a.dateDonated), + ); + const idx = sortedAtLoad.findIndex((d) => d.donationId === id); + if (idx >= 0) { + setCurrentPage(Math.floor(idx / itemsPerPage) + 1); + } } else { navigate(ROUTES.ADMIN_DONATION, { replace: true }); } diff --git a/apps/frontend/src/containers/adminOrderManagement.tsx b/apps/frontend/src/containers/adminOrderManagement.tsx index e5039a5c0..26f56e931 100644 --- a/apps/frontend/src/containers/adminOrderManagement.tsx +++ b/apps/frontend/src/containers/adminOrderManagement.tsx @@ -159,12 +159,25 @@ const AdminOrderManagement: React.FC = () => { const allOrders = Object.values(statusOrders).flat(); if (allOrders.length === 0) return; - const matchedOrder = allOrders.find( - (order) => order.orderId === Number(orderIdFromUrl), - ); + const id = Number(orderIdFromUrl); + const matchedOrder = allOrders.find((order) => order.orderId === id); if (matchedOrder) { - setSelectedOrderId(Number(orderIdFromUrl)); + setSelectedOrderId(id); + // Paginate the containing status to the page that holds this order. + for (const status of Object.values(OrderStatus)) { + const sorted = [...statusOrders[status]].sort((a, b) => + b.createdAt.localeCompare(a.createdAt), + ); + const idx = sorted.findIndex((o) => o.orderId === id); + if (idx >= 0) { + setCurrentPages((prev) => ({ + ...prev, + [status]: Math.floor(idx / MAX_PER_STATUS) + 1, + })); + break; + } + } } else { navigate(ROUTES.ADMIN_ORDER_MANAGEMENT, { replace: true }); } diff --git a/apps/frontend/src/containers/approveFoodManufacturers.tsx b/apps/frontend/src/containers/approveFoodManufacturers.tsx index 786e13137..14208d897 100644 --- a/apps/frontend/src/containers/approveFoodManufacturers.tsx +++ b/apps/frontend/src/containers/approveFoodManufacturers.tsx @@ -1,6 +1,5 @@ import React, { useEffect, useState } from 'react'; -import { useSearchParams } from 'react-router-dom'; -import { ROUTES } from '../routes'; +import { useNavigate, useSearchParams } from 'react-router-dom'; import { Table, Button, @@ -24,8 +23,10 @@ import { } from 'lucide-react'; import { useAlert } from '../hooks/alert'; import { FloatingAlert } from '@components/floatingAlert'; +import { ROUTES } from '../routes'; const ApproveFoodManufacturers: React.FC = () => { + const navigate = useNavigate(); const [foodManufacturers, setFoodManufacturers] = useState< FoodManufacturer[] >([]); @@ -117,9 +118,9 @@ const ApproveFoodManufacturers: React.FC = () => { : `${name} - Application Rejected`; setSuccessMessage(message); - setSearchParams({}); + navigate(ROUTES.APPROVE_FOOD_MANUFACTURERS, { replace: true }); } - }, [searchParams, setSearchParams, setErrorMessage, setSuccessMessage]); + }, [searchParams, setErrorMessage, setSuccessMessage, navigate]); return ( diff --git a/apps/frontend/src/containers/approvePantries.tsx b/apps/frontend/src/containers/approvePantries.tsx index 75dd9245a..90bce7abe 100644 --- a/apps/frontend/src/containers/approvePantries.tsx +++ b/apps/frontend/src/containers/approvePantries.tsx @@ -1,6 +1,5 @@ import React, { useEffect, useState } from 'react'; -import { useSearchParams } from 'react-router-dom'; -import { ROUTES } from '../routes'; +import { useNavigate, useSearchParams } from 'react-router-dom'; import { Table, Button, @@ -24,8 +23,10 @@ import { } from 'lucide-react'; import { useAlert } from '../hooks/alert'; import { FloatingAlert } from '@components/floatingAlert'; +import { ROUTES } from '../routes'; const ApprovePantries: React.FC = () => { + const navigate = useNavigate(); const [pantries, setPantries] = useState([]); const [sortAsc, setSortAsc] = useState(false); const [selectedPantries, setSelectedPantries] = useState([]); @@ -108,9 +109,9 @@ const ApprovePantries: React.FC = () => { : `${name} - Application Rejected`; setSuccessMessage(message); - setSearchParams({}); + navigate(ROUTES.APPROVE_PANTRIES, { replace: true }); } - }, [searchParams, setSearchParams, setErrorMessage, setSuccessMessage]); + }, [searchParams, navigate, setErrorMessage, setSuccessMessage]); return ( diff --git a/apps/frontend/src/containers/foodManufacturerDashboard.tsx b/apps/frontend/src/containers/foodManufacturerDashboard.tsx new file mode 100644 index 000000000..93dd02e0d --- /dev/null +++ b/apps/frontend/src/containers/foodManufacturerDashboard.tsx @@ -0,0 +1,128 @@ +import React, { useEffect, useState } from 'react'; +import { Box, Heading, Text } from '@chakra-ui/react'; +import DashboardCard, { DashboardCardType } from '@components/dashboardCard'; +import { + Donation, + DonationDetails, + DonationReminderDto, + FoodManufacturer, +} from '../types/types'; +import ApiClient from '@api/apiClient'; +import { useAlert } from '../hooks/alert'; +import { FloatingAlert } from '@components/floatingAlert'; +import { useNavigate } from 'react-router-dom'; +import { ROUTES } from '../routes'; + +const FoodManufacturerDashboard: React.FC = () => { + const navigate = useNavigate(); + + const [errorAlertState, setErrorMessage] = useAlert(); + const [foodManufacturer, setFoodManufacturer] = + useState(null); + const [upcomingReminders, setUpcomingReminders] = useState< + DonationReminderDto[] + >([]); + const [recentDonations, setRecentDonations] = useState([]); + + useEffect(() => { + const fetchFmData = async () => { + let fmId: number; + try { + fmId = await ApiClient.getCurrentUserFoodManufacturerId(); + const fm = await ApiClient.getFoodManufacturer(fmId); + setFoodManufacturer(fm); + } catch { + setErrorMessage('Error fetching your manufacturer profile.'); + return; + } + + const [reminders, donations] = await Promise.allSettled([ + ApiClient.getNextTwoDonationReminders(fmId), + ApiClient.getAllDonationsByFoodManufacturer(fmId), + ]); + + // If reminders is successfully retrieved from API with the Promise.allSettled + if (reminders.status === 'fulfilled') { + setUpcomingReminders(reminders.value); + } else { + setErrorMessage('Error fetching upcoming donations.'); + } + + // If donations is successfully retrieved from API with the Promise.allSettled + 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.'); + } + }; + fetchFmData(); + }, [setErrorMessage]); + + return ( + + {errorAlertState && ( + + )} + + Welcome, {foodManufacturer?.foodManufacturerName} + + + + Upcoming Donations + + + {upcomingReminders.map((reminder) => ( + + navigate( + `${ROUTES.FM_DONATION_MANAGEMENT}?donationId=${reminder.donation.donationId}`, + ) + } + /> + ))} + + + + Recent Donations + + + {recentDonations.map((donation) => ( + + navigate( + `${ROUTES.FM_DONATION_MANAGEMENT}?donationId=${donation.donationId}`, + ) + } + /> + ))} + + + ); +}; + +export default FoodManufacturerDashboard; diff --git a/apps/frontend/src/containers/foodManufacturerDonationManagement.tsx b/apps/frontend/src/containers/foodManufacturerDonationManagement.tsx index 4e6f390f0..8005e28eb 100644 --- a/apps/frontend/src/containers/foodManufacturerDonationManagement.tsx +++ b/apps/frontend/src/containers/foodManufacturerDonationManagement.tsx @@ -14,13 +14,13 @@ import { ChevronRight, ChevronLeft, Mail, CircleCheck } from 'lucide-react'; import { capitalize, formatDate, DONATION_STATUS_COLORS } from '@utils/utils'; import ApiClient from '@api/apiClient'; import { DonationDetails, DonationStatus } from '../types/types'; -import DonationDetailsModal from '@components/forms/donationDetailsModal'; import NewDonationFormModal from '@components/forms/newDonationFormModal'; import ResubmitDonationModal from '@components/forms/resubmitDonationModal'; import { useNavigate, useSearchParams } from 'react-router-dom'; import { ROUTES } from '../routes'; import { FloatingAlert } from '@components/floatingAlert'; import { useAlert } from '../hooks/alert'; +import DonationDetailsModal from '@components/forms/donationDetailsModal'; import FmCompleteRequiredActionsModal from '@components/forms/fmCompleteRequiredActionsModal'; const MAX_PER_STATUS = 5; @@ -45,12 +45,6 @@ const FoodManufacturerDonationManagement: React.FC = () => { [DonationStatus.AVAILABLE]: [], [DonationStatus.FULFILLED]: [], }); - - // State to hold selected donation for details modal - const [selectedDonationId, setSelectedDonationId] = useState( - null, - ); - // State to hold current page per status const [currentPages, setCurrentPages] = useState< Record @@ -60,6 +54,11 @@ const FoodManufacturerDonationManagement: React.FC = () => { [DonationStatus.FULFILLED]: 1, }); + // State to hold selected donation for details modal + const [selectedDonationId, setSelectedDonationId] = useState( + null, + ); + // Fetch all donations on component mount and sorts them into their appropriate status lists const fetchDonations = async (fmId: number) => { try { @@ -85,12 +84,27 @@ const FoodManufacturerDonationManagement: React.FC = () => { setStatusDonations(grouped); - // Initialize current page for each status 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); return grouped; @@ -130,6 +144,14 @@ const FoodManufacturerDonationManagement: React.FC = () => { init(); }, []); + useEffect(() => { + const donationIdParam = searchParams.get('donationId'); + if (!donationIdParam) return; + + const id = Number(donationIdParam); + setSelectedDonationId(id); + }, [searchParams, setErrorMessage]); + const handleResubmitClose = () => { setIsResubmitOpen(false); if (resubmitDonationId) { @@ -260,6 +282,10 @@ const FoodManufacturerDonationManagement: React.FC = () => { currentPage={currentPage} onPageChange={(page) => handlePageChange(status, page)} onActionSelect={setSelectedActionDonation} + onDonationClose={() => { + setSelectedDonationId(null); + navigate(ROUTES.FM_DONATION_MANAGEMENT, { replace: true }); + }} /> ); @@ -278,6 +304,7 @@ interface DonationStatusSectionProps { currentPage: number; onPageChange: (page: number) => void; onActionSelect: (donation: DonationDetails | null) => void; + onDonationClose: () => void; } const DonationStatusSection: React.FC = ({ @@ -285,11 +312,12 @@ const DonationStatusSection: React.FC = ({ status, colors, onDonationSelect, - selectedDonationId, totalDonations, currentPage, + selectedDonationId, onPageChange, onActionSelect, + onDonationClose, }) => { const totalPages = Math.ceil(totalDonations / MAX_PER_STATUS); @@ -420,7 +448,7 @@ const DonationStatusSection: React.FC = ({ onDonationSelect(null)} + onClose={onDonationClose} /> )} diff --git a/apps/frontend/src/containers/formRequests.tsx b/apps/frontend/src/containers/formRequests.tsx index 1434751a0..40a7cfb48 100644 --- a/apps/frontend/src/containers/formRequests.tsx +++ b/apps/frontend/src/containers/formRequests.tsx @@ -73,11 +73,15 @@ const FormRequests: React.FC = () => { const requestIdFromUrl = searchParams.get('requestId'); if (!requestIdFromUrl || requests.length === 0) return; - const match = requests.find( - (r) => r.requestId === Number(requestIdFromUrl), - ); + const id = Number(requestIdFromUrl); + const match = requests.find((r) => r.requestId === id); if (match) { setOpenReadOnlyRequest(match); + // Paginate to page that holds the deeplinked request + const idx = requests.findIndex((r) => r.requestId === id); + if (idx >= 0) { + setCurrentPage(Math.floor(idx / pageSize) + 1); + } } else { navigate(ROUTES.REQUEST_FORM, { replace: true }); } diff --git a/apps/frontend/src/containers/homepage.tsx b/apps/frontend/src/containers/homepage.tsx index f678e60fa..fca3ab8e0 100644 --- a/apps/frontend/src/containers/homepage.tsx +++ b/apps/frontend/src/containers/homepage.tsx @@ -80,6 +80,13 @@ const Homepage: React.FC = () => { + + + + Food Manufacturer Dashboard + + + diff --git a/apps/frontend/src/containers/pantryOrderManagement.tsx b/apps/frontend/src/containers/pantryOrderManagement.tsx index 394457d3f..5ee5339c2 100644 --- a/apps/frontend/src/containers/pantryOrderManagement.tsx +++ b/apps/frontend/src/containers/pantryOrderManagement.tsx @@ -129,9 +129,24 @@ const PantryOrderManagement: React.FC = () => { const allOrders = Object.values(statusOrders).flat(); if (!orderIdFromUrl || allOrders.length === 0) return; - const match = allOrders.find((o) => o.orderId === Number(orderIdFromUrl)); + const id = Number(orderIdFromUrl); + const match = allOrders.find((o) => o.orderId === id); if (match) { setSelectedOrderId(match.orderId); + // Paginate the containing status to the page that holds this order. + for (const status of Object.values(OrderStatus)) { + const sorted = [...statusOrders[status]].sort((a, b) => + b.createdAt.localeCompare(a.createdAt), + ); + const idx = sorted.findIndex((o) => o.orderId === id); + if (idx >= 0) { + setCurrentPages((prev) => ({ + ...prev, + [status]: Math.floor(idx / MAX_PER_STATUS) + 1, + })); + break; + } + } } else { navigate(ROUTES.PANTRY_ORDER_MANAGEMENT, { replace: true }); } diff --git a/apps/frontend/src/containers/volunteerOrderManagement.tsx b/apps/frontend/src/containers/volunteerOrderManagement.tsx index a130fb125..d23677005 100644 --- a/apps/frontend/src/containers/volunteerOrderManagement.tsx +++ b/apps/frontend/src/containers/volunteerOrderManagement.tsx @@ -171,9 +171,26 @@ const VolunteerOrderManagement: React.FC = () => { const allOrders = Object.values(statusOrders).flat(); if (!orderIdFromUrl || allOrders.length === 0) return; - const match = allOrders.find((o) => o.orderId === Number(orderIdFromUrl)); + const id = Number(orderIdFromUrl); + const match = allOrders.find((o) => o.orderId === id); if (match) { setSelectedOrderId(match.orderId); + + // Paginate the containing status to the page that holds this order. + for (const status of Object.values(OrderStatus)) { + const sorted = [...statusOrders[status]].sort( + (a, b) => + new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime(), + ); + const idx = sorted.findIndex((o) => o.orderId === id); + if (idx >= 0) { + setCurrentPages((prev) => ({ + ...prev, + [status]: Math.floor(idx / MAX_PER_STATUS) + 1, + })); + break; + } + } } else { navigate(ROUTES.VOLUNTEER_ORDER_MANAGEMENT, { replace: true }); } @@ -276,7 +293,6 @@ const VolunteerOrderManagement: React.FC = () => { orders={displayedOrders} status={status} colors={ORDER_STATUS_COLORS[status]} - selectedOrderId={selectedOrderId} onOrderSelect={setSelectedOrderId} totalOrders={totalFiltered} currentPage={currentPage} @@ -311,6 +327,17 @@ const VolunteerOrderManagement: React.FC = () => { onActionCompleted={handleActionCompleted} /> )} + + {selectedOrderId && ( + { + setSelectedOrderId(null); + navigate(ROUTES.VOLUNTEER_ORDER_MANAGEMENT, { replace: true }); + }} + /> + )} ); }; @@ -320,7 +347,6 @@ interface OrderStatusSectionProps { status: OrderStatus; colors: string[]; onOrderSelect: (orderId: number | null) => void; - selectedOrderId: number | null; totalOrders: number; currentPage: number; onPageChange: (page: number) => void; @@ -344,7 +370,6 @@ const OrderStatusSection: React.FC = ({ status, colors, onOrderSelect, - selectedOrderId, totalOrders, currentPage, onPageChange, @@ -357,8 +382,6 @@ const OrderStatusSection: React.FC = ({ const [isFilterOpen, setIsFilterOpen] = useState(false); const [isSortOpen, setIsSortOpen] = useState(false); - const navigate = useNavigate(); - const MAX_PER_STATUS = 5; const totalPages = Math.ceil(totalOrders / MAX_PER_STATUS); @@ -802,16 +825,6 @@ const OrderStatusSection: React.FC = ({ })} - {selectedOrderId && ( - { - onOrderSelect(null); - navigate(ROUTES.VOLUNTEER_ORDER_MANAGEMENT, { replace: true }); - }} - /> - )} {totalPages > 1 && ( diff --git a/apps/frontend/src/routes.ts b/apps/frontend/src/routes.ts index 3ea11f32c..ac42813db 100644 --- a/apps/frontend/src/routes.ts +++ b/apps/frontend/src/routes.ts @@ -37,4 +37,5 @@ export const ROUTES = { VOLUNTEER_DASHBOARD: '/volunteer-dashboard', FM_DONATION_MANAGEMENT: '/fm-donation-management', + FM_DASHBOARD: '/fm-dashboard', }; diff --git a/apps/frontend/src/types/types.ts b/apps/frontend/src/types/types.ts index 2a1592b91..8addbea64 100644 --- a/apps/frontend/src/types/types.ts +++ b/apps/frontend/src/types/types.ts @@ -225,6 +225,11 @@ export interface DonationOrderDetails { items: OrderItemDetails[]; } +export interface DonationReminderDto { + donation: Donation; + reminderDate: string; +} + export interface DonationItem { itemId: number; donationId: number;