diff --git a/apps/backend/src/pantries/pantries.controller.spec.ts b/apps/backend/src/pantries/pantries.controller.spec.ts index c5c5b14f..ef3f59d9 100644 --- a/apps/backend/src/pantries/pantries.controller.spec.ts +++ b/apps/backend/src/pantries/pantries.controller.spec.ts @@ -110,6 +110,50 @@ describe('PantriesController', () => { expect(controller).toBeDefined(); }); + describe('getAvailableYears', () => { + it('should return an array of years', async () => { + const mockYears = [2025, 2024]; + mockPantriesService.getAvailableYears.mockResolvedValueOnce(mockYears); + + const result = await controller.getAvailableYears(); + + expect(result).toEqual(mockYears); + expect(mockPantriesService.getAvailableYears).toHaveBeenCalled(); + }); + + it('should return an empty array when no approved pantry orders exist', async () => { + mockPantriesService.getAvailableYears.mockResolvedValueOnce([]); + + const result = await controller.getAvailableYears(); + + expect(result).toEqual([]); + expect(mockPantriesService.getAvailableYears).toHaveBeenCalled(); + }); + }); + + describe('getApprovedPantryNames', () => { + it('should return an array of approved pantry names', async () => { + const mockNames = ['Pantry A', 'Pantry B']; + mockPantriesService.getApprovedPantryNames.mockResolvedValueOnce( + mockNames, + ); + + const result = await controller.getApprovedPantryNames(); + + expect(result).toEqual(mockNames); + expect(mockPantriesService.getApprovedPantryNames).toHaveBeenCalled(); + }); + + it('should return an empty array if no approved pantries exist', async () => { + mockPantriesService.getApprovedPantryNames.mockResolvedValueOnce([]); + + const result = await controller.getApprovedPantryNames(); + + expect(result).toEqual([]); + expect(mockPantriesService.getApprovedPantryNames).toHaveBeenCalled(); + }); + }); + describe('getPendingPantries', () => { it('should return an array of pending pantries', async () => { mockPantriesService.getPendingPantries.mockResolvedValueOnce([ @@ -391,6 +435,7 @@ describe('PantriesController', () => { const mockStats: PantryStats[] = [ { pantryId: 1, + pantryName: 'Community Food Pantry Downtown', totalItems: 100, totalOz: 1600, totalLbs: 100, diff --git a/apps/backend/src/pantries/pantries.controller.ts b/apps/backend/src/pantries/pantries.controller.ts index c1e623e5..e3c99dac 100644 --- a/apps/backend/src/pantries/pantries.controller.ts +++ b/apps/backend/src/pantries/pantries.controller.ts @@ -76,6 +76,17 @@ export class PantriesController { } @Roles(Role.ADMIN) + @Get('/approved-names') + async getApprovedPantryNames(): Promise { + return this.pantriesService.getApprovedPantryNames(); + } + + @Roles(Role.ADMIN) + @Get('/available-years') + async getAvailableYears(): Promise { + return this.pantriesService.getAvailableYears(); + } + @Get('/approved') async getApprovedPantries(): Promise { return this.pantriesService.getApprovedPantriesWithVolunteers(); diff --git a/apps/backend/src/pantries/pantries.service.spec.ts b/apps/backend/src/pantries/pantries.service.spec.ts index 820e20a1..1cf08873 100644 --- a/apps/backend/src/pantries/pantries.service.spec.ts +++ b/apps/backend/src/pantries/pantries.service.spec.ts @@ -506,19 +506,16 @@ describe('PantriesService', () => { expect(stats.percentageFoodRescueItems).toBe(0); }); - it('returns zeroed stats for a pantry with no orders (Riverside Food Assistance)', async () => { - const stats = ( - await service.getPantryStats(['Riverside Food Assistance']) - )[0]; + it('throws NotFoundException for a non-approved (denied) pantry', async () => { + await expect( + service.getPantryStats(['Riverside Food Assistance']), + ).rejects.toThrow(NotFoundException); + }); - expect(stats.pantryId).toBe(4); - expect(stats.totalItems).toBe(0); - expect(stats.totalOz).toBe(0); - expect(stats.totalLbs).toBe(0); - expect(stats.totalDonatedFoodValue).toBe(0); - expect(stats.totalShippingCost).toBe(0); - expect(stats.totalValue).toBe(0); - expect(stats.percentageFoodRescueItems).toBe(0); + it('throws NotFoundException for a non-approved (pending) pantry', async () => { + await expect( + service.getPantryStats(['Harbor Community Center']), + ).rejects.toThrow(NotFoundException); }); it('respects year filter and returns zeros for a non-matching year', async () => { @@ -579,6 +576,9 @@ describe('PantriesService', () => { for (let i = 0; i < 10; i++) { await service.addPantry(makePantryDto(i)); } + await testDataSource.query( + `UPDATE public.pantries SET status = 'approved' WHERE pantry_name LIKE 'BulkTest Pantry%'`, + ); const page1 = await service.getPantryStats(undefined, undefined, 1); expect(page1.length).toBe(10); @@ -606,9 +606,13 @@ describe('PantriesService', () => { expect(community?.totalDonatedFoodValue).toBeCloseTo(130.0, 2); }); - it('returns proper array for no pantryNames given', async () => { + it('returns only approved pantries when no names given', async () => { const stats = await service.getPantryStats(); - expect(stats.length).toBe(6); + expect(stats.length).toBe(3); + const names = stats.map((s) => s.pantryName); + expect(names).not.toContain('Riverside Food Assistance'); + expect(names).not.toContain('Harbor Community Center'); + expect(names).not.toContain('Southside Pantry Network'); }); it('returns nothing for an invalid pantry name', async () => { @@ -628,9 +632,13 @@ describe('PantriesService', () => { }); it('validates all names before paginating — throws if any name is invalid regardless of page', async () => { + // Create 12 valid approved pantries so we have enough to paginate for (let i = 0; i < 12; i++) { await service.addPantry(makePantryDto(i)); } + await testDataSource.query( + `UPDATE public.pantries SET status = 'approved' WHERE pantry_name LIKE 'BulkTest Pantry%'`, + ); const validNames = Array.from( { length: 12 }, (_, i) => `BulkTest Pantry ${i}`, @@ -649,6 +657,9 @@ describe('PantriesService', () => { for (let i = 0; i < 12; i++) { await service.addPantry(makePantryDto(i)); } + await testDataSource.query( + `UPDATE public.pantries SET status = 'approved' WHERE pantry_name LIKE 'BulkTest Pantry%'`, + ); const names = Array.from( { length: 12 }, (_, i) => `BulkTest Pantry ${i}`, @@ -662,6 +673,9 @@ describe('PantriesService', () => { for (let i = 0; i < 12; i++) { await service.addPantry(makePantryDto(i)); } + await testDataSource.query( + `UPDATE public.pantries SET status = 'approved' WHERE pantry_name LIKE 'BulkTest Pantry%'`, + ); const names = Array.from( { length: 12 }, (_, i) => `BulkTest Pantry ${i}`, @@ -675,6 +689,9 @@ describe('PantriesService', () => { for (let i = 0; i < 12; i++) { await service.addPantry(makePantryDto(i)); } + await testDataSource.query( + `UPDATE public.pantries SET status = 'approved' WHERE pantry_name LIKE 'BulkTest Pantry%'`, + ); const names = Array.from( { length: 12 }, (_, i) => `BulkTest Pantry ${i}`, @@ -688,6 +705,9 @@ describe('PantriesService', () => { for (let i = 0; i < 12; i++) { await service.addPantry(makePantryDto(i)); } + await testDataSource.query( + `UPDATE public.pantries SET status = 'approved' WHERE pantry_name LIKE 'BulkTest Pantry%'`, + ); const names = Array.from( { length: 12 }, (_, i) => `BulkTest Pantry ${i}`, @@ -704,6 +724,27 @@ describe('PantriesService', () => { }); }); + describe('getApprovedPantryNames', () => { + it('returns the 3 approved pantry names from seed data', async () => { + const names = await service.getApprovedPantryNames(); + + expect(names).toHaveLength(3); + expect(names).toContain('Community Food Pantry Downtown'); + expect(names).toContain('Westside Community Kitchen'); + expect(names).toContain('North End Food Bank'); + }); + + it('returns an empty array when no pantries are approved', async () => { + await testDataSource.query( + `UPDATE public.pantries SET status = 'pending' WHERE status = 'approved'`, + ); + + const names = await service.getApprovedPantryNames(); + + expect(names).toEqual([]); + }); + }); + describe('getTotalStats', () => { it('aggregates stats across all pantries and matches migration sums', async () => { const total = await service.getTotalStats(); @@ -726,6 +767,72 @@ describe('PantriesService', () => { expect(totalEmpty.totalValue).toBe(0); expect(totalEmpty.percentageFoodRescueItems).toBe(0); }); + + it('returns all zeros when no approved pantries exist', async () => { + await testDataSource.query( + `UPDATE public.pantries SET status = 'pending' WHERE status = 'approved'`, + ); + const total = await service.getTotalStats(); + expect(total.totalItems).toBe(0); + expect(total.totalOz).toBe(0); + expect(total.totalLbs).toBe(0); + expect(total.totalDonatedFoodValue).toBe(0); + expect(total.totalShippingCost).toBe(0); + expect(total.totalValue).toBe(0); + expect(total.percentageFoodRescueItems).toBe(0); + }); + }); + + describe('getAvailableYears', () => { + it('returns years from approved pantry orders sorted descending', async () => { + await testDataSource.query( + `UPDATE public.orders SET created_at = '2024-06-01 00:00:00'`, + ); + + const years = await service.getAvailableYears(); + + expect(years).toEqual([2024]); + }); + + it('returns multiple years sorted descending', async () => { + await testDataSource.query(` + UPDATE public.orders + SET created_at = '2025-01-01 00:00:00' + WHERE order_id = (SELECT order_id FROM public.orders ORDER BY order_id LIMIT 1) + `); + await testDataSource.query(` + UPDATE public.orders + SET created_at = '2024-01-01 00:00:00' + WHERE order_id != (SELECT order_id FROM public.orders ORDER BY order_id LIMIT 1) + `); + + const years = await service.getAvailableYears(); + + expect(years).toEqual([2025, 2024]); + }); + + it('returns empty array when no approved pantries exist', async () => { + await testDataSource.query( + `UPDATE public.pantries SET status = 'pending' WHERE status = 'approved'`, + ); + + const years = await service.getAvailableYears(); + + expect(years).toEqual([]); + }); + + it('excludes years from non-approved pantry orders', async () => { + await testDataSource.query( + `UPDATE public.orders SET created_at = '2024-06-01 00:00:00'`, + ); + await testDataSource.query( + `UPDATE public.pantries SET status = 'pending' WHERE status = 'approved'`, + ); + + const years = await service.getAvailableYears(); + + expect(years).toEqual([]); + }); }); describe('findByIds', () => { diff --git a/apps/backend/src/pantries/pantries.service.ts b/apps/backend/src/pantries/pantries.service.ts index b7304150..20ca8c20 100644 --- a/apps/backend/src/pantries/pantries.service.ts +++ b/apps/backend/src/pantries/pantries.service.ts @@ -51,7 +51,7 @@ export class PantriesService { return pantry; } - private readonly EMPTY_STATS: Omit = { + private readonly EMPTY_STATS: Omit = { totalItems: 0, totalOz: 0, totalLbs: 0, @@ -64,7 +64,7 @@ export class PantriesService { private async aggregateStats( pantryIds?: number[], years?: number[], - ): Promise { + ): Promise[]> { // Query 1: aggregate item stats (totalItems, totalOz, totalDonatedFoodValue, totalFoodRescueItems) const itemsQb = this.orderRepo .createQueryBuilder('order') @@ -145,7 +145,7 @@ export class PantriesService { totalItems > 0 ? parseFloat(((totalFoodRescueItems / totalItems) * 100).toFixed(2)) : 0, - } satisfies PantryStats; + } satisfies Omit; }); } @@ -170,7 +170,10 @@ export class PantriesService { if (nameArray?.length) { const allMatched = await this.repo.find({ select: ['pantryId', 'pantryName'], - where: { pantryName: In(nameArray) }, + where: { + pantryName: In(nameArray), + status: ApplicationStatus.APPROVED, + }, order: { pantryId: 'ASC' }, }); @@ -197,14 +200,20 @@ export class PantriesService { const stats = await this.aggregateStats(pantryIds, yearsArray); const statsMap = new Map(stats.map((s) => [s.pantryId, s])); - return pantryIds.map( - (id) => statsMap.get(id) ?? { pantryId: id, ...this.EMPTY_STATS }, - ); + const nameMap = new Map(paginated.map((p) => [p.pantryId, p.pantryName])); + return pantryIds.map((id) => { + const stat = statsMap.get(id); + const pantryName = nameMap.get(id) ?? ''; + return stat + ? { ...stat, pantryName } + : { pantryId: id, pantryName, ...this.EMPTY_STATS }; + }); } - // No names provided — paginate from the full table + // No names provided — paginate from approved pantries only const pantries = await this.repo.find({ select: ['pantryId', 'pantryName'], + where: { status: ApplicationStatus.APPROVED }, order: { pantryId: 'ASC' }, skip: (page - 1) * PAGE_SIZE, take: PAGE_SIZE, @@ -219,17 +228,32 @@ export class PantriesService { const stats = await this.aggregateStats(pantryIds, yearsArray); const statsMap = new Map(stats.map((s) => [s.pantryId, s])); - return pantryIds.map( - (id) => statsMap.get(id) ?? { pantryId: id, ...this.EMPTY_STATS }, - ); + const nameMap = new Map(pantries.map((p) => [p.pantryId, p.pantryName])); + return pantryIds.map((id) => { + const stat = statsMap.get(id); + const pantryName = nameMap.get(id) ?? ''; + return stat + ? { ...stat, pantryName } + : { pantryId: id, pantryName, ...this.EMPTY_STATS }; + }); } async getTotalStats(years?: number[]): Promise { + const approvedPantries = await this.repo.find({ + select: ['pantryId'], + where: { status: ApplicationStatus.APPROVED }, + }); + + if (approvedPantries.length === 0) { + return { ...this.EMPTY_STATS }; + } + + const approvedPantryIds = approvedPantries.map((p) => p.pantryId); const yearsArray = years ? (Array.isArray(years) ? years : [years]).map(Number) : undefined; - const stats = await this.aggregateStats(undefined, yearsArray); + const stats = await this.aggregateStats(approvedPantryIds, yearsArray); const totalStats = { ...this.EMPTY_STATS }; let totalFoodRescueItems = 0; @@ -262,6 +286,40 @@ export class PantriesService { }); } + async getApprovedPantryNames(): Promise { + const pantries = await this.repo.find({ + select: ['pantryName'], + where: { status: ApplicationStatus.APPROVED }, + }); + return pantries.map((p) => p.pantryName); + } + + async getAvailableYears(): Promise { + const approvedPantries = await this.repo.find({ + select: ['pantryId'], + where: { status: ApplicationStatus.APPROVED }, + }); + + if (approvedPantries.length === 0) { + return []; + } + + const approvedPantryIds = approvedPantries.map((p) => p.pantryId); + + const rows = await this.orderRepo + .createQueryBuilder('order') + .leftJoin('order.request', 'request') + .select('EXTRACT(YEAR FROM order.createdAt)::int', 'year') + .where('request.pantryId IN (:...pantryIds)', { + pantryIds: approvedPantryIds, + }) + .groupBy('EXTRACT(YEAR FROM order.createdAt)::int') + .orderBy('"year"', 'DESC') + .getRawMany(); + + return rows.map((r) => Number(r.year)); + } + async addPantry(pantryData: PantryApplicationDto) { const pantryContact: User = new User(); const pantry: Pantry = new Pantry(); diff --git a/apps/backend/src/pantries/types.ts b/apps/backend/src/pantries/types.ts index a2927d7d..bcc9e4ab 100644 --- a/apps/backend/src/pantries/types.ts +++ b/apps/backend/src/pantries/types.ts @@ -57,6 +57,7 @@ export enum ReserveFoodForAllergic { export type PantryStats = { pantryId: number; + pantryName: string; totalItems: number; totalOz: number; totalLbs: number; @@ -66,5 +67,5 @@ export type PantryStats = { percentageFoodRescueItems: number; }; -// Make new type that is just a list of PantryStats with pantryId omitted -export type TotalStats = Omit; +// Make new type that is just a list of PantryStats with pantryId and pantryName omitted +export type TotalStats = Omit; diff --git a/apps/frontend/src/api/apiClient.ts b/apps/frontend/src/api/apiClient.ts index eacf11ad..f740341a 100644 --- a/apps/frontend/src/api/apiClient.ts +++ b/apps/frontend/src/api/apiClient.ts @@ -28,6 +28,8 @@ import { OrderWithoutFoodManufacturer, PantryWithUser, Assignments, + PantryStats, + TotalStats, UpdateProfileFields, } from 'types/types'; @@ -187,6 +189,46 @@ export class ApiClient { return this.axiosInstance.post(`/api/pantries`, data); } + public async getPantryStats(params?: { + pantryNames?: string[]; + years?: number[]; + page?: number; + }): Promise { + return this.axiosInstance + .get('/api/pantries/stats-by-pantry', { + params: { + ...(params?.pantryNames?.length && { + pantryNames: params.pantryNames, + }), + ...(params?.years?.length && { years: params.years }), + ...(params?.page && { page: params.page }), + }, + }) + .then((response) => response.data); + } + + public async getTotalStats(years?: number[]): Promise { + return this.axiosInstance + .get('/api/pantries/total-stats', { + params: { + ...(years?.length && { years }), + }, + }) + .then((response) => response.data); + } + + public async getApprovedPantryNames(): Promise { + return this.axiosInstance + .get('/api/pantries/approved-names') + .then((response) => response.data); + } + + public async getAvailableYears(): Promise { + return this.axiosInstance + .get('/api/pantries/available-years') + .then((response) => response.data); + } + public async getFoodRequestFromOrder( orderId: number, ): Promise { diff --git a/apps/frontend/src/app.tsx b/apps/frontend/src/app.tsx index 81daeb27..70529e7f 100644 --- a/apps/frontend/src/app.tsx +++ b/apps/frontend/src/app.tsx @@ -30,6 +30,7 @@ import FoodManufacturerApplication from '@containers/foodManufacturerApplication import { submitManufacturerApplicationForm } from '@components/forms/manufacturerApplicationForm'; import AssignedPantries from '@containers/volunteerAssignedPantries'; import VolunteerRequestManagement from '@containers/volunteerRequestManagement'; +import AdminDonationStats from '@containers/adminDonationStats'; import ProfilePage from '@containers/profilePage'; Amplify.configure(CognitoAuthConfig); @@ -172,6 +173,14 @@ const router = createBrowserRouter([ ), }, + { + path: '/admin-donation-stats', + element: ( + + + + ), + }, { path: '/volunteer-management', element: ( diff --git a/apps/frontend/src/components/forms/newDonationFormModal.tsx b/apps/frontend/src/components/forms/newDonationFormModal.tsx index f4cf7df7..b3fee374 100644 --- a/apps/frontend/src/components/forms/newDonationFormModal.tsx +++ b/apps/frontend/src/components/forms/newDonationFormModal.tsx @@ -85,9 +85,6 @@ const NewDonationFormModal: React.FC = ({ }); const [endsAfter, setEndsAfter] = useState('1'); - const [totalItems, setTotalItems] = useState(0); - const [totalOz, setTotalOz] = useState(0); - const [totalValue, setTotalValue] = useState(0); const [alertState, setAlertMessage] = useAlert(); const handleChange = (id: number, field: string, value: string | boolean) => { diff --git a/apps/frontend/src/components/forms/requestDetailsModal.tsx b/apps/frontend/src/components/forms/requestDetailsModal.tsx index b08bb7e0..5bc22329 100644 --- a/apps/frontend/src/components/forms/requestDetailsModal.tsx +++ b/apps/frontend/src/components/forms/requestDetailsModal.tsx @@ -265,13 +265,15 @@ const RequestDetailsModal: React.FC = ({ page={currentPage} onChange={(page: number) => setCurrentPage(page)} > - + setCurrentPage((prev) => Math.max(prev - 1, 1)) } + ml={2} > @@ -292,11 +294,13 @@ const RequestDetailsModal: React.FC = ({ setCurrentPage((prev) => Math.min(prev + 1, orderDetailsList.length), ) } + mr={2} > diff --git a/apps/frontend/src/containers/adminDonation.tsx b/apps/frontend/src/containers/adminDonation.tsx index 1c159528..fd3479ac 100644 --- a/apps/frontend/src/containers/adminDonation.tsx +++ b/apps/frontend/src/containers/adminDonation.tsx @@ -276,11 +276,14 @@ const AdminDonation: React.FC = () => { mt={12} variant="outline" size="sm" + gap={2} > @@ -301,7 +304,9 @@ const AdminDonation: React.FC = () => { diff --git a/apps/frontend/src/containers/adminDonationStats.tsx b/apps/frontend/src/containers/adminDonationStats.tsx new file mode 100644 index 00000000..fc1c8475 --- /dev/null +++ b/apps/frontend/src/containers/adminDonationStats.tsx @@ -0,0 +1,563 @@ +import React, { useState, useEffect } from 'react'; +import { + ChevronRight, + ChevronLeft, + ChevronDown, + Funnel, + Search, +} from 'lucide-react'; +import { + Box, + Button, + Table, + Heading, + Pagination, + IconButton, + Checkbox, + VStack, + ButtonGroup, + Input, +} from '@chakra-ui/react'; +import { PantryStats, TotalStats } from 'types/types'; +import ApiClient from '@api/apiClient'; +import { FloatingAlert } from '@components/floatingAlert'; +import { useAlert } from '../hooks/alert'; + +const AdminDonationStats: React.FC = () => { + // Individual and combined pantry stats to be displayed + const [pantryStats, setPantryStats] = useState([]); + const [totalStats, setTotalStats] = useState(); + // Names and years of all approved pantries, used for filters + const [pantryNameOptions, setPantryNameOptions] = useState([]); + const [availableYears, setAvailableYears] = useState([]); + // Filtering state management + const [selectedPantries, setSelectedPantries] = useState([]); + const [selectedYears, setSelectedYears] = useState([]); + const [searchPantry, setSearchPantry] = useState(''); + const [currentPage, setCurrentPage] = useState(1); + const [isFilterOpen, setIsFilterOpen] = useState(false); + const [isYearFilterOpen, setIsYearFilterOpen] = useState(false); + + const [alertState, setAlertMessage] = useAlert(); + + useEffect(() => { + ApiClient.getApprovedPantryNames() + .then(setPantryNameOptions) + .catch(() => setAlertMessage('Error fetching pantry names')); + + ApiClient.getAvailableYears() + .then((years) => { + setAvailableYears(years); + // On page load, set all years to selected + setSelectedYears(years); + }) + .catch(() => setAlertMessage('Error fetching available years')); + }, []); + + useEffect(() => { + // Total stats only displayed on first page, so no need to do anything on page change + if (currentPage !== 1) return; + + const allSelected = + selectedYears.length === 0 || + selectedYears.length === availableYears.length; + + ApiClient.getTotalStats(allSelected ? undefined : selectedYears) + .then(setTotalStats) + .catch(() => setAlertMessage('Error fetching total stats')); + }, [selectedYears, availableYears, currentPage]); + + useEffect(() => { + const fetchStats = async () => { + try { + const allSelected = + selectedYears.length === 0 || + selectedYears.length === availableYears.length; + const stats = await ApiClient.getPantryStats({ + pantryNames: selectedPantries.length ? selectedPantries : undefined, + years: allSelected ? undefined : selectedYears, + page: currentPage, + }); + setPantryStats(stats); + } catch { + setAlertMessage('Error fetching pantry stats'); + } + }; + fetchStats(); + }, [selectedPantries, selectedYears, availableYears, currentPage]); + + const handlePantryNameFilterChange = (name: string, checked: boolean) => { + // For simplicity, reset the page + setCurrentPage(1); + if (checked) { + setSelectedPantries([...selectedPantries, name]); + } else { + setSelectedPantries(selectedPantries.filter((n) => n !== name)); + } + }; + + const handleYearFilterChange = (year: number, checked: boolean) => { + // For simplicity, reset the page + setCurrentPage(1); + if (checked) { + setSelectedYears([...selectedYears, year]); + } else { + setSelectedYears(selectedYears.filter((y) => y !== year)); + } + }; + + const allYearsSelected = + availableYears.length > 0 && selectedYears.length === availableYears.length; + + // Default All Available Data, otherwise display as many years as possible in ascending order + const yearButtonLabel = + allYearsSelected || selectedYears.length === 0 + ? 'All Available Data' + : [...selectedYears].sort((a, b) => a - b).join(', '); + + const itemsPerPage = 10; + const totalCount = + selectedPantries.length > 0 + ? selectedPantries.length + : pantryNameOptions.length; + const totalPages = Math.ceil(totalCount / itemsPerPage); + // 9 pantries for the first page + const displayedStats = + currentPage === 1 ? pantryStats.slice(0, 9) : pantryStats; + + const tableHeaderStyles = { + borderBottom: '1px solid', + borderColor: 'neutral.100', + color: 'neutral.800', + fontFamily: 'inter', + fontWeight: '600', + fontSize: 'sm', + }; + + return ( + + + Donation Statistics + + {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', + }} + /> + + + {pantryNameOptions + .filter((name) => + name.toLowerCase().includes(searchPantry.toLowerCase()), + ) + .map((name) => ( + + handlePantryNameFilterChange(name, !!e.checked) + } + size="md" + > + + + {name} + + ))} + + + + )} + + + + + {isYearFilterOpen && ( + <> + setIsYearFilterOpen(false)} + zIndex={10} + /> + + + { + setCurrentPage(1); + setSelectedYears(e.checked ? [...availableYears] : []); + }} + size="md" + > + + + All Available Data + + {[...availableYears].map((year) => ( + + handleYearFilterChange(year, !!e.checked) + } + size="md" + > + + + {year} + + ))} + + + + )} + + + + + + + Pantry + + + Total Items + + + Total Weight (oz) + + + Total Weight (lbs) + + + Value of Donated Food + + + Shipping Cost/Tax + + + Total Value + + + + + {currentPage === 1 && totalStats && ( + + + All Pantries + + + {totalStats.totalItems} + + + {totalStats.totalOz.toFixed(2)} + + + {totalStats.totalLbs.toFixed(2)} + + + ${totalStats.totalDonatedFoodValue.toFixed(2)} + + + ${totalStats.totalShippingCost.toFixed(2)} + + + ${totalStats.totalValue.toFixed(2)} + + + )} + {displayedStats.map((stat) => ( + + + {stat.pantryName} + + + {stat.totalItems} + + + {stat.totalOz.toFixed(2)} + + + {stat.totalLbs.toFixed(2)} + + + ${stat.totalDonatedFoodValue.toFixed(2)} + + + ${stat.totalShippingCost.toFixed(2)} + + + ${stat.totalValue.toFixed(2)} + + + ))} + + + + {totalPages > 1 && ( + setCurrentPage(e.page)} + > + + + + + + ( + + {page.value} + + )} + /> + + + + + + + )} + + ); +}; + +export default AdminDonationStats; diff --git a/apps/frontend/src/containers/adminOrderManagement.tsx b/apps/frontend/src/containers/adminOrderManagement.tsx index cbec3241..bb49cf8e 100644 --- a/apps/frontend/src/containers/adminOrderManagement.tsx +++ b/apps/frontend/src/containers/adminOrderManagement.tsx @@ -739,11 +739,13 @@ const OrderStatusSection: React.FC = ({ alignItems="center" variant="outline" size="sm" + gap={2} > = ({ color="neutral.800" _hover={{ color: 'black' }} disabled={currentPage === totalPages} + mr={2} > { mt={12} variant="outline" size="sm" + gap={2} > @@ -357,7 +360,9 @@ const ApprovePantries: React.FC = () => { diff --git a/apps/frontend/src/containers/foodManufacturerDonationManagement.tsx b/apps/frontend/src/containers/foodManufacturerDonationManagement.tsx index d295e2c5..138507cb 100644 --- a/apps/frontend/src/containers/foodManufacturerDonationManagement.tsx +++ b/apps/frontend/src/containers/foodManufacturerDonationManagement.tsx @@ -353,11 +353,13 @@ const DonationStatusSection: React.FC = ({ alignItems="center" variant="outline" size="sm" + gap={2} > = ({ color="neutral.800" _hover={{ color: 'black' }} disabled={currentPage === totalPages} + mr={2} > { page={currentPage} onChange={(page: number) => setCurrentPage(page)} > - + setCurrentPage((prev) => Math.max(prev - 1, 1))} + ml={2} > @@ -242,11 +244,13 @@ const FormRequests: React.FC = () => { setCurrentPage((prev) => Math.min(prev + 1, Math.ceil(requests.length / pageSize)), ) } + mr={2} > diff --git a/apps/frontend/src/containers/homepage.tsx b/apps/frontend/src/containers/homepage.tsx index 1190a227..ff7d720f 100644 --- a/apps/frontend/src/containers/homepage.tsx +++ b/apps/frontend/src/containers/homepage.tsx @@ -150,6 +150,13 @@ const Homepage: React.FC = () => { + + + + Donation Statistics + + + diff --git a/apps/frontend/src/containers/pantryOrderManagement.tsx b/apps/frontend/src/containers/pantryOrderManagement.tsx index 1b03cb35..82c4d3e9 100644 --- a/apps/frontend/src/containers/pantryOrderManagement.tsx +++ b/apps/frontend/src/containers/pantryOrderManagement.tsx @@ -546,11 +546,13 @@ const OrderStatusSection: React.FC = ({ alignItems="center" variant="outline" size="sm" + gap={2} > = ({ color="neutral.800" _hover={{ color: 'black' }} disabled={currentPage === totalPages} + mr={2} > { page={currentPage} onChange={(page: number) => setCurrentPage(page)} > - + setCurrentPage((prev) => Math.max(prev - 1, 1)) } + ml={2} > @@ -209,6 +211,7 @@ const VolunteerManagement: React.FC = () => { setCurrentPage(page.value)} + mr={2} > {page.value} @@ -218,6 +221,10 @@ const VolunteerManagement: React.FC = () => { setCurrentPage((prev) => Math.min( diff --git a/apps/frontend/src/containers/volunteerRequestManagement.tsx b/apps/frontend/src/containers/volunteerRequestManagement.tsx index 1fe8915a..a25b9d93 100644 --- a/apps/frontend/src/containers/volunteerRequestManagement.tsx +++ b/apps/frontend/src/containers/volunteerRequestManagement.tsx @@ -323,11 +323,14 @@ const VolunteerRequestManagement: React.FC = () => { mt={12} variant="outline" size="sm" + gap={2} > @@ -348,7 +351,9 @@ const VolunteerRequestManagement: React.FC = () => { diff --git a/apps/frontend/src/theme.ts b/apps/frontend/src/theme.ts index 900fb8bd..ac465d5e 100644 --- a/apps/frontend/src/theme.ts +++ b/apps/frontend/src/theme.ts @@ -84,6 +84,7 @@ const customConfig = defineConfig({ yellow: { ssf: { value: '#F89E19' }, hover: { value: '#9C5D00' }, + 100: { value: '#FEF5E8' }, 200: { value: '#FEECD1' }, }, neutral: { diff --git a/apps/frontend/src/types/types.ts b/apps/frontend/src/types/types.ts index 3f28c59d..e5903f1f 100644 --- a/apps/frontend/src/types/types.ts +++ b/apps/frontend/src/types/types.ts @@ -377,6 +377,7 @@ export type RepeatOnState = Record; export interface PantryStats { pantryId: number; + pantryName: string; totalItems: number; totalOz: number; totalLbs: number; @@ -386,8 +387,7 @@ export interface PantryStats { percentageFoodRescueItems: number; } -// Make TotalStats interface just not include pantryId -export type TotalStats = Omit; +export type TotalStats = Omit; export type Assignments = Omit & { pantryIds: number[] };