diff --git a/apps/frontend/src/api/apiClient.ts b/apps/frontend/src/api/apiClient.ts index ffed2ae0..f660b753 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, } from 'types/types'; @@ -163,6 +165,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 +415,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 5a7efaa9..8e0cf9ef 100644 --- a/apps/frontend/src/app.tsx +++ b/apps/frontend/src/app.tsx @@ -31,8 +31,9 @@ import VolunteerRequestManagement from '@containers/volunteerRequestManagement'; import AdminDonationStats from '@containers/adminDonationStats'; import ProfilePage from '@containers/profilePage'; import VolunteerOrderManagement from '@containers/volunteerOrderManagement'; -import TestAdminDashboard from '@containers/testAdminDashboard'; +import AdminPantryManagement from '@containers/adminPantryManagement'; import AdminRequestManagement from '@containers/adminRequestManagement'; +import AdminDashboard from '@containers/testAdminDashboard'; Amplify.configure(CognitoAuthConfig); @@ -153,7 +154,7 @@ const router = createBrowserRouter([ path: ROUTES.TEST_ADMIN_DASHBOARD, element: ( - + ), }, @@ -213,6 +214,14 @@ const router = createBrowserRouter([ ), }, + { + path: ROUTES.ADMIN_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 00000000..21d629c4 --- /dev/null +++ b/apps/frontend/src/components/forms/assignVolunteersModal.tsx @@ -0,0 +1,286 @@ +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 [assignedVolunteers, setAssignedVolunteers] = useState< + VolunteerDisplay[] + >([]); + const [unassignedVolunteers, setUnassignedVolunteers] = useState< + VolunteerDisplay[] + >([]); + + 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, + })); + + const assigned = normalized.filter((v) => assignedIds.has(v.userId)); + + const unassigned = normalized.filter((v) => !assignedIds.has(v.userId)); + + setAssignedVolunteers(assigned); + setUnassignedVolunteers(unassigned); + setSelectedIds(new Set(pantry.volunteers.map((v) => v.userId))); + } catch { + setAlertMessage('Error fetching volunteers'); + } + }; + + fetchVolunteers(); + }, [pantry, setAlertMessage]); + + const allVolunteers = [...assignedVolunteers, ...unassignedVolunteers]; + + const filteredVolunteers = allVolunteers.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/adminPantryManagement.tsx b/apps/frontend/src/containers/adminPantryManagement.tsx new file mode 100644 index 00000000..b95f9781 --- /dev/null +++ b/apps/frontend/src/containers/adminPantryManagement.tsx @@ -0,0 +1,442 @@ +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'; + +const AdminPantryManagement: React.FC = () => { + 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) => ( + + + setSelectedPantryToAssignVolunteers(pantry)} + > + {pantry.pantryName} + + + + + {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/homepage.tsx b/apps/frontend/src/containers/homepage.tsx index 26750cb0..6c5861fd 100644 --- a/apps/frontend/src/containers/homepage.tsx +++ b/apps/frontend/src/containers/homepage.tsx @@ -156,6 +156,13 @@ const Homepage: React.FC = () => { + + + + Pantry Management + + + Dashboard diff --git a/apps/frontend/src/containers/volunteerManagement.tsx b/apps/frontend/src/containers/volunteerManagement.tsx index c48c92fd..bbf61740 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 976befab..6ab5162a 100644 --- a/apps/frontend/src/routes.ts +++ b/apps/frontend/src/routes.ts @@ -26,6 +26,7 @@ export const ROUTES = { ADMIN_DONATION_STATS: '/admin-donation-stats', ADMIN_REQUEST_MANAGEMENT: '/admin-request-management', TEST_ADMIN_DASHBOARD: '/test-admin-dashboard', + ADMIN_PANTRY_MANAGEMENT: '/admin-pantry-management', VOLUNTEER_ASSIGNED_PANTRIES: '/volunteer-assigned-pantries', VOLUNTEER_REQUEST_MANAGEMENT: '/volunteer-request-management', diff --git a/apps/frontend/src/types/types.ts b/apps/frontend/src/types/types.ts index 20318879..46f939f0 100644 --- a/apps/frontend/src/types/types.ts +++ b/apps/frontend/src/types/types.ts @@ -59,6 +59,11 @@ export interface ConfirmDeliveryDto { feedback?: string; } +export interface UpdatePantryVolunteersDto { + addVolunteerIds?: number[]; + removeVolunteerIds?: number[]; +} + export interface PantryWithUser extends Pantry { pantryUser: User; } @@ -422,6 +427,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 4fa59c2b..40054e0a 100644 --- a/apps/frontend/src/utils/utils.ts +++ b/apps/frontend/src/utils/utils.ts @@ -24,6 +24,8 @@ export const DONATION_STATUS_COLORS: Record = [DonationStatus.FULFILLED]: TEAL_STATUS, }; +export const USER_ICON_COLORS = ['yellow.core', 'red', 'teal.ssf', 'blue.core']; + export const formatPhone = (phone?: string | null) => { if (!phone) return null; let digits = phone.replace(/\D/g, '');