diff --git a/apps/backend/src/orders/order.controller.ts b/apps/backend/src/orders/order.controller.ts index a43649640..972973f81 100644 --- a/apps/backend/src/orders/order.controller.ts +++ b/apps/backend/src/orders/order.controller.ts @@ -31,7 +31,6 @@ import { FilesInterceptor } from '@nestjs/platform-express'; import * as multer from 'multer'; import { ConfirmDeliveryDto } from './dtos/confirm-delivery.dto'; import { CompleteVolunteerActionDto } from './dtos/complete-volunteer-action.dto'; -import { FoodRequest } from '../foodRequests/request.entity'; import { CreateOrderDto } from './dtos/create-order.dto'; import { AuthenticatedRequest } from '../auth/authenticated-request'; import { Roles } from '../auth/roles.decorator'; @@ -82,8 +81,8 @@ export class OrdersController { resolver: async ({ entityId, services }) => { return pipeNullable( () => services.get(OrdersService).findOrderFoodRequest(entityId), - (request: FoodRequest) => - services.get(PantriesService).findOne(request.pantryId), + (request: FoodRequestSummaryDto) => + services.get(PantriesService).findOne(request.pantry.pantryId), (pantry: Pantry) => [pantry.pantryUser.id], ); }, diff --git a/apps/backend/src/orders/order.service.spec.ts b/apps/backend/src/orders/order.service.spec.ts index 92d37acc3..3101964de 100644 --- a/apps/backend/src/orders/order.service.spec.ts +++ b/apps/backend/src/orders/order.service.spec.ts @@ -404,44 +404,16 @@ describe('OrdersService', () => { const orders = await service.getOrdersByPantry(pantryId); expect(orders.length).toBe(2); - expect(orders.every((order) => order.request)).toBeDefined(); expect(orders.every((order) => order.request.pantryId === 1)).toBe(true); - expect(orders.every((order) => order.request.pantry)).toBeDefined(); - expect(orders.every((order) => order.assignee)).toBeDefined(); }); - it('returns empty list for pantry with no orderes', async () => { + it('returns empty list for pantry with no orders', async () => { const pantryId = 5; const orders = await service.getOrdersByPantry(pantryId); expect(orders).toEqual([]); }); - it('honors year filter (no results for future year)', async () => { - const pantryId = 1; - const orders = await service.getOrdersByPantry(pantryId, [2025]); - expect(orders).toEqual([]); - }); - - it('returns orders when a valid year filter is provided', async () => { - const pantryId = 1; - - // Change some order dates so we have 2024, 2025 and 2026 values - await testDataSource.query( - `UPDATE "orders" SET created_at='2025-01-01' WHERE order_id = 1`, - ); - await testDataSource.query( - `UPDATE "orders" SET created_at='2026-01-01' WHERE order_id = 2`, - ); - - const orders = await service.getOrdersByPantry(pantryId, [2024, 2025]); - expect(orders.length).toBeGreaterThan(0); - - const years = orders.map((o) => new Date(o.createdAt).getFullYear()); - expect(years).toContain(2025); - expect(years.every((y) => y === 2024 || y === 2025)).toBe(true); - }); - it('throws NotFoundException for non-existent pantry', async () => { const pantryId = 9999; diff --git a/apps/backend/src/orders/order.service.ts b/apps/backend/src/orders/order.service.ts index ff41610fa..51f74dfb8 100644 --- a/apps/backend/src/orders/order.service.ts +++ b/apps/backend/src/orders/order.service.ts @@ -24,6 +24,7 @@ import { DonationItemsService } from '../donationItems/donationItems.service'; import { AllocationsService } from '../allocations/allocations.service'; import { ApplicationStatus } from '../shared/types'; import { VolunteerOrder } from '../volunteers/types'; +import { OrderSummary } from '../pantries/types'; @Injectable() export class OrdersService { @@ -452,10 +453,7 @@ export class OrdersService { return updatedOrder; } - async getOrdersByPantry( - pantryId: number, - years?: number[], - ): Promise { + async getOrdersByPantry(pantryId: number): Promise { validateId(pantryId, 'Pantry'); const pantry = await this.pantryRepo.findOneBy({ pantryId }); @@ -468,18 +466,35 @@ export class OrdersService { .leftJoinAndSelect('order.request', 'request') .leftJoin('request.pantry', 'pantry') .addSelect('pantry.pantryName') - .leftJoinAndSelect('order.allocations', 'allocations') - .leftJoinAndSelect('allocations.item', 'item') .leftJoinAndSelect('order.assignee', 'assignee') .where('request.pantryId = :pantryId', { pantryId }); - if (years && years.length > 0) { - qb.andWhere('EXTRACT(YEAR FROM order.createdAt) IN (:...years)', { - years, - }); - } + const orders = await qb.getMany(); - return qb.getMany(); + return orders.map((order) => ({ + orderId: order.orderId, + status: order.status, + createdAt: order.createdAt.toISOString(), + shippedAt: order.shippedAt?.toISOString() ?? null, + deliveredAt: order.deliveredAt?.toISOString() ?? null, + request: { + pantryId: order.request.pantryId, + pantry: { + pantryName: order.request.pantry.pantryName, + volunteers: + order.request.pantry.volunteers?.map((v) => ({ + id: v.id, + firstName: v.firstName, + lastName: v.lastName, + })) ?? null, + }, + }, + assignee: { + id: order.assignee.id, + firstName: order.assignee.firstName, + lastName: order.assignee.lastName, + }, + })); } async updateTrackingCostInfo(orderId: number, dto: TrackingCostDto) { diff --git a/apps/backend/src/pantries/pantries.controller.spec.ts b/apps/backend/src/pantries/pantries.controller.spec.ts index 5a7233a3c..612038ae4 100644 --- a/apps/backend/src/pantries/pantries.controller.spec.ts +++ b/apps/backend/src/pantries/pantries.controller.spec.ts @@ -5,7 +5,6 @@ import { Pantry } from './pantries.entity'; import { mock } from 'jest-mock-extended'; import { PantryApplicationDto } from './dtos/pantry-application.dto'; import { OrdersService } from '../orders/order.service'; -import { Order } from '../orders/order.entity'; import { Activity, AllergensConfidence, @@ -16,6 +15,7 @@ import { ServeAllergicChildren, ApprovedPantryResponse, TotalStats, + OrderSummary, } from './types'; import { EmailsService } from '../emails/email.service'; import { ApplicationStatus } from '../shared/types'; @@ -359,21 +359,17 @@ describe('PantriesController', () => { it('should return orders for a pantry', async () => { const pantryId = 24; - const mockOrders: Partial[] = [ + const mockOrders: Partial[] = [ { orderId: 26, - requestId: 26, - foodManufacturerId: 32, }, { orderId: 27, - requestId: 27, - foodManufacturerId: 33, }, ]; mockOrdersService.getOrdersByPantry.mockResolvedValue( - mockOrders as Order[], + mockOrders as OrderSummary[], ); const result = await controller.getOrders(pantryId); diff --git a/apps/backend/src/pantries/pantries.controller.ts b/apps/backend/src/pantries/pantries.controller.ts index e7c886a18..b8286b0d4 100644 --- a/apps/backend/src/pantries/pantries.controller.ts +++ b/apps/backend/src/pantries/pantries.controller.ts @@ -27,8 +27,8 @@ import { ServeAllergicChildren, ApprovedPantryResponse, TotalStats, + OrderSummary, } from './types'; -import { Order } from '../orders/order.entity'; import { OrdersService } from '../orders/order.service'; import { CheckOwnership, pipeNullable } from '../auth/ownership.decorator'; import { Public } from '../auth/public.decorator'; @@ -122,7 +122,7 @@ export class PantriesController { @Get('/:pantryId/orders') async getOrders( @Param('pantryId', ParseIntPipe) pantryId: number, - ): Promise { + ): Promise { return this.ordersService.getOrdersByPantry(pantryId); } diff --git a/apps/backend/src/pantries/types.ts b/apps/backend/src/pantries/types.ts index bcc9e4ab8..8b904d331 100644 --- a/apps/backend/src/pantries/types.ts +++ b/apps/backend/src/pantries/types.ts @@ -1,3 +1,5 @@ +import { OrderStatus } from '../orders/types'; + export interface ApprovedPantryResponse { pantryId: number; pantryName: string; @@ -5,6 +7,32 @@ export interface ApprovedPantryResponse { volunteers: AssignedVolunteer[]; } +export interface OrderSummary { + orderId: number; + status: OrderStatus; + createdAt: string; + shippedAt: string | null; + deliveredAt: string | null; + request: { + pantryId: number; + pantry: { + pantryName: string; + volunteers: + | { + id: number; + firstName: string; + lastName: string; + }[] + | null; + }; + }; + assignee: { + id: number; + firstName: string; + lastName: string; + }; +} + export interface AssignedVolunteer { userId: number; firstName: string; diff --git a/apps/backend/src/volunteers/volunteers.controller.spec.ts b/apps/backend/src/volunteers/volunteers.controller.spec.ts index b2868620f..1691952d7 100644 --- a/apps/backend/src/volunteers/volunteers.controller.spec.ts +++ b/apps/backend/src/volunteers/volunteers.controller.spec.ts @@ -214,8 +214,11 @@ describe('VolunteersController', () => { }); }); - describe('GET /:id/my-recent-orders', () => { + describe('GET /me/my-recent-orders', () => { it('returns the 2 most recent orders for a volunteer', async () => { + const req: AuthenticatedRequest = { + user: { id: 6 }, + } as AuthenticatedRequest; const assignee = { id: 6, firstName: 'James', lastName: 'Thomas' }; const recentOrders: Partial[] = [ { @@ -236,7 +239,7 @@ describe('VolunteersController', () => { recentOrders as VolunteerOrder[], ); - const result = await controller.getRecentOrders(6); + const result = await controller.getRecentOrders(req); expect(result).toEqual(recentOrders); expect(result).toHaveLength(2); @@ -244,9 +247,12 @@ describe('VolunteersController', () => { }); it('returns empty array when volunteer has no assigned orders', async () => { + const req: AuthenticatedRequest = { + user: { id: 6 }, + } as AuthenticatedRequest; mockVolunteersService.getRecentOrders.mockResolvedValueOnce([]); - const result = await controller.getRecentOrders(6); + const result = await controller.getRecentOrders(req); expect(result).toEqual([]); expect(mockVolunteersService.getRecentOrders).toHaveBeenCalledWith(6); diff --git a/apps/backend/src/volunteers/volunteers.controller.ts b/apps/backend/src/volunteers/volunteers.controller.ts index ee330b44e..5dc36a729 100644 --- a/apps/backend/src/volunteers/volunteers.controller.ts +++ b/apps/backend/src/volunteers/volunteers.controller.ts @@ -16,7 +16,6 @@ import { Assignments, VolunteerOrder } from './types'; import { AuthenticatedRequest } from '../auth/authenticated-request'; import { OrdersService } from '../orders/order.service'; import { FoodRequestSummaryDto } from '../foodRequests/dtos/food-request-summary.dto'; -import { CheckOwnership } from '../auth/ownership.decorator'; @Controller('volunteers') export class VolunteersController { @@ -44,19 +43,6 @@ export class VolunteersController { return this.volunteersService.findOne(userId); } - @CheckOwnership({ - idParam: 'id', - resolver: async ({ entityId }) => [entityId], - bypassRoles: [Role.ADMIN], - }) - @Roles(Role.VOLUNTEER, Role.ADMIN) - @Get('/:id/my-recent-orders') - async getRecentOrders( - @Param('id', ParseIntPipe) id: number, - ): Promise { - return this.volunteersService.getRecentOrders(id); - } - @Post('/:id/pantries') async assignPantries( @Param('id', ParseIntPipe) id: number, @@ -75,6 +61,14 @@ export class VolunteersController { return this.volunteersService.findRequestsByVolunteer(currentUser.id); } + @Roles(Role.VOLUNTEER) + @Get('/me/my-recent-orders') + async getRecentOrders( + @Req() req: AuthenticatedRequest, + ): Promise { + return this.volunteersService.getRecentOrders(req.user.id); + } + // returns all orders globally // only includes actionCompletion for orders assigned to the requesting volunteer @Roles(Role.VOLUNTEER) diff --git a/apps/frontend/src/api/apiClient.ts b/apps/frontend/src/api/apiClient.ts index d9ca8653b..a3cd19c09 100644 --- a/apps/frontend/src/api/apiClient.ts +++ b/apps/frontend/src/api/apiClient.ts @@ -6,6 +6,7 @@ import axios, { type InternalAxiosRequestConfig, } from 'axios'; import { NavigateFunction } from 'react-router-dom'; +import { ROUTES } from '../routes'; import { User, Order, @@ -24,7 +25,6 @@ import { ConfirmDeliveryDto, OrderWithoutRelations, FoodRequestSummaryDto, - OrderWithoutFoodManufacturer, PantryWithUser, Assignments, PantryStats, @@ -76,9 +76,9 @@ export class ApiClient { (error: AxiosError) => { if (error.response?.status === 403) { if (this.navigate) { - this.navigate('/unauthorized'); + this.navigate(ROUTES.UNAUTHORIZED); } else { - window.location.replace('/unauthorized'); + window.location.replace(ROUTES.UNAUTHORIZED); } } return Promise.reject(error); @@ -169,9 +169,7 @@ export class ApiClient { .then((response) => response.data); } - public async getPantryOrders( - pantryId: number, - ): Promise { + public async getPantryOrders(pantryId: number): Promise { return this.axiosInstance .get(`/api/pantries/${pantryId}/orders`) .then((response) => response.data); @@ -255,6 +253,12 @@ export class ApiClient { .then((response) => response.data); } + public async getVolunteerRecentOrders(): Promise { + return this.axiosInstance + .get(`/api/volunteers/me/my-recent-orders`) + .then((response) => response.data); + } + public async completeOrderAction( orderId: number, action: VolunteerAction, diff --git a/apps/frontend/src/app.tsx b/apps/frontend/src/app.tsx index 5a7efaa9d..3683014a8 100644 --- a/apps/frontend/src/app.tsx +++ b/apps/frontend/src/app.tsx @@ -33,6 +33,8 @@ import ProfilePage from '@containers/profilePage'; import VolunteerOrderManagement from '@containers/volunteerOrderManagement'; import TestAdminDashboard from '@containers/testAdminDashboard'; import AdminRequestManagement from '@containers/adminRequestManagement'; +import PantryDashboard from '@containers/pantryDashboard'; +import VolunteerDashboard from '@containers/volunteerDashboard'; Amplify.configure(CognitoAuthConfig); @@ -85,6 +87,22 @@ const router = createBrowserRouter([ ), }, + { + path: ROUTES.PANTRY_DASHBOARD, + element: ( + + + + ), + }, + { + path: ROUTES.VOLUNTEER_DASHBOARD, + element: ( + + + + ), + }, { path: ROUTES.FM_DONATION_MANAGEMENT, element: ( diff --git a/apps/frontend/src/components/Header.tsx b/apps/frontend/src/components/Header.tsx index d1f76a8a8..8584a968a 100644 --- a/apps/frontend/src/components/Header.tsx +++ b/apps/frontend/src/components/Header.tsx @@ -1,7 +1,7 @@ -import React from 'react'; import SignOutButton from './signOutButton'; import { useAuthenticator } from '@aws-amplify/ui-react'; import { Link as RouterLink } from 'react-router-dom'; +import { ROUTES } from '../routes'; import { Link } from '@chakra-ui/react'; const Header = () => { @@ -10,7 +10,7 @@ const Header = () => { return (
- Securing Safe Food + Securing Safe Food {user && } diff --git a/apps/frontend/src/components/foodRequestManagement.tsx b/apps/frontend/src/components/foodRequestManagement.tsx index 805df96f3..971bc6d09 100644 --- a/apps/frontend/src/components/foodRequestManagement.tsx +++ b/apps/frontend/src/components/foodRequestManagement.tsx @@ -1,4 +1,4 @@ -import React, { useState, useEffect } from 'react'; +import React, { useState, useEffect, useCallback } from 'react'; import { Box, Button, @@ -20,15 +20,19 @@ import VolunteerCloseRequestActionModal from '@components/forms/volunteerCloseRe import VolunteerRequestActionRequiredModal from '@components/forms/volunteerRequestActionRequiredModal'; 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; enableVolunteerActions?: boolean; + initialRequestId?: number; } const RequestManagement: React.FC = ({ fetchRequests: fetchData, enableVolunteerActions = true, + initialRequestId, }) => { const [requests, setRequests] = useState([]); const [sortRequestedAtAsc, setSortRequestedAtAsc] = useState(false); @@ -50,7 +54,10 @@ const RequestManagement: React.FC = ({ const [alertState, setAlertMessage] = useAlert(); const [isAlertError, setIsAlertError] = useState(true); - const loadRequests = async () => { + const navigate = useNavigate(); + const location = useLocation(); + + const loadRequests = useCallback(async () => { try { const data = await fetchData(); setRequests(data); @@ -58,16 +65,27 @@ const RequestManagement: React.FC = ({ setIsAlertError(true); setAlertMessage('Error fetching requests'); } - }; + }, [fetchData, setAlertMessage]); useEffect(() => { loadRequests(); - }, []); + }, [loadRequests]); useEffect(() => { setCurrentPage(1); }, [selectedFilteredPantries]); + useEffect(() => { + if (!initialRequestId || requests.length === 0) return; + const match = requests.find((r) => r.requestId === initialRequestId); + + if (match) { + setSelectedViewDetailsRequest(match); + } else { + navigate(ROUTES.REQUEST_FORM, { replace: true }); + } + }, [initialRequestId, requests, navigate]); + const pantryOptions = [ ...new Set( requests @@ -361,7 +379,12 @@ const RequestManagement: React.FC = ({ setSelectedViewDetailsRequest(null)} + onClose={() => { + setSelectedViewDetailsRequest(null); + if (initialRequestId) { + navigate(location.pathname, { replace: true }); + } + }} /> )} diff --git a/apps/frontend/src/components/forms/resetPasswordModal.tsx b/apps/frontend/src/components/forms/resetPasswordModal.tsx index e87df83c0..5fa1b0943 100644 --- a/apps/frontend/src/components/forms/resetPasswordModal.tsx +++ b/apps/frontend/src/components/forms/resetPasswordModal.tsx @@ -1,5 +1,6 @@ import { useState } from 'react'; import { useNavigate } from 'react-router-dom'; +import { ROUTES } from '../../routes'; import { Box, Text, @@ -59,7 +60,7 @@ const ResetPasswordModal: React.FC = () => { confirmationCode: code, newPassword: password, }); - navigate('/login'); + navigate(ROUTES.LOGIN); } catch { setAlertMessage('Failed to set new password'); } @@ -205,7 +206,7 @@ const ResetPasswordModal: React.FC = () => { navigate('/login')} + onClick={() => navigate(ROUTES.LOGIN)} variant="underline" textDecorationColor="neutral.300" > diff --git a/apps/frontend/src/components/protectedRoute.tsx b/apps/frontend/src/components/protectedRoute.tsx index cdd6ed231..52392dd3c 100644 --- a/apps/frontend/src/components/protectedRoute.tsx +++ b/apps/frontend/src/components/protectedRoute.tsx @@ -1,4 +1,5 @@ import { Navigate, useLocation, Outlet } from 'react-router-dom'; +import { ROUTES } from '../routes'; import { useAuthenticator } from '@aws-amplify/ui-react'; import { Center, Spinner, Text } from '@chakra-ui/react'; @@ -20,7 +21,7 @@ const ProtectedRoute = ({ children }: Props) => { } if (authStatus !== 'authenticated') { - return ; + return ; } return children ?? ; diff --git a/apps/frontend/src/components/signOutButton.tsx b/apps/frontend/src/components/signOutButton.tsx index 2de170fe2..cff94f15c 100644 --- a/apps/frontend/src/components/signOutButton.tsx +++ b/apps/frontend/src/components/signOutButton.tsx @@ -1,4 +1,3 @@ -import apiClient from '@api/apiClient'; import { Button, ButtonProps } from '@chakra-ui/react'; import { signOut } from 'aws-amplify/auth'; import { useNavigate } from 'react-router-dom'; diff --git a/apps/frontend/src/containers/adminDonationStats.tsx b/apps/frontend/src/containers/adminDonationStats.tsx index c699c5110..22f1d5c93 100644 --- a/apps/frontend/src/containers/adminDonationStats.tsx +++ b/apps/frontend/src/containers/adminDonationStats.tsx @@ -57,7 +57,7 @@ const AdminDonationStats: React.FC = () => { } }; fetchInitialData(); - }, []); + }, [setAlertMessage]); useEffect(() => { // Total stats only displayed on first page, so no need to do anything on page change @@ -74,7 +74,7 @@ const AdminDonationStats: React.FC = () => { } }; fetchTotalStats(); - }, [selectedYears, currentPage]); + }, [setAlertMessage, selectedYears, currentPage]); useEffect(() => { const fetchStats = async () => { @@ -90,7 +90,7 @@ const AdminDonationStats: React.FC = () => { } }; fetchStats(); - }, [selectedPantries, selectedYears, currentPage]); + }, [setAlertMessage, selectedPantries, selectedYears, currentPage]); const handlePantryNameFilterChange = (name: string, checked: boolean) => { // For simplicity, reset the page diff --git a/apps/frontend/src/containers/adminRequestManagement.tsx b/apps/frontend/src/containers/adminRequestManagement.tsx index 6a85dccc2..af4e2d3e0 100644 --- a/apps/frontend/src/containers/adminRequestManagement.tsx +++ b/apps/frontend/src/containers/adminRequestManagement.tsx @@ -1,12 +1,16 @@ -import React from 'react'; +import React, { useCallback } from 'react'; import ApiClient from '@api/apiClient'; import RequestManagement from '@components/foodRequestManagement'; -const AdminRequestManagement: React.FC = () => ( - ApiClient.getAllFoodRequests()} - enableVolunteerActions={false} - /> -); +const AdminRequestManagement: React.FC = () => { + const fetchRequests = useCallback(() => ApiClient.getAllFoodRequests(), []); + + return ( + + ); +}; export default AdminRequestManagement; diff --git a/apps/frontend/src/containers/approveFoodManufacturers.tsx b/apps/frontend/src/containers/approveFoodManufacturers.tsx index 17627f8a3..74015df29 100644 --- a/apps/frontend/src/containers/approveFoodManufacturers.tsx +++ b/apps/frontend/src/containers/approveFoodManufacturers.tsx @@ -1,5 +1,6 @@ import React, { useEffect, useState } from 'react'; import { useSearchParams } from 'react-router-dom'; +import { ROUTES } from '../routes'; import { Table, Button, @@ -322,7 +323,10 @@ const ApproveFoodManufacturers: React.FC = () => { textStyle="p2" variant="underline" textDecorationColor="neutral.700" - href={`/food-manufacturer-application-details/${foodManufacturer.foodManufacturerId}`} + href={ROUTES.FOOD_MANUFACTURER_APPLICATION_DETAILS.replace( + ':applicationId', + String(foodManufacturer.foodManufacturerId), + )} > View Details diff --git a/apps/frontend/src/containers/approvePantries.tsx b/apps/frontend/src/containers/approvePantries.tsx index 3d1bdf80e..ef4941d3c 100644 --- a/apps/frontend/src/containers/approvePantries.tsx +++ b/apps/frontend/src/containers/approvePantries.tsx @@ -1,5 +1,6 @@ import React, { useEffect, useState } from 'react'; import { useSearchParams } from 'react-router-dom'; +import { ROUTES } from '../routes'; import { Table, Button, @@ -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', + String(pantry.pantryId), + )} > View Details diff --git a/apps/frontend/src/containers/formRequests.tsx b/apps/frontend/src/containers/formRequests.tsx index 7adba8da6..5dc980bc7 100644 --- a/apps/frontend/src/containers/formRequests.tsx +++ b/apps/frontend/src/containers/formRequests.tsx @@ -15,16 +15,14 @@ import { } from '@chakra-ui/react'; import { ChevronRight, ChevronLeft } from 'lucide-react'; import FoodRequestFormModal from '@components/forms/requestFormModal'; -import { - FoodRequest, - FoodRequestStatus, - FoodRequestSummaryDto, -} from '../types/types'; +import { FoodRequestStatus, FoodRequestSummaryDto } from '../types/types'; import RequestDetailsModal from '@components/forms/requestDetailsModal'; import { formatDate } from '@utils/utils'; import ApiClient from '@api/apiClient'; import { FloatingAlert } from '@components/floatingAlert'; import { useAlert } from '../hooks/alert'; +import { useSearchParams, useNavigate } from 'react-router-dom'; +import { ROUTES } from '../routes'; const FormRequests: React.FC = () => { const [currentPage, setCurrentPage] = useState(1); @@ -40,6 +38,8 @@ const FormRequests: React.FC = () => { const [openReadOnlyRequest, setOpenReadOnlyRequest] = useState(null); + const [searchParams] = useSearchParams(); + const navigate = useNavigate(); const [alertState, setAlertMessage] = useAlert(); const pageSize = 10; @@ -69,6 +69,20 @@ const FormRequests: React.FC = () => { fetchRequests(); }, [fetchRequests]); + useEffect(() => { + const requestIdFromUrl = searchParams.get('requestId'); + if (!requestIdFromUrl || requests.length === 0) return; + + const match = requests.find( + (r) => r.requestId === Number(requestIdFromUrl), + ); + if (match) { + setOpenReadOnlyRequest(match); + } else { + navigate(ROUTES.REQUEST_FORM, { replace: true }); + } + }, [searchParams, requests, navigate]); + const paginatedRequests = requests.slice( (currentPage - 1) * pageSize, currentPage * pageSize, @@ -211,7 +225,12 @@ const FormRequests: React.FC = () => { setOpenReadOnlyRequest(null)} + onClose={() => { + setOpenReadOnlyRequest(null); + if (searchParams.get('requestId')) { + navigate(ROUTES.REQUEST_FORM, { replace: true }); + } + }} /> )} diff --git a/apps/frontend/src/containers/homepage.tsx b/apps/frontend/src/containers/homepage.tsx index 26750cb02..79ac37e7d 100644 --- a/apps/frontend/src/containers/homepage.tsx +++ b/apps/frontend/src/containers/homepage.tsx @@ -1,5 +1,6 @@ import React from 'react'; import { Link as RouterLink } from 'react-router-dom'; +import { ROUTES } from '../routes'; import { Box, Container, @@ -25,7 +26,7 @@ const Homepage: React.FC = () => { - Profile View + Profile View @@ -35,19 +36,24 @@ const Homepage: React.FC = () => { - Request Form + Dasboard - + Request Form + + + + + Pantry Application - + Order Management @@ -62,14 +68,14 @@ const Homepage: React.FC = () => { - + Donation Management - + Food Manufacturer Application @@ -84,21 +90,28 @@ const Homepage: React.FC = () => { - + + Dashboard + + + + + + Assigned Pantries - + Food Request Management - + Order Management @@ -113,12 +126,14 @@ const Homepage: React.FC = () => { - Approve Pantries + + Approve Pantries + - + Approve Food Manufacturers @@ -129,41 +144,43 @@ const Homepage: React.FC = () => { - - + + Volunteer Management - + Donation Management - + Donation Statistics - + Order Management - Dashboard + + Dashboard + - + Food Request Management @@ -179,12 +196,12 @@ const Homepage: React.FC = () => { - Login + Login - Sign Up + Sign Up diff --git a/apps/frontend/src/containers/loginPage.tsx b/apps/frontend/src/containers/loginPage.tsx index 1cc951dfd..037d6f711 100644 --- a/apps/frontend/src/containers/loginPage.tsx +++ b/apps/frontend/src/containers/loginPage.tsx @@ -1,4 +1,5 @@ import { useState } from 'react'; +import { ROUTES } from '../routes'; import { signIn, confirmSignIn, fetchAuthSession } from '@aws-amplify/auth'; import { useNavigate, useLocation } from 'react-router-dom'; import { @@ -17,7 +18,6 @@ import { Eye, EyeOff } from 'lucide-react'; import { FloatingAlert } from '@components/floatingAlert'; import { useAlert } from '../hooks/alert'; import AuthHeader from '@components/AuthHeader'; -import { ROUTES } from '../routes'; type Step = 'login' | 'new-password'; @@ -34,7 +34,7 @@ const LoginPage: React.FC = () => { const navigate = useNavigate(); const location = useLocation(); - const from = location.state?.from?.pathname || '/'; + const from = location.state?.from?.pathname || ROUTES.HOME; const handleLogin = async () => { try { diff --git a/apps/frontend/src/containers/pantryDashboard.tsx b/apps/frontend/src/containers/pantryDashboard.tsx index 95595aa6b..e3ff30545 100644 --- a/apps/frontend/src/containers/pantryDashboard.tsx +++ b/apps/frontend/src/containers/pantryDashboard.tsx @@ -1,147 +1,126 @@ -import { - Menu, - Portal, - Button, - HStack, - Text, - VStack, - Card, - CardBody, - Box, - Link, -} from '@chakra-ui/react'; -import { MenuIcon } from 'lucide-react'; import React, { useEffect, useState } from 'react'; -import { PantryWithUser } from 'types/types'; -import { formatPhone } from '@utils/utils'; +import { Box, Heading, Text } from '@chakra-ui/react'; +import DashboardCard, { ORDER_STATUS_BADGE } from '@components/dashboardCard'; +import { + FoodRequestSummaryDto, + OrderSummary, + PantryWithUser, +} from '../types/types'; +import { DashboardCardType } from '@components/dashboardCard'; 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 PantryDashboard: React.FC = () => { - const [pantryId, setPantryId] = useState(null); + const navigate = useNavigate(); + + const [alertState, setAlertMessage] = useAlert(); const [pantry, setPantry] = useState(null); + const [recentFoodRequests, setRecentFoodRequests] = useState< + FoodRequestSummaryDto[] + >([]); + const [recentOrders, setRecentOrders] = useState([]); useEffect(() => { - const fetchPantryId = async () => { + const fetchDashboardData = async () => { + let pantryId: number; try { - const pantryId = await ApiClient.getCurrentUserPantryId(); - setPantryId(pantryId); - } catch (error) { - console.error('Error fetching pantry ID', error); + pantryId = await ApiClient.getCurrentUserPantryId(); + const pantryData = await ApiClient.getPantry(pantryId); + setPantry(pantryData); + } catch { + setAlertMessage('Error fetching pantry information'); + return; } - }; - fetchPantryId(); - }, []); - - useEffect(() => { - const fetchPantryData = async () => { - if (!pantryId) return; + try { + const pantryFoodRequests = await ApiClient.getPantryRequests(pantryId); + const sortedFoodRequests = pantryFoodRequests.sort( + (a: FoodRequestSummaryDto, b: FoodRequestSummaryDto) => + new Date(b.requestedAt).getTime() - + new Date(a.requestedAt).getTime(), + ); + setRecentFoodRequests(sortedFoodRequests.slice(0, 2)); + } catch { + setAlertMessage('Error fetching pantry food requests'); + } try { - const pantryData = await ApiClient.getPantry(pantryId); - setPantry(pantryData); - } catch (error) { - console.error('Error fetching pantry data/SSFRep data', error); + const pantryOrders = await ApiClient.getPantryOrders(pantryId); + const sortedOrders = pantryOrders.sort( + (a: OrderSummary, b: OrderSummary) => + new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime(), + ); + setRecentOrders(sortedOrders.slice(0, 4)); + } catch { + setAlertMessage('Error fetching orders'); } }; + fetchDashboardData(); + }, [setAlertMessage]); - fetchPantryData(); - }, [pantryId]); + if (!pantry) return; return ( - - - - Welcome {pantry?.pantryName}! - - - - - - - - - - - Profile - - - Request Form - - - Sign out - - - - - - - + + {alertState && ( + + )} + + Welcome, {pantry.pantryName} + - - - - Need help? Contact your SSF representative - - - Name: {pantry?.pantryUser?.firstName} {pantry?.pantryUser?.lastName} - - Email: {pantry?.pantryUser?.email} - Phone: {formatPhone(pantry?.pantryUser?.phone)} - - + + Recent Food Requests + + + {recentFoodRequests.map((fr) => ( + + navigate(`${ROUTES.REQUEST_FORM}?requestId=${fr.requestId}`) + } + /> + ))} + - - + + Recent Orders + + + {recentOrders.map((order) => ( + + navigate( + `${ROUTES.PANTRY_ORDER_MANAGEMENT}?orderId=${order.orderId}`, + ) + } + /> + ))} + + ); }; diff --git a/apps/frontend/src/containers/pantryOrderManagement.tsx b/apps/frontend/src/containers/pantryOrderManagement.tsx index baf28ef4e..6d2bc2fdd 100644 --- a/apps/frontend/src/containers/pantryOrderManagement.tsx +++ b/apps/frontend/src/containers/pantryOrderManagement.tsx @@ -1,4 +1,4 @@ -import React, { useState, useEffect } from 'react'; +import React, { useState, useEffect, useCallback } from 'react'; import { Box, Button, @@ -18,13 +18,15 @@ import { } from 'lucide-react'; import { capitalize, formatDate, ORDER_STATUS_COLORS } from '@utils/utils'; import ApiClient from '@api/apiClient'; -import { OrderStatus, OrderWithoutFoodManufacturer } from '../types/types'; +import { OrderStatus, OrderSummary } from '../types/types'; import OrderReceivedActionModal from '@components/forms/orderReceivedActionModal'; import OrderDetailsModal from '@components/forms/orderDetailsModal'; import { FloatingAlert } from '@components/floatingAlert'; import { useAlert } from '../hooks/alert'; +import { useSearchParams, useNavigate } from 'react-router-dom'; +import { ROUTES } from '../routes'; -type OrderWithColor = OrderWithoutFoodManufacturer & { assigneeColor?: string }; +type OrderWithColor = OrderSummary & { assigneeColor?: string }; const MAX_PER_STATUS = 5; const PantryOrderManagement: React.FC = () => { @@ -55,6 +57,8 @@ const PantryOrderManagement: React.FC = () => { const [isAlertError, setIsAlertError] = useState(false); const [alertState, setAlertMessage] = useAlert(); + const [searchParams] = useSearchParams(); + const navigate = useNavigate(); // State to hold filter state per status type FilterState = { @@ -78,7 +82,7 @@ const PantryOrderManagement: React.FC = () => { }, }); - const fetchOrders = async () => { + const fetchOrders = useCallback(async () => { try { const pantryId = await ApiClient.getCurrentUserPantryId(); const data = await ApiClient.getPantryOrders(pantryId); @@ -110,11 +114,24 @@ const PantryOrderManagement: React.FC = () => { setIsAlertError(true); setAlertMessage('Failed to fetch orders'); } - }; + }, [setAlertMessage]); useEffect(() => { fetchOrders(); - }, []); + }, [fetchOrders]); + + useEffect(() => { + const orderIdFromUrl = searchParams.get('orderId'); + const allOrders = Object.values(statusOrders).flat(); + if (!orderIdFromUrl || allOrders.length === 0) return; + + const match = allOrders.find((o) => o.orderId === Number(orderIdFromUrl)); + if (match) { + setSelectedOrderId(match.orderId); + } else { + navigate(ROUTES.PANTRY_ORDER_MANAGEMENT, { replace: true }); + } + }, [searchParams, statusOrders, navigate]); // Helper to reset page for a specific status const resetPageForStatus = (status: OrderStatus) => { @@ -190,7 +207,10 @@ const PantryOrderManagement: React.FC = () => { setSelectedOrderId(null)} + onClose={() => { + setSelectedOrderId(null); + navigate(ROUTES.PANTRY_ORDER_MANAGEMENT, { replace: true }); + }} /> )} diff --git a/apps/frontend/src/containers/unauthorized.tsx b/apps/frontend/src/containers/unauthorized.tsx index 39dc5d844..d38221f8d 100644 --- a/apps/frontend/src/containers/unauthorized.tsx +++ b/apps/frontend/src/containers/unauthorized.tsx @@ -1,3 +1,5 @@ +import { ROUTES } from '../routes'; + export const Unauthorized: React.FC = () => { return (
@@ -6,7 +8,7 @@ export const Unauthorized: React.FC = () => {

Return to{' '} - home page + home page

diff --git a/apps/frontend/src/containers/volunteerDashboard.tsx b/apps/frontend/src/containers/volunteerDashboard.tsx new file mode 100644 index 000000000..c33843670 --- /dev/null +++ b/apps/frontend/src/containers/volunteerDashboard.tsx @@ -0,0 +1,121 @@ +import React, { useEffect, useState } from 'react'; +import { Box, Heading, Text } from '@chakra-ui/react'; +import DashboardCard, { ORDER_STATUS_BADGE } from '@components/dashboardCard'; +import { FoodRequestSummaryDto, User, VolunteerOrder } from '../types/types'; +import { DashboardCardType } from '@components/dashboardCard'; +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 VolunteerDashboard: React.FC = () => { + const navigate = useNavigate(); + + const [alertState, setAlertMessage] = useAlert(); + const [user, setUser] = useState(null); + const [recentFoodRequests, setRecentFoodRequests] = useState< + FoodRequestSummaryDto[] + >([]); + const [recentOrders, setRecentOrders] = useState([]); + + useEffect(() => { + const fetchDashboardData = async () => { + try { + const currentUser = await ApiClient.getMe(); + setUser(currentUser); + } catch { + setAlertMessage('Error fetching user information'); + return; + } + + try { + const requests = await ApiClient.getVolunteerAssignedRequests(); + const sorted = requests.sort( + (a: FoodRequestSummaryDto, b: FoodRequestSummaryDto) => + new Date(b.requestedAt).getTime() - + new Date(a.requestedAt).getTime(), + ); + setRecentFoodRequests(sorted.slice(0, 2)); + } catch { + setAlertMessage('Error fetching food requests'); + } + + try { + const orders = await ApiClient.getVolunteerRecentOrders(); + setRecentOrders(orders); + } catch { + setAlertMessage('Error fetching orders'); + } + }; + fetchDashboardData(); + }, [setAlertMessage]); + + if (!user) return null; + + return ( + + {alertState && ( + + )} + + Welcome, {user.firstName} {user.lastName} + + + + Recent Food Requests + + + {recentFoodRequests.map((fr) => ( + + navigate( + `${ROUTES.VOLUNTEER_REQUEST_MANAGEMENT}?requestId=${fr.requestId}`, + ) + } + /> + ))} + + + + My Orders + + + {recentOrders.map((order) => ( + + navigate( + `${ROUTES.VOLUNTEER_ORDER_MANAGEMENT}?orderId=${order.orderId}`, + ) + } + /> + ))} + + + ); +}; + +export default VolunteerDashboard; diff --git a/apps/frontend/src/containers/volunteerManagement.tsx b/apps/frontend/src/containers/volunteerManagement.tsx index c48c92fd9..b89dc09ae 100644 --- a/apps/frontend/src/containers/volunteerManagement.tsx +++ b/apps/frontend/src/containers/volunteerManagement.tsx @@ -1,4 +1,5 @@ import { useEffect, useState } from 'react'; +import { ROUTES } from '../routes'; import { Table, Text, @@ -176,7 +177,7 @@ const VolunteerManagement: React.FC = () => { textStyle="p2" variant="underline" textDecorationColor="neutral.700" - href={`/pantry-management/${volunteer.id}`} + href={`${ROUTES.PANTRY_MANAGEMENT}/${volunteer.id}`} > View Assigned Pantries diff --git a/apps/frontend/src/containers/volunteerOrderManagement.tsx b/apps/frontend/src/containers/volunteerOrderManagement.tsx index 813f73cdb..8d416dfcd 100644 --- a/apps/frontend/src/containers/volunteerOrderManagement.tsx +++ b/apps/frontend/src/containers/volunteerOrderManagement.tsx @@ -40,6 +40,8 @@ import OrderDetailsModal from '@components/forms/orderDetailsModal'; import CompleteRequiredActionsModal from '@components/forms/completeRequiredActionsModal'; import { FloatingAlert } from '@components/floatingAlert'; import { useAlert } from '../hooks/alert'; +import { useNavigate, useSearchParams } from 'react-router-dom'; +import { ROUTES } from '../routes'; type VolunteerOrderWithColor = VolunteerOrder & { assigneeColor?: string }; @@ -83,6 +85,8 @@ const VolunteerOrderManagement: React.FC = () => { const [alertState, setAlertMessage] = useAlert(); const [currentUser, setCurrentUser] = useState(null); + const [searchParams] = useSearchParams(); + const navigate = useNavigate(); type FilterState = { selectedPantries: string[]; @@ -167,6 +171,20 @@ const VolunteerOrderManagement: React.FC = () => { fetchOrders(); }, [setAlertMessage]); + useEffect(() => { + const orderIdFromUrl = searchParams.get('orderId'); + + const allOrders = Object.values(statusOrders).flat(); + if (!orderIdFromUrl || allOrders.length === 0) return; + + const match = allOrders.find((o) => o.orderId === Number(orderIdFromUrl)); + if (match) { + setSelectedOrderId(match.orderId); + } else { + navigate(ROUTES.VOLUNTEER_ORDER_MANAGEMENT, { replace: true }); + } + }, [searchParams, statusOrders, navigate]); + const resetPageForStatus = (status: OrderStatus) => { setCurrentPages((prev) => ({ ...prev, [status]: 1 })); }; @@ -345,6 +363,8 @@ 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); @@ -792,7 +812,10 @@ const OrderStatusSection: React.FC = ({ onOrderSelect(null)} + onClose={() => { + onOrderSelect(null); + navigate(ROUTES.VOLUNTEER_ORDER_MANAGEMENT, { replace: true }); + }} /> )} diff --git a/apps/frontend/src/containers/volunteerRequestManagement.tsx b/apps/frontend/src/containers/volunteerRequestManagement.tsx index 4fad5f34d..dd6c4fb31 100644 --- a/apps/frontend/src/containers/volunteerRequestManagement.tsx +++ b/apps/frontend/src/containers/volunteerRequestManagement.tsx @@ -1,11 +1,24 @@ -import React from 'react'; +import React, { useCallback } from 'react'; import ApiClient from '@api/apiClient'; import RequestManagement from '@components/foodRequestManagement'; +import { useSearchParams } from 'react-router-dom'; -const VolunteerRequestManagement: React.FC = () => ( - ApiClient.getVolunteerAssignedRequests()} - /> -); +const VolunteerRequestManagement: React.FC = () => { + const [searchParams] = useSearchParams(); + const requestIdParam = searchParams.get('requestId'); + const initialRequestId = requestIdParam ? Number(requestIdParam) : undefined; + + const fetchRequests = useCallback( + () => ApiClient.getVolunteerAssignedRequests(), + [], + ); + + return ( + + ); +}; export default VolunteerRequestManagement; diff --git a/apps/frontend/src/routes.ts b/apps/frontend/src/routes.ts index 976befab1..751837fe5 100644 --- a/apps/frontend/src/routes.ts +++ b/apps/frontend/src/routes.ts @@ -33,6 +33,8 @@ export const ROUTES = { PANTRY_ORDER_MANAGEMENT: '/pantry-order-management', REQUEST_FORM: '/request-form', + PANTRY_DASHBOARD: '/pantry-dashboard', + VOLUNTEER_DASHBOARD: '/volunteer-dashboard', FM_DONATION_MANAGEMENT: '/fm-donation-management', }; diff --git a/apps/frontend/src/types/types.ts b/apps/frontend/src/types/types.ts index 20318879c..6f71f6322 100644 --- a/apps/frontend/src/types/types.ts +++ b/apps/frontend/src/types/types.ts @@ -492,17 +492,19 @@ export interface OrderSummary { orderId: number; status: OrderStatus; createdAt: string; - shippedAt?: string; - deliveredAt?: string; + shippedAt: string | null; + deliveredAt: string | null; request: { pantryId: number; pantry: { pantryName: string; - volunteers?: { - id: number; - firstName: string; - lastName: string; - }[]; + volunteers: + | { + id: number; + firstName: string; + lastName: string; + }[] + | null; }; }; assignee: {