diff --git a/apps/frontend/src/api/apiClient.ts b/apps/frontend/src/api/apiClient.ts index 262ef0ff9..912a48a42 100644 --- a/apps/frontend/src/api/apiClient.ts +++ b/apps/frontend/src/api/apiClient.ts @@ -38,6 +38,9 @@ import { DonationDetails, VolunteerOrder, VolunteerAction, + ApprovedPantryResponse, + UpdatePantryVolunteersDto, + FoodRequestWithoutRelations, BulkUpdateTrackingCostDto, UpdateDonationItemDetailsDto, PendingApplication, @@ -161,6 +164,12 @@ export class ApiClient { .then((response) => response.data); } + public async getApprovedPantries(): Promise { + return this.axiosInstance + .get(`/api/pantries/approved`) + .then((response) => response.data); + } + public async getPantryFromOrder(orderId: number): Promise { return this.axiosInstance .get(`/api/orders/${orderId}/pantry`) @@ -407,6 +416,16 @@ export class ApiClient { }); } + public async updatePantryVolunteers( + pantryId: number, + body: UpdatePantryVolunteersDto, + ): Promise { + await this.axiosInstance.patch( + `/api/pantries/${pantryId}/volunteers`, + body, + ); + } + public async updateFoodManufacturer( manufacturerId: number, decision: 'approve' | 'deny', diff --git a/apps/frontend/src/app.tsx b/apps/frontend/src/app.tsx index be6b3c9aa..d10b5410e 100644 --- a/apps/frontend/src/app.tsx +++ b/apps/frontend/src/app.tsx @@ -31,6 +31,7 @@ import VolunteerRequestManagement from '@containers/volunteerRequestManagement'; import AdminDonationStats from '@containers/adminDonationStats'; import ProfilePage from '@containers/profilePage'; import VolunteerOrderManagement from '@containers/volunteerOrderManagement'; +import AdminPantryManagement from '@containers/adminPantryManagement'; import AdminRequestManagement from '@containers/adminRequestManagement'; import AdminDashboard from '@containers/adminDashboard'; @@ -117,6 +118,14 @@ const router = createBrowserRouter([ ), }, + { + path: ROUTES.PANTRY_MANAGEMENT_DETAILS, + element: ( + + + + ), + }, { path: ROUTES.FOOD_MANUFACTURER_APPLICATION_DETAILS, element: ( @@ -213,6 +222,14 @@ const router = createBrowserRouter([ ), }, + { + path: ROUTES.PANTRY_MANAGEMENT, + element: ( + + + + ), + }, ], }, ]); diff --git a/apps/frontend/src/components/dashboardCard.tsx b/apps/frontend/src/components/dashboardCard.tsx index d78201021..cd08c459d 100644 --- a/apps/frontend/src/components/dashboardCard.tsx +++ b/apps/frontend/src/components/dashboardCard.tsx @@ -6,7 +6,7 @@ import { getInitials, ORDER_STATUS_COLORS, DONATION_STATUS_COLORS, - ASSIGNEE_COLORS, + USER_ICON_COLORS, } from '@utils/utils'; import { OrderAssignee, OrderStatus, DonationStatus } from '../types/types'; @@ -192,7 +192,7 @@ const DashboardCard: React.FC = ({ w="30px" h="30px" borderRadius="full" - bg={ASSIGNEE_COLORS[assignee.id % ASSIGNEE_COLORS.length]} + bg={USER_ICON_COLORS[assignee.id % USER_ICON_COLORS.length]} color="white" display="flex" alignItems="center" diff --git a/apps/frontend/src/components/forms/assignVolunteersModal.tsx b/apps/frontend/src/components/forms/assignVolunteersModal.tsx new file mode 100644 index 000000000..a4aeb70ba --- /dev/null +++ b/apps/frontend/src/components/forms/assignVolunteersModal.tsx @@ -0,0 +1,282 @@ +import ApiClient from '@api/apiClient'; +import { + Box, + Button, + Checkbox, + CloseButton, + Dialog, + Flex, + Input, + InputGroup, + Text, + VStack, +} from '@chakra-ui/react'; +import { useAlert } from '../../hooks/alert'; +import { useEffect, useState } from 'react'; +import { ApprovedPantryResponse, Assignments } from 'types/types'; +import { SearchIcon } from 'lucide-react'; +import { getInitials, USER_ICON_COLORS } from '@utils/utils'; +import { FloatingAlert } from '@components/floatingAlert'; +import { useModalBodyCleanup } from '../../hooks/modalBodyCleanup'; + +interface AssignVolunteersModalProps { + pantry: ApprovedPantryResponse; + onSuccess: () => void; + onClose: () => void; + isOpen: boolean; +} + +type VolunteerDisplay = { + userId: number; + firstName: string; + lastName: string; +}; + +const AssignVolunteersModal: React.FC = ({ + pantry, + onSuccess, + onClose, + isOpen, +}) => { + useModalBodyCleanup(); + const [alertState, setAlertMessage] = useAlert(); + + const [volunteers, setVolunteers] = useState([]); + + const [selectedIds, setSelectedIds] = useState>(new Set()); + + const [searchName, setSearchName] = useState(''); + + const handleSearchNameChange = ( + event: React.ChangeEvent, + ) => { + setSearchName(event.target.value); + }; + + useEffect(() => { + if (!isOpen) return; + const fetchVolunteers = async () => { + try { + const allVolunteers: Assignments[] = await ApiClient.getVolunteers(); + + const assignedIds = new Set(pantry.volunteers.map((v) => v.userId)); + + const normalized: VolunteerDisplay[] = allVolunteers.map((v) => ({ + userId: v.id, + firstName: v.firstName, + lastName: v.lastName, + })); + + setVolunteers(normalized); + setSelectedIds(new Set(assignedIds)); + } catch { + setAlertMessage('Error fetching volunteers'); + } + }; + + fetchVolunteers(); + }, [pantry, setAlertMessage, isOpen]); + + const filteredVolunteers = volunteers.filter((v) => { + const fullName = `${v.firstName} ${v.lastName}`.toLowerCase(); + return fullName.includes(searchName.toLowerCase()); + }); + + const handleToggle = (userId: number, checked: boolean) => { + setSelectedIds((prev) => { + const next = new Set(prev); + if (checked) next.add(userId); + else next.delete(userId); + return next; + }); + }; + + const handleSave = async () => { + try { + const originalIds = new Set(pantry.volunteers.map((v) => v.userId)); + + const addVolunteerIds = [...selectedIds].filter( + (id) => !originalIds.has(id), + ); + const removeVolunteerIds = [...originalIds].filter( + (id) => !selectedIds.has(id), + ); + + if (addVolunteerIds.length > 0 || removeVolunteerIds.length > 0) { + await ApiClient.updatePantryVolunteers(pantry.pantryId, { + addVolunteerIds, + removeVolunteerIds, + }); + } + + onSuccess(); + onClose(); + } catch { + setAlertMessage('Error saving volunteer assignments'); + } + }; + + return ( + { + if (!e.open) onClose(); + }} + closeOnInteractOutside + > + {alertState && ( + + )} + + + + + + + + + + Assign Volunteers + + + + + + {pantry.pantryName} + + + + + + } + px={3} + > + + + + + {filteredVolunteers.map((volunteer) => ( + + + + {getInitials( + volunteer.firstName, + volunteer.lastName, + )} + + + + {volunteer.firstName} {volunteer.lastName} + + + + + + handleToggle(volunteer.userId, e.checked) + } + size="md" + > + + + + + + ))} + + {filteredVolunteers.length === 0 && ( + + No volunteers found + + )} + + + + + + + + + + + + ); +}; + +export default AssignVolunteersModal; diff --git a/apps/frontend/src/containers/adminDashboard.tsx b/apps/frontend/src/containers/adminDashboard.tsx index acd7912c3..7ce01914e 100644 --- a/apps/frontend/src/containers/adminDashboard.tsx +++ b/apps/frontend/src/containers/adminDashboard.tsx @@ -15,6 +15,7 @@ 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 AdminDashboard: React.FC = () => { const navigate = useNavigate(); @@ -117,8 +118,14 @@ const AdminDashboard: React.FC = () => { onLinkClick={() => { navigate( application.type === 'pantry' - ? `/pantry-application-details/${application.id}` - : `/food-manufacturer-application-details/${application.id}`, + ? ROUTES.PANTRY_MANAGEMENT_DETAILS.replace( + ':pantryId', + application.id.toString(), + ) + : ROUTES.FOOD_MANUFACTURER_APPLICATION_DETAILS.replace( + ':applicationId', + application.id.toString(), + ), ); }} /> diff --git a/apps/frontend/src/containers/adminOrderManagement.tsx b/apps/frontend/src/containers/adminOrderManagement.tsx index 1cd4896d2..84e09c3f8 100644 --- a/apps/frontend/src/containers/adminOrderManagement.tsx +++ b/apps/frontend/src/containers/adminOrderManagement.tsx @@ -26,7 +26,7 @@ import { formatDate, getInitials, ORDER_STATUS_COLORS, - ASSIGNEE_COLORS, + USER_ICON_COLORS, } from '@utils/utils'; import ApiClient from '@api/apiClient'; import { OrderStatus, OrderSummary } from '../types/types'; @@ -117,7 +117,7 @@ const AdminOrderManagement: React.FC = () => { if (order.assignee) { orderWithColor.assigneeColor = - ASSIGNEE_COLORS[order.assignee.id % ASSIGNEE_COLORS.length]; + USER_ICON_COLORS[order.assignee.id % USER_ICON_COLORS.length]; } grouped[status].push(orderWithColor); diff --git a/apps/frontend/src/containers/adminPantryManagement.tsx b/apps/frontend/src/containers/adminPantryManagement.tsx new file mode 100644 index 000000000..e807b5ce4 --- /dev/null +++ b/apps/frontend/src/containers/adminPantryManagement.tsx @@ -0,0 +1,456 @@ +import { useEffect, useState } from 'react'; +import { + Table, + Text, + Flex, + Input, + VStack, + Box, + Pagination, + ButtonGroup, + IconButton, + Link, + Button, + Checkbox, + Badge, +} from '@chakra-ui/react'; +import { ChevronRight, ChevronLeft, Funnel, Search } from 'lucide-react'; +import { ApprovedPantryResponse } from '../types/types'; +import ApiClient from '@api/apiClient'; +import { FloatingAlert } from '@components/floatingAlert'; +import { useAlert } from '../hooks/alert'; +import { getInitials, USER_ICON_COLORS } from '@utils/utils'; +import { RefrigeratedDonation } from '../types/pantryEnums'; +import AssignVolunteersModal from '@components/forms/assignVolunteersModal'; +import { useNavigate } from 'react-router-dom'; +import { ROUTES } from '../routes'; + +const AdminPantryManagement: React.FC = () => { + const navigate = useNavigate(); + + const [currentPage, setCurrentPage] = useState(1); + const [pantries, setPantries] = useState([]); + + // The pantry searched in the filter + const [searchPantry, setSearchPantry] = useState(''); + + // The pantries selected in the filter + const [selectedPantries, setSelectedPantries] = useState([]); + + const [alertState, setAlertMessage] = useAlert(); + const [isAlertSuccess, setIsAlertSuccess] = useState(false); + const [isFilterOpen, setIsFilterOpen] = useState(false); + const [ + selectedPantryToAssignVolunteers, + setSelectedPantryToAssignVolunteers, + ] = useState(null); + + const pageSize = 10; + + const fetchPantries = async () => { + try { + const allApprovedPantries = await ApiClient.getApprovedPantries(); + setPantries(allApprovedPantries); + } catch { + setIsAlertSuccess(false); + setAlertMessage('Error fetching pantries'); + } + }; + + useEffect(() => { + fetchPantries(); + }, [setAlertMessage]); + + const handleAssignVolunteersSuccess = () => { + setIsAlertSuccess(true); + setAlertMessage('Successfully assigned volunteers'); + fetchPantries(); + }; + + useEffect(() => { + setCurrentPage(1); + }, [selectedPantries]); + + const pantryOptions = [...new Set(pantries.map((p) => p.pantryName))].sort( + (a, b) => a.localeCompare(b), + ); + + const handleFilterChange = (pantry: string, checked: boolean) => { + if (checked) { + setSelectedPantries([...selectedPantries, pantry]); + } else { + setSelectedPantries(selectedPantries.filter((p) => p !== pantry)); + } + }; + + const filteredPantries = pantries.filter((p) => { + const matchesFilter = + selectedPantries.length === 0 || selectedPantries.includes(p.pantryName); + return matchesFilter; + }); + + const paginatedPantries = filteredPantries.slice( + (currentPage - 1) * pageSize, + currentPage * pageSize, + ); + + const textHeaderStyles = { + color: 'neutral.800', + textStyle: 'p2', + fontWeight: '600', + fontFamily: 'inter', + }; + + return ( + + + Pantry Management + + {alertState && ( + + )} + + + + + + {isFilterOpen && ( + <> + setIsFilterOpen(false)} + zIndex={10} + /> + + + + setSearchPantry(e.target.value)} + fontSize="sm" + pl="30px" + border="none" + bg="transparent" + _focus={{ + boxShadow: 'none', + border: 'none', + outline: 'none', + }} + /> + + + {pantryOptions + .filter((pantry) => + pantry + .toLowerCase() + .includes(searchPantry.toLowerCase()), + ) + .map((pantry) => ( + + handleFilterChange(pantry, e.checked) + } + color="gray.dark" + size="md" + > + + + {pantry} + + ))} + + + + )} + + + + + + + Pantry + + + Assignee + + + Refrigerator-Friendly + + + Action + + + + + {paginatedPantries?.map((pantry) => ( + + + + navigate( + ROUTES.PANTRY_MANAGEMENT_DETAILS.replace( + ':pantryId', + pantry.pantryId.toString(), + ), + ) + } + > + {pantry.pantryName} + + + setSelectedPantryToAssignVolunteers(pantry)} + cursor="pointer" + _hover={{ bg: 'gray.50' }} + > + + {pantry.volunteers && pantry.volunteers.length > 0 ? ( + (() => { + const volunteers = pantry.volunteers; + const maxVisible = 3; + + const hasOverflow = volunteers.length > maxVisible; + const visibleVolunteers = hasOverflow + ? volunteers.slice(0, maxVisible - 1) + : volunteers; + + const remainingCount = + volunteers.length - (maxVisible - 1); + + return ( + <> + {visibleVolunteers.map((volunteer, index) => ( + + {getInitials( + volunteer.firstName, + volunteer.lastName, + )} + + ))} + + {hasOverflow && ( + + +{remainingCount} + + )} + + ); + })() + ) : ( + + No Volunteer + + )} + + + + + {pantry.refrigeratedDonation === RefrigeratedDonation.YES + ? 'Refrigerator-Friendly' + : 'Not Refrigerator-Friendly'} + + + + + View Orders + + + + ))} + {selectedPantryToAssignVolunteers && ( + setSelectedPantryToAssignVolunteers(null)} + onSuccess={handleAssignVolunteersSuccess} + isOpen={true} + /> + )} + + + + setCurrentPage(page)} + > + + + + setCurrentPage((prev) => Math.max(prev - 1, 1)) + } + > + + + + + ( + setCurrentPage(page.value)} + > + {page.value} + + )} + /> + + + + setCurrentPage((prev) => + Math.min( + prev + 1, + Math.ceil(filteredPantries.length / pageSize), + ), + ) + } + > + + + + + + + + + ); +}; + +export default AdminPantryManagement; diff --git a/apps/frontend/src/containers/approvePantries.tsx b/apps/frontend/src/containers/approvePantries.tsx index 20d44cd80..a87e9f792 100644 --- a/apps/frontend/src/containers/approvePantries.tsx +++ b/apps/frontend/src/containers/approvePantries.tsx @@ -23,6 +23,7 @@ import { } from 'lucide-react'; import { useAlert } from '../hooks/alert'; import { FloatingAlert } from '@components/floatingAlert'; +import { ROUTES } from '../routes'; const ApprovePantries: React.FC = () => { const [pantries, setPantries] = useState([]); @@ -317,7 +318,10 @@ const ApprovePantries: React.FC = () => { textStyle="p2" variant="underline" textDecorationColor="neutral.700" - href={`/pantry-application-details/${pantry.pantryId}`} + href={ROUTES.PANTRY_APPLICATION_DETAILS.replace( + ':applicationId', + pantry.pantryId.toString(), + )} > View Details diff --git a/apps/frontend/src/containers/homepage.tsx b/apps/frontend/src/containers/homepage.tsx index 84e5ed310..b782b8173 100644 --- a/apps/frontend/src/containers/homepage.tsx +++ b/apps/frontend/src/containers/homepage.tsx @@ -151,6 +151,13 @@ const Homepage: React.FC = () => { + + + + Pantry Management + + + Dashboard diff --git a/apps/frontend/src/containers/pantryApplicationDetails.tsx b/apps/frontend/src/containers/pantryApplicationDetails.tsx index e9766eeba..5838b7336 100644 --- a/apps/frontend/src/containers/pantryApplicationDetails.tsx +++ b/apps/frontend/src/containers/pantryApplicationDetails.tsx @@ -1,5 +1,5 @@ import React, { useCallback, useEffect, useState } from 'react'; -import { useParams, useNavigate, Link } from 'react-router-dom'; +import { useParams, useNavigate, Link, useMatch } from 'react-router-dom'; import { Box, Grid, @@ -84,7 +84,15 @@ const EmptyState: React.FC = ({ }; const PantryApplicationDetails: React.FC = () => { - const { applicationId } = useParams<{ applicationId: string }>(); + const { applicationId, pantryId } = useParams<{ + applicationId?: string; + pantryId?: string; + }>(); + + const id = applicationId ?? pantryId; + + const isApplicationMode = useMatch(ROUTES.PANTRY_APPLICATION_DETAILS); + const navigate = useNavigate(); const [application, setApplication] = useState(null); const [loading, setLoading] = useState(true); @@ -124,16 +132,16 @@ const PantryApplicationDetails: React.FC = () => { const fetchApplicationDetails = useCallback(async () => { try { setLoading(true); - if (!applicationId) { + if (!id) { setError({ type: 'invalid', message: 'Application ID not provided.' }); return; - } else if (isNaN(parseInt(applicationId, 10))) { + } else if (isNaN(parseInt(id, 10))) { setError({ type: 'invalid', message: 'Application ID is not a number.', }); } - const data = await ApiClient.getPantry(parseInt(applicationId, 10)); + const data = await ApiClient.getPantry(parseInt(id, 10)); if (!data) { setError({ type: 'not_found', @@ -153,7 +161,7 @@ const PantryApplicationDetails: React.FC = () => { } finally { setLoading(false); } - }, [applicationId]); + }, [id]); useEffect(() => { fetchApplicationDetails(); @@ -232,7 +240,7 @@ const PantryApplicationDetails: React.FC = () => { - Application Details + {isApplicationMode ? 'Application Details' : 'Pantry Details'} {alertState && ( @@ -254,15 +262,23 @@ const PantryApplicationDetails: React.FC = () => { > - - Application #{application.pantryId} - - - {application.pantryName} - - - Applied {formatDate(application.dateApplied)} - + {isApplicationMode ? ( + <> + + Application #{application.pantryId} + + + {application.pantryName} + + + Applied {formatDate(application.dateApplied)} + + + ) : ( + + {application.pantryName} + + )} @@ -433,48 +449,50 @@ const PantryApplicationDetails: React.FC = () => { - - - - - setShowApproveModal(false)} - onConfirm={handleApprove} - decision="approve" - pantryName={application.pantryName} - dateApplied={formatDate(application.dateApplied)} - /> - - setShowDenyModal(false)} - onConfirm={handleDeny} - decision="deny" - pantryName={application.pantryName} - dateApplied={formatDate(application.dateApplied)} - /> - + {isApplicationMode && ( + + + + + setShowApproveModal(false)} + onConfirm={handleApprove} + decision="approve" + pantryName={application.pantryName} + dateApplied={formatDate(application.dateApplied)} + /> + + setShowDenyModal(false)} + onConfirm={handleDeny} + decision="deny" + pantryName={application.pantryName} + dateApplied={formatDate(application.dateApplied)} + /> + + )} diff --git a/apps/frontend/src/containers/volunteerManagement.tsx b/apps/frontend/src/containers/volunteerManagement.tsx index 20ee2137f..683298da5 100644 --- a/apps/frontend/src/containers/volunteerManagement.tsx +++ b/apps/frontend/src/containers/volunteerManagement.tsx @@ -18,7 +18,7 @@ import ApiClient from '@api/apiClient'; import NewVolunteerModal from '@components/forms/addNewVolunteerModal'; import { FloatingAlert } from '@components/floatingAlert'; import { useAlert } from '../hooks/alert'; -import { getInitials } from '@utils/utils'; +import { getInitials, USER_ICON_COLORS } from '@utils/utils'; const VolunteerManagement: React.FC = () => { const [currentPage, setCurrentPage] = useState(1); @@ -30,8 +30,6 @@ const VolunteerManagement: React.FC = () => { const pageSize = 8; - const USER_ICON_COLORS = ['#F89E19', '#CC3538', '#2795A5', '#2B4E60']; - useEffect(() => { const fetchVolunteers = async () => { try { diff --git a/apps/frontend/src/containers/volunteerOrderManagement.tsx b/apps/frontend/src/containers/volunteerOrderManagement.tsx index 813f73cdb..f25f453fe 100644 --- a/apps/frontend/src/containers/volunteerOrderManagement.tsx +++ b/apps/frontend/src/containers/volunteerOrderManagement.tsx @@ -27,7 +27,7 @@ import { formatDate, getInitials, ORDER_STATUS_COLORS, - ASSIGNEE_COLORS, + USER_ICON_COLORS, } from '@utils/utils'; import ApiClient from '@api/apiClient'; import { @@ -142,7 +142,7 @@ const VolunteerOrderManagement: React.FC = () => { if (order.assignee) { orderWithColor.assigneeColor = - ASSIGNEE_COLORS[order.assignee.id % ASSIGNEE_COLORS.length]; + USER_ICON_COLORS[order.assignee.id % USER_ICON_COLORS.length]; } grouped[status].push(orderWithColor); diff --git a/apps/frontend/src/routes.ts b/apps/frontend/src/routes.ts index 19da091e0..671070660 100644 --- a/apps/frontend/src/routes.ts +++ b/apps/frontend/src/routes.ts @@ -12,7 +12,8 @@ export const ROUTES = { FOOD_MANUFACTURER_APPLICATION: '/food-manufacturer-application', APPLICATION_SUBMITTED: '/application-submitted', - PANTRY_APPLICATION_DETAILS: '/pantry-application-details/:applicationId', + PANTRY_APPLICATION_DETAILS: '/pantry-details/application/:applicationId', + PANTRY_MANAGEMENT_DETAILS: '/pantry-details/pantry/:pantryId', FOOD_MANUFACTURER_APPLICATION_DETAILS: '/food-manufacturer-application-details/:applicationId', diff --git a/apps/frontend/src/types/types.ts b/apps/frontend/src/types/types.ts index ce6696e28..37d464890 100644 --- a/apps/frontend/src/types/types.ts +++ b/apps/frontend/src/types/types.ts @@ -66,6 +66,11 @@ export interface ConfirmDeliveryDto { feedback?: string; } +export interface UpdatePantryVolunteersDto { + addVolunteerIds?: number[]; + removeVolunteerIds?: number[]; +} + export interface PantryWithUser extends Pantry { pantryUser: User; } @@ -437,6 +442,21 @@ export interface ManufacturerApplicationDto { newsletterSubscription?: boolean; } +export interface ApprovedPantryResponse { + pantryId: number; + pantryName: string; + refrigeratedDonation: RefrigeratedDonation; + volunteers: AssignedVolunteer[]; +} + +export interface AssignedVolunteer { + userId: number; + firstName: string; + lastName: string; + email: string; + phone: string; +} + export interface CreateFoodRequestBody { pantryId: number; requestedSize: RequestSize; diff --git a/apps/frontend/src/utils/utils.ts b/apps/frontend/src/utils/utils.ts index 61d80dd4c..10fbd4f4e 100644 --- a/apps/frontend/src/utils/utils.ts +++ b/apps/frontend/src/utils/utils.ts @@ -105,7 +105,7 @@ export const generateNextDonationDate = ( export const getInitials = (first: string, last: string) => `${first[0] ?? ''}${last[0] ?? ''}`.toUpperCase(); -export const ASSIGNEE_COLORS = ['yellow.core', 'red', 'teal.ssf', 'blue.ssf']; +export const USER_ICON_COLORS = ['yellow.core', 'red', 'teal.ssf', 'blue.core']; export const isValidUrl = (url: string): boolean => { try {