diff --git a/apps/backend/src/foodManufacturers/manufacturers.service.ts b/apps/backend/src/foodManufacturers/manufacturers.service.ts index ee4b49ac..3c79eff4 100644 --- a/apps/backend/src/foodManufacturers/manufacturers.service.ts +++ b/apps/backend/src/foodManufacturers/manufacturers.service.ts @@ -28,6 +28,7 @@ export class FoodManufacturersService { const foodManufacturer = await this.repo.findOne({ where: { foodManufacturerId }, + relations: ['foodManufacturerRepresentative'], }); if (!foodManufacturer) { @@ -121,6 +122,7 @@ export class FoodManufacturersService { const foodManufacturer = await this.repo.findOne({ where: { foodManufacturerId: id }, + relations: ['foodManufacturerRepresentative'], }); if (!foodManufacturer) { throw new NotFoundException(`Food Manufacturer ${id} not found`); @@ -147,6 +149,7 @@ export class FoodManufacturersService { const foodManufacturer = await this.repo.findOne({ where: { foodManufacturerId: id }, + relations: ['foodManufacturerRepresentative'], }); if (!foodManufacturer) { throw new NotFoundException(`Food Manufacturer ${id} not found`); diff --git a/apps/frontend/src/api/apiClient.ts b/apps/frontend/src/api/apiClient.ts index 37e4ed11..79e32ee0 100644 --- a/apps/frontend/src/api/apiClient.ts +++ b/apps/frontend/src/api/apiClient.ts @@ -163,6 +163,20 @@ export class ApiClient { .then((response) => response.data); } + public async getAllPendingFoodManufacturers(): Promise { + return this.axiosInstance + .get('/api/manufacturers/pending') + .then((response) => response.data); + } + + public async getFoodManufacturer( + manufacturerId: number, + ): Promise { + return this.get( + `/api/manufacturers/${manufacturerId}`, + ) as Promise; + } + public async getPantryFromOrder(orderId: number): Promise { return this.axiosInstance .get(`/api/orders/${orderId}/pantry`) @@ -289,6 +303,18 @@ export class ApiClient { }); } + public async updateFoodManufacturer( + manufacturerId: number, + decision: 'approve' | 'deny', + ): Promise { + await this.axiosInstance.patch( + `/api/manufacturers/${manufacturerId}/${decision}`, + { + manufacturerId, + }, + ); + } + public async getPantryRequests(pantryId: number): Promise { const data = await this.get(`/api/requests/${pantryId}/all`); return data as FoodRequest[]; diff --git a/apps/frontend/src/app.tsx b/apps/frontend/src/app.tsx index d679ca6e..da5a3666 100644 --- a/apps/frontend/src/app.tsx +++ b/apps/frontend/src/app.tsx @@ -29,6 +29,8 @@ import { Authenticator } from '@aws-amplify/ui-react'; import FoodManufacturerApplication from '@containers/foodManufacturerApplication'; import { submitManufacturerApplicationForm } from '@components/forms/manufacturerApplicationForm'; import AssignedPantries from '@containers/volunteerAssignedPantries'; +import ApproveFoodManufacturers from '@containers/approveFoodManufacturers'; +import FoodManufacturerApplicationDetails from '@containers/foodManufacturerApplicationDetails'; Amplify.configure(CognitoAuthConfig); @@ -154,6 +156,14 @@ const router = createBrowserRouter([ ), }, + { + path: '/approve-food-manufacturers', + element: ( + + + + ), + }, { path: '/pantry-application-details/:applicationId', element: ( @@ -162,6 +172,14 @@ const router = createBrowserRouter([ ), }, + { + path: '/food-manufacturer-application-details/:applicationId', + element: ( + + + + ), + }, { path: '/admin-donation', element: ( diff --git a/apps/frontend/src/components/forms/confirmFoodManufacturerDecisionModal.tsx b/apps/frontend/src/components/forms/confirmFoodManufacturerDecisionModal.tsx new file mode 100644 index 00000000..6d734825 --- /dev/null +++ b/apps/frontend/src/components/forms/confirmFoodManufacturerDecisionModal.tsx @@ -0,0 +1,90 @@ +import { Dialog, Text, Box, Button, CloseButton } from '@chakra-ui/react'; +import { capitalize } from '@utils/utils'; + +interface ConfirmFoodManufacturerDecisionModalProps { + isOpen: boolean; + onClose: () => void; + onConfirm: () => void; + decision: string; + foodManufacturerName: string; + dateApplied: string; +} + +const ConfirmFoodManufacturerDecisionModal: React.FC< + ConfirmFoodManufacturerDecisionModalProps +> = ({ + isOpen, + onClose, + onConfirm, + decision, + foodManufacturerName, + dateApplied, +}) => { + return ( + !e.open && onClose()} + > + + + + + + Confirm Action + + + + + + Are you sure you want to {decision} this application? + + + + {foodManufacturerName} + + Applied {dateApplied} + + + + + + + + + + + + + + + ); +}; + +export default ConfirmFoodManufacturerDecisionModal; diff --git a/apps/frontend/src/containers/approveFoodManufacturers.tsx b/apps/frontend/src/containers/approveFoodManufacturers.tsx new file mode 100644 index 00000000..40de36a1 --- /dev/null +++ b/apps/frontend/src/containers/approveFoodManufacturers.tsx @@ -0,0 +1,387 @@ +import React, { useEffect, useState } from 'react'; +import { useSearchParams } from 'react-router-dom'; +import { + Table, + Button, + Box, + Heading, + VStack, + Checkbox, + Pagination, + ButtonGroup, + IconButton, + Link, +} from '@chakra-ui/react'; +import ApiClient from '@api/apiClient'; +import { FoodManufacturer } from 'types/types'; +import { + ArrowDownUp, + ChevronLeft, + ChevronRight, + CircleCheck, + Funnel, +} from 'lucide-react'; +import { useAlert } from '../hooks/alert'; +import { FloatingAlert } from '@components/floatingAlert'; + +const ApproveFoodManufacturers: React.FC = () => { + const [foodManufacturers, setFoodManufacturers] = useState< + FoodManufacturer[] + >([]); + const [sortAsc, setSortAsc] = useState(true); + const [selectedFoodManufacturers, setSelectedFoodManufacturers] = useState< + string[] + >([]); + const [currentPage, setCurrentPage] = useState(1); + const [isFilterOpen, setIsFilterOpen] = useState(false); + const [searchParams, setSearchParams] = useSearchParams(); + const [alertState, setAlertMessage] = useAlert(); + + useEffect(() => { + const fetchFoodManufacturers = async () => { + try { + const data = await ApiClient.getAllPendingFoodManufacturers(); + setFoodManufacturers(data); + } catch { + setAlertMessage('Error fetching food manufacturers'); + } + }; + + fetchFoodManufacturers(); + }, [setAlertMessage]); + + useEffect(() => { + setCurrentPage(1); + }, [selectedFoodManufacturers]); + + const foodManufacturerOptions = [ + ...new Set( + foodManufacturers + .map((fm) => fm.foodManufacturerName) + .filter((name): name is string => !!name), + ), + ].sort((a, b) => a.localeCompare(b)); + + const handleFilterChange = (foodManufacturer: string, checked: boolean) => { + if (checked) { + setSelectedFoodManufacturers([ + ...selectedFoodManufacturers, + foodManufacturer, + ]); + } else { + setSelectedFoodManufacturers( + selectedFoodManufacturers.filter((fm) => fm !== foodManufacturer), + ); + } + }; + + const filteredFoodManufacturers = foodManufacturers + .filter((fm) => { + const matchesFilter = + selectedFoodManufacturers.length === 0 || + selectedFoodManufacturers.includes(fm.foodManufacturerName); + return matchesFilter; + }) + .sort((a, b) => + sortAsc + ? new Date(a.dateApplied).getTime() - new Date(b.dateApplied).getTime() + : new Date(b.dateApplied).getTime() - new Date(a.dateApplied).getTime(), + ); + + const itemsPerPage = 10; + const totalPages = Math.ceil(filteredFoodManufacturers.length / itemsPerPage); + const paginatedFoodManufacturers = filteredFoodManufacturers.slice( + (currentPage - 1) * itemsPerPage, + currentPage * itemsPerPage, + ); + + const tableHeaderStyles = { + borderBottom: '1px solid', + borderColor: 'neutral.100', + color: 'neutral.800', + fontFamily: 'inter', + fontWeight: '600', + fontSize: 'sm', + }; + + useEffect(() => { + const action = searchParams.get('action'); + const name = searchParams.get('name'); + + if (action && name) { + const message = + action === 'approved' + ? `${name} - Application Accepted` + : `${name} - Application Rejected`; + + setAlertMessage(message); + setSearchParams({}); + } + }, [searchParams, setSearchParams, setAlertMessage]); + + return ( + + + Application Review + + {alertState && ( + + )} + {filteredFoodManufacturers.length === 0 ? ( + + + + + + No Applications + + + There are no applications to review at this time + + + ) : ( + + + + + + {isFilterOpen && ( + <> + setIsFilterOpen(false)} + zIndex={10} + /> + + + {foodManufacturerOptions.map((foodManufacturer) => ( + + handleFilterChange(foodManufacturer, e.checked) + } + color="black" + size="sm" + > + + + {foodManufacturer} + + ))} + + + + )} + + + + + + + + Application # + + + Food Manufacturer + + + Date Applied + + + Actions + + + + + {paginatedFoodManufacturers.map((foodManufacturer, index) => ( + + + {foodManufacturer.foodManufacturerId} + + + {foodManufacturer.foodManufacturerName} + + + {new Date(foodManufacturer.dateApplied).toLocaleDateString( + 'en-US', + { + month: '2-digit', + day: '2-digit', + year: 'numeric', + }, + )} + + + + View Details + + + + ))} + + + + {totalPages > 1 && ( + setCurrentPage(e.page)} + > + + + + + + ( + + {page.value} + + )} + /> + + + + + + + )} + + )} + + ); +}; + +export default ApproveFoodManufacturers; diff --git a/apps/frontend/src/containers/foodManufacturerApplicationDetails.tsx b/apps/frontend/src/containers/foodManufacturerApplicationDetails.tsx new file mode 100644 index 00000000..5147e3e3 --- /dev/null +++ b/apps/frontend/src/containers/foodManufacturerApplicationDetails.tsx @@ -0,0 +1,471 @@ +import React, { useCallback, useEffect, useState } from 'react'; +import { useParams, useNavigate, Link } from 'react-router-dom'; +import { + Box, + Grid, + GridItem, + Text, + Button, + Heading, + VStack, + HStack, + Spinner, +} from '@chakra-ui/react'; +import ApiClient from '@api/apiClient'; +import { FoodManufacturer } from 'types/types'; +import { formatDate, formatPhone } from '@utils/utils'; +import { TagGroup } from '@components/forms/tagGroup'; +import { FileX, TriangleAlert, WifiOff } from 'lucide-react'; +import { AxiosError } from 'axios'; +import { FloatingAlert } from '@components/floatingAlert'; +import { useAlert } from '../hooks/alert'; +import ConfirmFoodManufacturerDecisionModal from '@components/forms/confirmFoodManufacturerDecisionModal'; + +interface EmptyStateProps { + icon: React.ReactNode; + title: string; + subtitle?: string; + isLoading?: boolean; +} + +const EmptyState: React.FC = ({ + icon, + title, + subtitle, + isLoading = false, +}) => { + return ( + + + + Application Details + + + + {icon} + + {title} + + {subtitle && ( + + {subtitle} + + )} + {!isLoading && ( + + )} + + + + ); +}; + +const FoodManufacturerApplicationDetails: React.FC = () => { + const { applicationId } = useParams<{ applicationId: string }>(); + const navigate = useNavigate(); + const [application, setApplication] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState<{ + type: 'network' | 'not_found' | 'invalid' | null; + message: string; + }>({ + type: null, + message: '', + }); + const [alertState, setAlertMessage] = useAlert(); + const [showApproveModal, setShowApproveModal] = useState(false); + const [showDenyModal, setShowDenyModal] = useState(false); + + const fieldContentStyles = { + textStyle: 'p2', + color: 'gray.light', + lineHeight: '1.2', + }; + + const headerStyles = { + textStyle: 'p2', + color: 'neutral.800', + }; + + const sectionHeaderStyles = { + ...headerStyles, + fontWeight: 600, + }; + + const fieldHeaderStyles = { + ...headerStyles, + fontWeight: 500, + mb: 1, + }; + + const fetchApplicationDetails = useCallback(async () => { + try { + setLoading(true); + if (!applicationId) { + setError({ type: 'invalid', message: 'Application ID not provided.' }); + return; + } else if (isNaN(parseInt(applicationId, 10))) { + setError({ + type: 'invalid', + message: 'Application ID is not a number.', + }); + } + const data = await ApiClient.getFoodManufacturer( + parseInt(applicationId, 10), + ); + if (!data) { + setError({ + type: 'not_found', + message: 'Application not found.', + }); + } + setApplication(data); + } catch (err: unknown) { + if (err instanceof AxiosError) { + if (err.response?.status !== 404 && err.response?.status !== 400) { + setError({ + type: 'network', + message: 'Could not load application details.', + }); + } + } + } finally { + setLoading(false); + } + }, [applicationId]); + + useEffect(() => { + fetchApplicationDetails(); + }, [fetchApplicationDetails]); + + const handleApprove = async () => { + if (application) { + try { + await ApiClient.updateFoodManufacturer( + application.foodManufacturerId, + 'approve', + ); + navigate( + '/approve-food-manufacturers?action=approved&name=' + + application.foodManufacturerName, + ); + } catch { + setAlertMessage('Error approving application'); + } + } + }; + + const handleDeny = async () => { + if (application) { + try { + await ApiClient.updateFoodManufacturer( + application.foodManufacturerId, + 'deny', + ); + navigate( + '/approve-food-manufacturers?action=denied&name=' + + application.foodManufacturerName, + ); + } catch { + setAlertMessage('Error denying application'); + } + } + }; + + if (loading) { + return ( + } + title="Loading application details..." + isLoading={true} + /> + ); + } + + if (error.message || !application) { + const getIcon = () => { + switch (error.type) { + case 'network': + return ; + case 'not_found': + return ; + default: + return ; + } + }; + + return ( + + ); + } + + const rep = application.foodManufacturerRepresentative; + const hasSecondaryContact = + application.secondaryContactFirstName || + application.secondaryContactLastName || + application.secondaryContactEmail || + application.secondaryContactPhone; + + return ( + + + + Application Details + + + {alertState && ( + + )} + + + + {/* Header */} + + + Application #{application.foodManufacturerId} + + + {application.foodManufacturerName} + + + Applied {formatDate(application.dateApplied)} + + + + {/* Point of Contact Information */} + + + Point of Contact Information + + + + Primary Representative + + {rep.firstName} {rep.lastName} + + {formatPhone(rep.phone)} + {rep.email} + + + {hasSecondaryContact && ( + + Secondary Contact + {(application.secondaryContactFirstName || + application.secondaryContactLastName) && ( + + {application.secondaryContactFirstName}{' '} + {application.secondaryContactLastName} + + )} + {application.secondaryContactPhone && ( + + {formatPhone(application.secondaryContactPhone)} + + )} + {application.secondaryContactEmail && ( + + {application.secondaryContactEmail} + + )} + + )} + + + + {/* Food Manufacturer Details */} + + + Food Manufacturer Details + + + + + Name + + {application.foodManufacturerName} + + + + Website + + {application.foodManufacturerWebsite} + + + {application.manufacturerAttribute && ( + + Manufacturer Attribute + + {application.manufacturerAttribute} + + + )} + + + + Unlisted Product Allergens + {application.unlistedProductAllergens.length > 0 ? ( + + ) : ( + None + )} + + + + + Allergens Facility is Free Of + + {application.facilityFreeAllergens.length > 0 ? ( + + ) : ( + None + )} + + + + + + Products are Gluten-Free? + + + {application.productsGlutenFree ? 'Yes' : 'No'} + + + + + Products Contain Sulfites? + + + {application.productsContainSulfites ? 'Yes' : 'No'} + + + + In-Kind Donations? + + {application.inKindDonations ? 'Yes' : 'No'} + + + + Donate Wasted Food? + + {application.donateWastedFood} + + + + + + + Sustainable Products Explanation + + + {application.productsSustainableExplanation || '-'} + + + + + Additional Comments + + {application.additionalComments || '-'} + + + + + Subscribed to Newsletter + + {application.newsletterSubscription ? 'Yes' : 'No'} + + + + + + + + + + setShowApproveModal(false)} + onConfirm={handleApprove} + decision="approve" + foodManufacturerName={application.foodManufacturerName} + dateApplied={formatDate(application.dateApplied)} + /> + + setShowDenyModal(false)} + onConfirm={handleDeny} + decision="deny" + foodManufacturerName={application.foodManufacturerName} + dateApplied={formatDate(application.dateApplied)} + /> + + + + + + ); +}; + +export default FoodManufacturerApplicationDetails; diff --git a/apps/frontend/src/containers/homepage.tsx b/apps/frontend/src/containers/homepage.tsx index d37eee02..be6f760f 100644 --- a/apps/frontend/src/containers/homepage.tsx +++ b/apps/frontend/src/containers/homepage.tsx @@ -112,6 +112,13 @@ const Homepage: React.FC = () => { Approve Pantries + + + + Approve Food Manufacturers + + + All Pantries diff --git a/apps/frontend/src/types/types.ts b/apps/frontend/src/types/types.ts index 40a9ff21..2f548747 100644 --- a/apps/frontend/src/types/types.ts +++ b/apps/frontend/src/types/types.ts @@ -234,7 +234,25 @@ export interface OrderDetails { export interface FoodManufacturer { foodManufacturerId: number; foodManufacturerName: string; - foodManufacturerRepresentative?: User; + foodManufacturerWebsite: string; + foodManufacturerRepresentative: User; + secondaryContactFirstName?: string; + secondaryContactLastName?: string; + secondaryContactEmail?: string; + secondaryContactPhone?: string; + unlistedProductAllergens: Allergen[]; + facilityFreeAllergens: Allergen[]; + productsGlutenFree: boolean; + productsContainSulfites: boolean; + productsSustainableExplanation: string; + inKindDonations: boolean; + donateWastedFood: DonateWastedFood; + manufacturerAttribute?: ManufacturerAttribute; + additionalComments?: string; + newsletterSubscription: boolean; + donations: Donation[]; + status: ApplicationStatus; + dateApplied: string; } export interface ManufacturerApplicationDto {