diff --git a/apps/backend/src/config/migrations.ts b/apps/backend/src/config/migrations.ts index b4c465a2..0385198c 100644 --- a/apps/backend/src/config/migrations.ts +++ b/apps/backend/src/config/migrations.ts @@ -26,9 +26,11 @@ import { UpdatePantryFields1763762628431 } from '../migrations/1763762628431-Upd import { PopulateDummyData1768501812134 } from '../migrations/1768501812134-populateDummyData'; import { RemovePantryFromOrders1769316004958 } from '../migrations/1769316004958-RemovePantryFromOrders'; import { AddDonationRecurrenceFields1770080947285 } from '../migrations/1770080947285-AddDonationRecurrenceFields'; +import { AddFoodRescueToDonationItems1770679339809 } from '../migrations/1770679339809-AddFoodRescueToDonationItems'; import { UpdateManufacturerEntity1768680807820 } from '../migrations/1768680807820-UpdateManufacturerEntity'; import { AddUserPoolId1769189327767 } from '../migrations/1769189327767-AddUserPoolId'; import { UpdateOrderEntity1769990652833 } from '../migrations/1769990652833-UpdateOrderEntity'; +import { RenameDonationMatchingStatus1771260403657 } from '../migrations/1771260403657-RenameDonationMatchingStatus'; const schemaMigrations = [ User1725726359198, @@ -59,9 +61,11 @@ const schemaMigrations = [ PopulateDummyData1768501812134, RemovePantryFromOrders1769316004958, AddDonationRecurrenceFields1770080947285, + AddFoodRescueToDonationItems1770679339809, UpdateManufacturerEntity1768680807820, AddUserPoolId1769189327767, UpdateOrderEntity1769990652833, + RenameDonationMatchingStatus1771260403657, ]; export default schemaMigrations; diff --git a/apps/backend/src/donationItems/donationItems.entity.ts b/apps/backend/src/donationItems/donationItems.entity.ts index bd9a5098..7e2e5c35 100644 --- a/apps/backend/src/donationItems/donationItems.entity.ts +++ b/apps/backend/src/donationItems/donationItems.entity.ts @@ -13,29 +13,29 @@ import { FoodType } from './types'; @Entity('donation_items') export class DonationItem { @PrimaryGeneratedColumn({ name: 'item_id' }) - itemId: number; + itemId!: number; @Column({ name: 'donation_id', type: 'int' }) - donationId: number; + donationId!: number; @ManyToOne(() => Donation, { nullable: false }) @JoinColumn({ name: 'donation_id', referencedColumnName: 'donationId' }) - donation: Donation; + donation!: Donation; @Column({ name: 'item_name', type: 'varchar', length: 255 }) - itemName: string; + itemName!: string; @Column({ name: 'quantity', type: 'int' }) - quantity: number; + quantity!: number; @Column({ name: 'reserved_quantity', type: 'int', default: 0 }) - reservedQuantity: number; + reservedQuantity!: number; @Column({ name: 'oz_per_item', type: 'int', nullable: true }) - ozPerItem: number; + ozPerItem?: number; @Column({ name: 'estimated_value', type: 'int', nullable: true }) - estimatedValue: number; + estimatedValue?: number; @Column({ name: 'food_type', @@ -44,8 +44,11 @@ export class DonationItem { enumName: 'food_type_enum', nullable: true, }) - foodType: FoodType; + foodType?: FoodType; @OneToMany(() => Allocation, (allocation) => allocation.item) - allocations: Allocation[]; + allocations!: Allocation[]; + + @Column({ name: 'food_rescue', type: 'boolean', default: false }) + foodRescue!: boolean; } diff --git a/apps/backend/src/donations/donations.controller.spec.ts b/apps/backend/src/donations/donations.controller.spec.ts index 971ec188..3c0b648f 100644 --- a/apps/backend/src/donations/donations.controller.spec.ts +++ b/apps/backend/src/donations/donations.controller.spec.ts @@ -2,6 +2,8 @@ import { DonationService } from './donations.service'; import { DonationsController } from './donations.controller'; import { Test, TestingModule } from '@nestjs/testing'; import { mock } from 'jest-mock-extended'; +import { Donation } from './donations.entity'; +import { FoodManufacturer } from '../foodManufacturers/manufacturers.entity'; const mockDonationService = mock(); @@ -38,4 +40,44 @@ describe('DonationsController', () => { expect(mockDonationService.getNumberOfDonations).toHaveBeenCalled(); }); }); + + describe('GET /by-fm-id/:foodManufacturerId', () => { + it('should return donations for a given food manufacturer', async () => { + const mockDonations: Partial[] = [ + { + donationId: 1, + foodManufacturer: { foodManufacturerId: 1 } as FoodManufacturer, + }, + { + donationId: 2, + foodManufacturer: { foodManufacturerId: 1 } as FoodManufacturer, + }, + ]; + mockDonationService.getByFoodManufacturer.mockResolvedValue( + mockDonations as Donation[], + ); + + const result = await controller.getDonationsByFoodManufacturer(1); + + expect(result).toBe(mockDonations); + expect(mockDonationService.getByFoodManufacturer).toHaveBeenCalledWith(1); + }); + }); + + describe('GET /by-donation-id/:donationId', () => { + it('should return a donation for a given donation ID', async () => { + const mockDonations: Partial[] = [ + { donationId: 1 }, + { donationId: 2 }, + ]; + mockDonationService.findOne.mockResolvedValue( + mockDonations[0] as Donation, + ); + + const result = await controller.getDonation(1); + + expect(result).toBe(mockDonations[0]); + expect(mockDonationService.findOne).toHaveBeenCalledWith(1); + }); + }); }); diff --git a/apps/backend/src/donations/donations.controller.ts b/apps/backend/src/donations/donations.controller.ts index e3b0ffcb..6e43380c 100644 --- a/apps/backend/src/donations/donations.controller.ts +++ b/apps/backend/src/donations/donations.controller.ts @@ -7,12 +7,11 @@ import { Param, NotFoundException, ParseIntPipe, - BadRequestException, } from '@nestjs/common'; import { ApiBody } from '@nestjs/swagger'; import { Donation } from './donations.entity'; import { DonationService } from './donations.service'; -import { DonationStatus, RecurrenceEnum } from './types'; +import { RecurrenceEnum } from './types'; import { CreateDonationDto } from './dtos/create-donation.dto'; @Controller('donations') @@ -29,7 +28,14 @@ export class DonationsController { return this.donationService.getNumberOfDonations(); } - @Get('/:donationId') + @Get('/by-fm-id/:foodManufacturerId') + async getDonationsByFoodManufacturer( + @Param('foodManufacturerId', ParseIntPipe) foodManufacturerId: number, + ): Promise { + return this.donationService.getByFoodManufacturer(foodManufacturerId); + } + + @Get('/by-donation-id/:donationId') async getDonation( @Param('donationId', ParseIntPipe) donationId: number, ): Promise { @@ -43,15 +49,6 @@ export class DonationsController { type: 'object', properties: { foodManufacturerId: { type: 'integer', example: 1 }, - dateDonated: { - type: 'string', - format: 'date-time', - }, - status: { - type: 'string', - enum: Object.values(DonationStatus), - example: DonationStatus.AVAILABLE, - }, totalItems: { type: 'integer', example: 100 }, totalOz: { type: 'number', example: 100.5 }, totalEstimatedValue: { type: 'number', example: 100.5 }, diff --git a/apps/backend/src/donations/donations.service.ts b/apps/backend/src/donations/donations.service.ts index 1dd719d5..955655ca 100644 --- a/apps/backend/src/donations/donations.service.ts +++ b/apps/backend/src/donations/donations.service.ts @@ -22,7 +22,6 @@ export class DonationService { where: { donationId }, relations: ['foodManufacturer'], }); - if (!donation) { throw new NotFoundException(`Donation ${donationId} not found`); } @@ -39,6 +38,25 @@ export class DonationService { return this.repo.count(); } + async getByFoodManufacturer(foodManufacturerId: number): Promise { + validateId(foodManufacturerId, 'Food Manufacturer'); + + const manufacturer = await this.manufacturerRepo.findOne({ + where: { foodManufacturerId }, + }); + + if (!manufacturer) { + throw new NotFoundException( + `Food Manufacturer ${foodManufacturerId} not found`, + ); + } + + return this.repo.find({ + where: { foodManufacturer: { foodManufacturerId } }, + relations: ['foodManufacturer'], + }); + } + async create(donationData: CreateDonationDto): Promise { validateId(donationData.foodManufacturerId, 'Food Manufacturer'); const manufacturer = await this.manufacturerRepo.findOne({ @@ -52,8 +70,8 @@ export class DonationService { } const donation = this.repo.create({ foodManufacturer: manufacturer, - dateDonated: donationData.dateDonated, - status: donationData.status, + dateDonated: new Date(), + status: DonationStatus.AVAILABLE, totalItems: donationData.totalItems, totalOz: donationData.totalOz, totalEstimatedValue: donationData.totalEstimatedValue, diff --git a/apps/backend/src/donations/dtos/create-donation.dto.ts b/apps/backend/src/donations/dtos/create-donation.dto.ts index 771ba2e6..01a23f09 100644 --- a/apps/backend/src/donations/dtos/create-donation.dto.ts +++ b/apps/backend/src/donations/dtos/create-donation.dto.ts @@ -9,7 +9,7 @@ import { Min, ValidateIf, } from 'class-validator'; -import { DonationStatus, RecurrenceEnum } from '../types'; +import { RecurrenceEnum } from '../types'; import { Type } from 'class-transformer'; export class CreateDonationDto { @@ -17,15 +17,6 @@ export class CreateDonationDto { @Min(1) foodManufacturerId!: number; - @Type(() => Date) - @IsDate() - @IsNotEmpty() - dateDonated!: Date; - - @IsNotEmpty() - @IsEnum(DonationStatus) - status!: DonationStatus; - @IsNumber() @Min(1) @IsOptional() diff --git a/apps/backend/src/donations/types.ts b/apps/backend/src/donations/types.ts index cb63fda3..40ed9734 100644 --- a/apps/backend/src/donations/types.ts +++ b/apps/backend/src/donations/types.ts @@ -1,7 +1,7 @@ export enum DonationStatus { AVAILABLE = 'available', FULFILLED = 'fulfilled', - MATCHING = 'matching', + MATCHED = 'matched', } export enum RecurrenceEnum { diff --git a/apps/backend/src/migrations/1770679339809-AddFoodRescueToDonationItems.ts b/apps/backend/src/migrations/1770679339809-AddFoodRescueToDonationItems.ts new file mode 100644 index 00000000..8d5f8994 --- /dev/null +++ b/apps/backend/src/migrations/1770679339809-AddFoodRescueToDonationItems.ts @@ -0,0 +1,19 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class AddFoodRescueToDonationItems1770679339809 + implements MigrationInterface +{ + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE donation_items + ADD COLUMN food_rescue boolean NOT NULL DEFAULT false + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE donation_items + DROP COLUMN food_rescue + `); + } +} diff --git a/apps/backend/src/migrations/1771260403657-RenameDonationMatchingStatus.ts b/apps/backend/src/migrations/1771260403657-RenameDonationMatchingStatus.ts new file mode 100644 index 00000000..df25b623 --- /dev/null +++ b/apps/backend/src/migrations/1771260403657-RenameDonationMatchingStatus.ts @@ -0,0 +1,71 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class RenameDonationMatchingStatus1771260403657 + implements MigrationInterface +{ + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE donations + ALTER COLUMN status DROP DEFAULT; + + CREATE TYPE donations_status_enum_new AS ENUM ( + 'available', + 'matched', + 'fulfilled' + ); + + ALTER TABLE donations + ALTER COLUMN status + TYPE donations_status_enum_new + USING ( + CASE + WHEN status = 'matching' + THEN 'matched' + ELSE status::text + END + )::donations_status_enum_new; + + DROP TYPE donations_status_enum; + + ALTER TYPE donations_status_enum_new + RENAME TO donations_status_enum; + + ALTER TABLE donations + ALTER COLUMN status + SET DEFAULT 'available'; + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE donations + ALTER COLUMN status DROP DEFAULT; + + CREATE TYPE donations_status_enum_old AS ENUM ( + 'available', + 'matching', + 'fulfilled' + ); + + ALTER TABLE donations + ALTER COLUMN status + TYPE donations_status_enum_old + USING ( + CASE + WHEN status = 'matched' + THEN 'matching' + ELSE status::text + END + )::donations_status_enum_old; + + DROP TYPE donations_status_enum; + + ALTER TYPE donations_status_enum_old + RENAME TO donations_status_enum; + + ALTER TABLE donations + ALTER COLUMN status + SET DEFAULT 'available'; + `); + } +} diff --git a/apps/backend/src/orders/order.service.spec.ts b/apps/backend/src/orders/order.service.spec.ts index 808ef510..8d89fe1c 100644 --- a/apps/backend/src/orders/order.service.spec.ts +++ b/apps/backend/src/orders/order.service.spec.ts @@ -21,7 +21,7 @@ describe('OrdersService', () => { } // Clean database at the start - await testDataSource.query(`DROP SCHEMA public CASCADE`); + await testDataSource.query(`DROP SCHEMA IF EXISTS public CASCADE`); await testDataSource.query(`CREATE SCHEMA public`); const module: TestingModule = await Test.createTestingModule({ diff --git a/apps/frontend/src/api/apiClient.ts b/apps/frontend/src/api/apiClient.ts index 0378afb9..5634bb3c 100644 --- a/apps/frontend/src/api/apiClient.ts +++ b/apps/frontend/src/api/apiClient.ts @@ -101,6 +101,14 @@ export class ApiClient { .then((response) => response.data); } + public async getAllDonationsByFoodManufacturer( + foodManufacturerId: number, + ): Promise { + return this.axiosInstance + .get(`/api/donations/by-fm-id/${foodManufacturerId}`) + .then((response) => response.data); + } + public async fulfillDonation( donationId: number, body?: unknown, @@ -180,12 +188,14 @@ export class ApiClient { await this.axiosInstance.put(`/api/users/${userId}/role`, body); } - public async getOrderFoodRequest(requestId: number): Promise { + public async getFoodRequest(requestId: number): Promise { return this.get(`/api/requests/${requestId}`) as Promise; } - public async getOrderDonation(donationId: number): Promise { - return this.get(`/api/donations/${donationId}`) as Promise; + public async getDonation(donationId: number): Promise { + return this.get( + `/api/donations/by-donation-id/${donationId}`, + ) as Promise; } public async getDonationItemsByDonationId( diff --git a/apps/frontend/src/app.tsx b/apps/frontend/src/app.tsx index 95009ac2..374f0805 100644 --- a/apps/frontend/src/app.tsx +++ b/apps/frontend/src/app.tsx @@ -7,7 +7,6 @@ import PantryPastOrders from '@containers/pantryPastOrders'; import Pantries from '@containers/pantries'; import Orders from '@containers/orders'; import PantryDashboard from '@containers/pantryDashboard'; -import submitFoodRequestFormModal from '@components/forms/requestFormModal'; import { submitDeliveryConfirmationFormModal } from '@components/forms/deliveryConfirmationModal'; import FormRequests from '@containers/formRequests'; import PantryApplication from '@containers/pantryApplication'; @@ -16,12 +15,12 @@ import { submitPantryApplicationForm } from '@components/forms/pantryApplication import ApprovePantries from '@containers/approvePantries'; import VolunteerManagement from '@containers/volunteerManagement'; import FoodManufacturerOrderDashboard from '@containers/foodManufacturerOrderDashboard'; -import DonationManagement from '@containers/donationManagement'; import AdminDonation from '@containers/adminDonation'; import Homepage from '@containers/homepage'; import AdminOrderManagement from '@containers/adminOrderManagement'; import { Amplify } from 'aws-amplify'; import CognitoAuthConfig from './aws-exports'; +import FoodManufacturerDonationManagement from '@containers/foodManufacturerDonationManagement'; import LoginPage from '@containers/loginPage'; import SignupPage from '@containers/signupPage'; import ForgotPasswordPage from '@containers/forgotPasswordPage'; @@ -153,10 +152,10 @@ const router = createBrowserRouter([ ), }, { - path: '/donation-management', + path: '/fm-donation-management', element: ( - + ), }, diff --git a/apps/frontend/src/chakra-ui.d.ts b/apps/frontend/src/chakra-ui.d.ts index 2e7b4e97..d4db3fae 100644 --- a/apps/frontend/src/chakra-ui.d.ts +++ b/apps/frontend/src/chakra-ui.d.ts @@ -70,6 +70,10 @@ declare module '@chakra-ui/react' { export interface FieldRootProps extends ComponentPropsLenientChildren {} export interface FieldHelperTextProps extends ComponentPropsLenientChildren {} + // Native Select components + export interface NativeSelectFieldProps + extends ComponentPropsLenientChildren {} + // Common components export interface ButtonProps extends ComponentPropsStrictChildren {} export interface IconButtonProps extends ComponentPropsStrictChildren {} @@ -79,4 +83,6 @@ declare module '@chakra-ui/react' { export interface CardProps extends ComponentPropsStrictChildren {} export interface CardBodyProps extends ComponentPropsStrictChildren {} export interface TextareaProps extends ComponentPropsStrictChildren {} + export interface NumberInputInputProps + extends ComponentPropsLenientChildren {} } diff --git a/apps/frontend/src/components/forms/donationDetailsModal.tsx b/apps/frontend/src/components/forms/donationDetailsModal.tsx index a3aa1731..e33792c7 100644 --- a/apps/frontend/src/components/forms/donationDetailsModal.tsx +++ b/apps/frontend/src/components/forms/donationDetailsModal.tsx @@ -22,22 +22,19 @@ const DonationDetailsModal: React.FC = ({ isOpen, onClose, }) => { - const [loadedDonation, setLoadedDonation] = useState(); const [items, setItems] = useState([]); const donationId = donation.donationId; useEffect(() => { - if (!isOpen || !donationId) return; + if (!isOpen) return; const fetchData = async () => { try { - const donationData = await ApiClient.getOrderDonation(donationId); const itemsData = await ApiClient.getDonationItemsByDonationId( donationId, ); - setLoadedDonation(donationData); setItems(itemsData); } catch (err) { alert('Error fetching donation details: ' + err); @@ -75,65 +72,66 @@ const DonationDetailsModal: React.FC = ({ - Donation #{donationId} Details + Donation #{donationId} Stock - - {loadedDonation && ( - <> - - {loadedDonation.foodManufacturer?.foodManufacturerName} - - - {formatDate(loadedDonation.dateDonated)} - - - )} + + {donation.foodManufacturer?.foodManufacturerName} + + {formatDate(donation.dateDonated)} - {loadedDonation && ( - - {Object.entries(groupedItems).map(([foodType, typeItems]) => ( - - - {foodType} - + + {Object.entries(groupedItems).map(([foodType, typeItems]) => ( + + + {foodType} + + + + {typeItems.map((item, index) => ( + + + {item.itemName} + - - {typeItems.map((item, index) => ( - - {item.itemName} - - - - {item.quantity} - + + {item.quantity - item.reservedQuantity} of{' '} + {item.quantity} remaining + - ))} - - - ))} - - )} + + ))} + + + ))} + diff --git a/apps/frontend/src/components/forms/newDonationFormModal.tsx b/apps/frontend/src/components/forms/newDonationFormModal.tsx index 11d1d9f3..6cf355ea 100644 --- a/apps/frontend/src/components/forms/newDonationFormModal.tsx +++ b/apps/frontend/src/components/forms/newDonationFormModal.tsx @@ -11,10 +11,14 @@ import { NativeSelect, NativeSelectIndicator, Portal, + Checkbox, + Menu, + NumberInput, } from '@chakra-ui/react'; import { useState } from 'react'; import ApiClient from '@api/apiClient'; -import { FoodTypes, FoodType } from '../../types/types'; +import { FoodType, FoodTypes, RecurrenceEnum } from '../../types/types'; +import { Minus } from 'lucide-react'; interface NewDonationFormModalProps { onDonationSuccess: () => void; @@ -29,13 +33,39 @@ interface DonationRow { numItems: string; ozPerItem: string; valuePerItem: string; + foodRescue: boolean; } +type DayOfWeek = + | 'Monday' + | 'Tuesday' + | 'Wednesday' + | 'Thursday' + | 'Friday' + | 'Saturday' + | 'Sunday'; + +type RepeatOnState = Record; + const NewDonationFormModal: React.FC = ({ onDonationSuccess, isOpen, onClose, }) => { + enum RepeatEnum { + NONE = 'None', + WEEK = 'Week', + MONTH = 'Month', + YEAR = 'Year', + } + + const RECURRENCE_MAP: Record = { + [RepeatEnum.NONE]: RecurrenceEnum.NONE, + [RepeatEnum.WEEK]: RecurrenceEnum.WEEKLY, + [RepeatEnum.MONTH]: RecurrenceEnum.MONTHLY, + [RepeatEnum.YEAR]: RecurrenceEnum.YEARLY, + }; + const [rows, setRows] = useState([ { id: 1, @@ -44,18 +74,34 @@ const NewDonationFormModal: React.FC = ({ numItems: '', ozPerItem: '', valuePerItem: '', + foodRescue: false, }, ]); + const [isRecurring, setIsRecurring] = useState(false); + const [repeatEvery, setRepeatEvery] = useState('1'); + const [repeatInterval, setRepeatInterval] = useState( + RepeatEnum.NONE, + ); + const [repeatOn, setRepeatOn] = useState({ + Monday: false, + Tuesday: false, + Wednesday: true, + Thursday: false, + Friday: false, + Saturday: false, + Sunday: false, + }); + const [endsAfter, setEndsAfter] = useState('1'); + const [totalItems, setTotalItems] = useState(0); const [totalOz, setTotalOz] = useState(0); const [totalValue, setTotalValue] = useState(0); - const handleChange = (id: number, field: string, value: string) => { + const handleChange = (id: number, field: string, value: string | boolean) => { const updatedRows = rows.map((row) => row.id === id ? { ...row, [field]: value } : row, ); - setRows(updatedRows); calculateTotals(updatedRows); }; @@ -79,6 +125,13 @@ const NewDonationFormModal: React.FC = ({ setTotalValue(parseFloat(totalValue.toFixed(2))); }; + const handleDayToggle = (day: DayOfWeek) => { + setRepeatOn((prev) => ({ + ...prev, + [day]: !prev[day], + })); + }; + const addRow = () => { setRows([ ...rows, @@ -89,14 +142,109 @@ const NewDonationFormModal: React.FC = ({ numItems: '', ozPerItem: '', valuePerItem: '', + foodRescue: false, }, ]); }; - const deleteRow = () => { - const newRows = rows.slice(0, -1); - setRows(newRows); - calculateTotals(newRows); + const deleteRow = (id: number) => { + if (rows.length > 1) { + const newRows = rows.filter((r) => r.id !== id); + setRows(newRows); + calculateTotals(newRows); + } + }; + + const generateNextDonationDates = (): string[] => { + const today = new Date(); + const repeatCount = parseInt(repeatEvery); + const dates: string[] = []; + + if (repeatInterval === RepeatEnum.WEEK) { + const selectedDays = (Object.keys(repeatOn) as DayOfWeek[]).filter( + (day) => repeatOn[day], + ); + if (selectedDays.length === 0) return []; + + const dayOfWeek = today.getDay(); + const daysOfWeek: DayOfWeek[] = [ + 'Sunday', + 'Monday', + 'Tuesday', + 'Wednesday', + 'Thursday', + 'Friday', + 'Saturday', + ]; + + const baseWeeksToAdd = repeatCount; + const baseDaysToAdd = baseWeeksToAdd * 7; + const startDay = repeatCount > 1 ? baseDaysToAdd : 1; + + for (let i = startDay; i <= startDay + 6; i++) { + const nextDayIndex = (dayOfWeek + i) % 7; + const nextDay = daysOfWeek[nextDayIndex]; + + if (selectedDays.includes(nextDay)) { + const nextDate = new Date(today); + nextDate.setDate(today.getDate() + i); + nextDate.setHours( + today.getHours(), + today.getMinutes(), + today.getSeconds(), + today.getMilliseconds(), + ); + dates.push(nextDate.toISOString()); + } + } + } else if (repeatInterval === RepeatEnum.MONTH) { + const nextDate = new Date(today); + nextDate.setMonth(today.getMonth() + repeatCount); + nextDate.setHours( + today.getHours(), + today.getMinutes(), + today.getSeconds(), + today.getMilliseconds(), + ); + dates.push(nextDate.toISOString()); + } else if (repeatInterval === RepeatEnum.YEAR) { + const nextDate = new Date(today); + nextDate.setFullYear(today.getFullYear() + repeatCount); + nextDate.setHours( + today.getHours(), + today.getMinutes(), + today.getSeconds(), + today.getMilliseconds(), + ); + dates.push(nextDate.toISOString()); + } + + return dates; + }; + + const getNextDonationDateDisplay = (): string => { + const dates = generateNextDonationDates(); + if (dates.length === 0) return ''; + + const firstDate = new Date(dates[0]); + return firstDate.toLocaleDateString('en-US', { + weekday: 'long', + year: 'numeric', + month: 'long', + day: 'numeric', + }); + }; + + const getSelectedDaysText = () => { + const selected = (Object.keys(repeatOn) as DayOfWeek[]).filter( + (day) => repeatOn[day], + ); + if (selected.length === 0) return 'Select days'; + if (selected.length === 1) return selected[0]; + if (selected.length <= 4) return selected.join(', '); + if (selected.length > 4) + return `${selected.slice(0, 4).join(', ')} + ${selected.length - 4}`; + return `${selected.length} days selected`; }; const handleSubmit = async () => { @@ -108,17 +256,27 @@ const NewDonationFormModal: React.FC = ({ !row.ozPerItem || !row.valuePerItem, ); - if (hasEmpty) { alert('Please fill in all fields before submitting.'); return; } + const nextDonationDates = isRecurring ? generateNextDonationDates() : null; + + if (nextDonationDates && nextDonationDates.length === 0) { + alert('Please select at least one day for weekly recurrence.'); + return; + } + const donation_body = { foodManufacturerId: 1, totalItems, totalOz, totalEstimatedValue: totalValue, + recurrence: RECURRENCE_MAP[repeatInterval], + recurrenceFreq: isRecurring ? parseInt(repeatEvery) : null, + nextDonationDates, + occurrencesRemaining: isRecurring ? parseInt(endsAfter) : null, }; try { @@ -133,6 +291,7 @@ const NewDonationFormModal: React.FC = ({ ozPerItem: parseFloat(row.ozPerItem), estimatedValue: parseFloat(row.valuePerItem), foodType: row.foodType as FoodType, + foodRescue: row.foodRescue, })); await ApiClient.postMultipleDonationItems({ donationId, items }); @@ -146,11 +305,14 @@ const NewDonationFormModal: React.FC = ({ numItems: '', ozPerItem: '', valuePerItem: '', + foodRescue: false, }, ]); setTotalItems(0); setTotalOz(0); setTotalValue(0); + setIsRecurring(false); + setRepeatInterval(RepeatEnum.NONE); onClose(); } else { alert('Failed to submit donation'); @@ -160,10 +322,19 @@ const NewDonationFormModal: React.FC = ({ } }; + const isRepeatOnDisabled = repeatInterval !== RepeatEnum.WEEK; + + const placeholderStyles = { + color: 'neutral.300', + fontFamily: 'inter', + fontSize: 'sm', + fontWeight: '400', + }; + return ( { if (!e.open) onClose(); }} @@ -172,48 +343,126 @@ const NewDonationFormModal: React.FC = ({ - + - - SSF Log New Donation Form + + Log New Donation - - Log a new donation by filling out the form below. + + Please fill out the following information to record donation + details. - - - - - Total Items: {totalItems}   Total oz: {totalOz}{' '} -   Total Value: {totalValue} - - - + + + + + { + if (e.checked) { + setRepeatInterval(RepeatEnum.WEEK); + } else { + setRepeatInterval(RepeatEnum.NONE); + } + setIsRecurring(!!e.checked); + }} + > + + + + + + Make Donation Recurring + + - - Food Item - Food Type - # of Items - Oz per Item - Value per Item + + + + Food Item + + * + + + + Food Type + + * + + + + Quantity + + * + + + + Oz. per item + + + Donation Value + + + Food Rescue + - {rows.map((row) => ( - + + + + handleChange(row.id, 'foodItem', e.target.value) @@ -222,14 +471,17 @@ const NewDonationFormModal: React.FC = ({ - + handleChange(row.id, 'foodType', e.target.value) } > - {FoodTypes.map((type) => ( ))} - - - + {isRecurring && ( + + + + + Repeat every + + + + setRepeatEvery(e.value) + } + min={1} + > + + + + + + setRepeatInterval(e.target.value as RepeatEnum) + } + > + {Object.values(RepeatEnum) + .filter((interval) => interval !== 'None') + .map((interval) => ( + + ))} + + + + + + + + + Repeat on + + + {!isRepeatOnDisabled && ( + + + + + + + + + + + )} + {isRepeatOnDisabled && ( + + + + + + + + + )} + {!isRepeatOnDisabled && ( + + + + {(Object.keys(repeatOn) as DayOfWeek[]).map( + (day) => ( + handleDayToggle(day)} + p={2} + > + + + + + + + + {day} + + + ), + )} + + + + )} + + + + + + Ends after + + + setEndsAfter(e.value) + } + min={1} + > + + + + {parseInt(endsAfter) > 1 + ? 'Occurrences' + : 'Occurrence'} + + + + + + + {(repeatInterval === RepeatEnum.WEEK + ? Object.values(repeatOn).some(Boolean) + : true) && ( + + Next Donation scheduled for {getNextDonationDateDisplay()} + + )} + + )} + + + + diff --git a/apps/frontend/src/containers/adminOrderManagement.tsx b/apps/frontend/src/containers/adminOrderManagement.tsx index 6edc9289..849e25b9 100644 --- a/apps/frontend/src/containers/adminOrderManagement.tsx +++ b/apps/frontend/src/containers/adminOrderManagement.tsx @@ -82,22 +82,16 @@ const AdminOrderManagement: React.FC = () => { }, }); - const STATUS_ORDER = [ - OrderStatus.PENDING, - OrderStatus.SHIPPED, - OrderStatus.DELIVERED, - ]; - // Color mapping for statuses const STATUS_COLORS = new Map([ - [OrderStatus.PENDING, ['#FEECD1', '#9C5D00']], - [OrderStatus.SHIPPED, ['#D5DCDF', '#2B4E60']], - [OrderStatus.DELIVERED, ['#D4EAED', '#19717D']], + [OrderStatus.PENDING, ['yellow.200', 'yellow.hover']], + [OrderStatus.SHIPPED, ['blue.200', 'blue.core']], + [OrderStatus.DELIVERED, ['teal.200', 'teal.hover']], ]); const MAX_PER_STATUS = 5; - const ASSIGNEE_COLORS = ['yellow', 'red', 'cyan', 'blue.ssf']; + const ASSIGNEE_COLORS = ['yellow.ssf', 'red', 'cyan', 'blue.ssf']; useEffect(() => { // Fetch all orders on component mount and sorts them into their appropriate status lists @@ -168,7 +162,7 @@ const AdminOrderManagement: React.FC = () => { Order Management - {STATUS_ORDER.map((status) => { + {Object.values(OrderStatus).map((status) => { const allOrders = statusOrders[status] || []; const filterState = filterStates[status]; diff --git a/apps/frontend/src/containers/foodManufacturerDonationManagement.tsx b/apps/frontend/src/containers/foodManufacturerDonationManagement.tsx new file mode 100644 index 00000000..42c481b9 --- /dev/null +++ b/apps/frontend/src/containers/foodManufacturerDonationManagement.tsx @@ -0,0 +1,406 @@ +import React, { useState, useEffect } from 'react'; +import { + Box, + Button, + Table, + Heading, + Pagination, + IconButton, + ButtonGroup, +} from '@chakra-ui/react'; +import { ChevronRight, ChevronLeft, Mail, CircleCheck } from 'lucide-react'; +import { capitalize, formatDate } from '@utils/utils'; +import ApiClient from '@api/apiClient'; +import { Donation, DonationStatus } from '../types/types'; +import DonationDetailsModal from '@components/forms/donationDetailsModal'; +import NewDonationFormModal from '@components/forms/newDonationFormModal'; + +const FoodManufacturerDonationManagement: React.FC = () => { + const [isLogDonationOpen, setIsLogDonationOpen] = useState(false); + // State to hold donations grouped by status + const [statusDonations, setStatusDonations] = useState<{ + [key in DonationStatus]: Donation[]; + }>({ + [DonationStatus.MATCHED]: [], + [DonationStatus.AVAILABLE]: [], + [DonationStatus.FULFILLED]: [], + }); + + // State to hold selected donation for details modal + const [selectedDonationId, setSelectedDonationId] = useState( + null, + ); + + // State to hold current page per status + const [currentPages, setCurrentPages] = useState< + Record + >({ + [DonationStatus.MATCHED]: 1, + [DonationStatus.AVAILABLE]: 1, + [DonationStatus.FULFILLED]: 1, + }); + + // Color mapping for statuses + const STATUS_COLORS = new Map([ + [DonationStatus.MATCHED, ['yellow.200', 'yellow.hover']], + [DonationStatus.AVAILABLE, ['blue.200', 'blue.core']], + [DonationStatus.FULFILLED, ['teal.200', 'teal.hover']], + ]); + + const MAX_PER_STATUS = 5; + + // Fetch all donations on component mount and sorts them into their appropriate status lists + const fetchDonations = async () => { + try { + const data = await ApiClient.getAllDonationsByFoodManufacturer(1); // Replace with actual food manufacturer ID + + const grouped: Record = { + [DonationStatus.AVAILABLE]: [], + [DonationStatus.FULFILLED]: [], + [DonationStatus.MATCHED]: [], + }; + + data.forEach((donation: Donation) => { + grouped[donation.status].push(donation); + }); + + setStatusDonations(grouped); + + // Initialize current page for each status + const initialPages: Record = { + [DonationStatus.AVAILABLE]: 1, + [DonationStatus.FULFILLED]: 1, + [DonationStatus.MATCHED]: 1, + }; + setCurrentPages(initialPages); + } catch (error) { + alert('Error fetching donations: ' + error); + } + }; + + useEffect(() => { + fetchDonations(); + }, []); + + const handlePageChange = (status: DonationStatus, page: number) => { + setCurrentPages((prev) => ({ + ...prev, + [status]: page, + })); + }; + + return ( + + + Donation Management + + + + + {isLogDonationOpen && ( + setIsLogDonationOpen(false)} + /> + )} + + {Object.values(DonationStatus).map((status) => { + const allDonationsByStatus = statusDonations[status] || []; + + const currentPage = currentPages[status] || 1; + const displayedDonations = allDonationsByStatus.slice( + (currentPage - 1) * MAX_PER_STATUS, + currentPage * MAX_PER_STATUS, + ); + + return ( + + handlePageChange(status, page)} + /> + + ); + })} + + ); +}; + +interface DonationStatusSectionProps { + donations: Donation[]; + status: DonationStatus; + colors: string[]; + onDonationSelect: (donationId: number | null) => void; + selectedDonationId: number | null; + totalDonations: number; + currentPage: number; + onPageChange: (page: number) => void; +} + +const DonationStatusSection: React.FC = ({ + donations, + status, + colors, + onDonationSelect, + selectedDonationId, + totalDonations, + currentPage, + onPageChange, +}) => { + const MAX_PER_STATUS = 5; + const totalPages = Math.ceil(totalDonations / MAX_PER_STATUS); + + const tableHeaderStyles = { + borderBottom: '1px solid', + borderColor: 'neutral.100', + color: 'neutral.800', + fontFamily: 'ibm', + fontWeight: '600', + fontSize: 'sm', + }; + + const tableCellStyles = { + borderBottom: '1px solid', + borderColor: 'neutral.100', + color: 'black', + fontFamily: "'Inter', sans-serif", + fontSize: 'sm', + py: 0, + }; + + return ( + + + + + {capitalize(status)} + + + + {donations.length === 0 ? ( + + + + + + No Donations + + + You have no {status.toLowerCase()} donations at this time. + + + ) : ( + <> + + + + + Donation # + + + Status + + + Date Donated + + + Action Required + + + + + {donations.map((donation, index) => ( + + + + {selectedDonationId === donation.donationId && ( + onDonationSelect(null)} + /> + )} + + + + {capitalize(donation.status)} + + + + {formatDate(donation.dateDonated)} + + + No Action Required + + + ))} + + + + {totalPages > 1 && ( + + onPageChange(e.page)} + > + + + + + + ( + + {page.value} + + )} + /> + + + + + + + + )} + + )} + + ); +}; + +export default FoodManufacturerDonationManagement; diff --git a/apps/frontend/src/containers/homepage.tsx b/apps/frontend/src/containers/homepage.tsx index ba539a5f..2d2801ff 100644 --- a/apps/frontend/src/containers/homepage.tsx +++ b/apps/frontend/src/containers/homepage.tsx @@ -74,7 +74,7 @@ const Homepage: React.FC = () => { - + Donation Management diff --git a/apps/frontend/src/containers/signupPage.tsx b/apps/frontend/src/containers/signupPage.tsx index 79173787..ca831b9a 100644 --- a/apps/frontend/src/containers/signupPage.tsx +++ b/apps/frontend/src/containers/signupPage.tsx @@ -34,7 +34,7 @@ const SignupPage: React.FC = () => {