diff --git a/apps/frontend/src/api/apiClient.ts b/apps/frontend/src/api/apiClient.ts index 9820681ec..aaa88069a 100644 --- a/apps/frontend/src/api/apiClient.ts +++ b/apps/frontend/src/api/apiClient.ts @@ -39,6 +39,8 @@ import { DonationDetails, VolunteerOrder, VolunteerAction, + ApprovedPantryResponse, + UpdatePantryVolunteersDto, FoodRequestWithoutRelations, PendingApplication, } from 'types/types'; @@ -164,6 +166,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`) @@ -414,6 +422,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/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..58edbfaeb 100644 --- a/apps/frontend/src/containers/adminDashboard.tsx +++ b/apps/frontend/src/containers/adminDashboard.tsx @@ -117,7 +117,7 @@ const AdminDashboard: React.FC = () => { onLinkClick={() => { navigate( application.type === 'pantry' - ? `/pantry-application-details/${application.id}` + ? `/pantry-details/pantry/${application.id}` : `/food-manufacturer-application-details/${application.id}`, ); }} diff --git a/apps/frontend/src/containers/adminPantryManagement.tsx b/apps/frontend/src/containers/adminPantryManagement.tsx new file mode 100644 index 000000000..e088bcdfe --- /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([]); + const [searchPantry, setSearchPantry] = useState(''); + + const [selectedPantries, setSelectedPantries] = useState([]); + + const [alertState, setAlertMessage] = useAlert(); + const [submitSuccess, setSubmitSuccess] = 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 { + setSubmitSuccess(false); + + setAlertMessage('Error fetching pantries'); + } + }; + + useEffect(() => { + fetchPantries(); + }, [setAlertMessage]); + + const handleAssignVolunteersSuccess = () => { + setSubmitSuccess(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( + ':applicationId', + 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 3d1bdf80e..a339aea8f 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([]); @@ -308,7 +309,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..2db75ba7d 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, @@ -85,6 +85,9 @@ const EmptyState: React.FC = ({ const PantryApplicationDetails: React.FC = () => { const { applicationId } = useParams<{ applicationId: string }>(); + + const isApplicationMode = useMatch(ROUTES.PANTRY_APPLICATION_DETAILS); + const navigate = useNavigate(); const [application, setApplication] = useState(null); const [loading, setLoading] = useState(true); @@ -232,7 +235,7 @@ const PantryApplicationDetails: React.FC = () => { - Application Details + {isApplicationMode ? 'Application Details' : 'Pantry Details'} {alertState && ( @@ -433,48 +436,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 c48c92fd9..bbf61740d 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/routes.ts b/apps/frontend/src/routes.ts index 19da091e0..254a1b783 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/:applicationId', 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 2a15a0584..1bfd9f442 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; } @@ -429,6 +434,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 4fa59c2b5..8232f24ea 100644 --- a/apps/frontend/src/utils/utils.ts +++ b/apps/frontend/src/utils/utils.ts @@ -106,3 +106,7 @@ 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 = ASSIGNEE_COLORS.map((color) => + color === 'blue.ssf' ? 'blue.core' : color, +);