diff --git a/apps/backend/src/config/migrations.ts b/apps/backend/src/config/migrations.ts index e7177b51..81c4dff0 100644 --- a/apps/backend/src/config/migrations.ts +++ b/apps/backend/src/config/migrations.ts @@ -25,6 +25,8 @@ import { RemoveUnusedStatuses1764816885341 } from '../migrations/1764816885341-R import { UpdatePantryFields1763762628431 } from '../migrations/1763762628431-UpdatePantryFields'; 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'; const schemaMigrations = [ @@ -55,6 +57,8 @@ const schemaMigrations = [ RemoveUnusedStatuses1764816885341, PopulateDummyData1768501812134, RemovePantryFromOrders1769316004958, + AddDonationRecurrenceFields1770080947285, + AddFoodRescueToDonationItems1770679339809, UpdateManufacturerEntity1768680807820, ]; diff --git a/apps/backend/src/donationItems/donationItems.entity.ts b/apps/backend/src/donationItems/donationItems.entity.ts index bd9a5098..ca5f3cb5 100644 --- a/apps/backend/src/donationItems/donationItems.entity.ts +++ b/apps/backend/src/donationItems/donationItems.entity.ts @@ -48,4 +48,7 @@ export class DonationItem { @OneToMany(() => Allocation, (allocation) => allocation.item) allocations: Allocation[]; + + @Column({ name: 'food_rescue', type: 'boolean', default: false }) + foodRescue: boolean; } diff --git a/apps/backend/src/donations/donations.controller.ts b/apps/backend/src/donations/donations.controller.ts index 6bcd2a7e..306cdc01 100644 --- a/apps/backend/src/donations/donations.controller.ts +++ b/apps/backend/src/donations/donations.controller.ts @@ -7,12 +7,12 @@ 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 } from './types'; +import { RecurrenceEnum } from './types'; +import { CreateDonationDto } from './dtos/create-donation.dto'; @Controller('donations') export class DonationsController { @@ -28,6 +28,13 @@ export class DonationsController { return this.donationService.getNumberOfDonations(); } + @Get('/donations/:foodManufacturerId') + async getDonationsByFoodManufacturer( + @Param('foodManufacturerId', ParseIntPipe) foodManufacturerId: number, + ): Promise { + return this.donationService.getByFoodManufacturer(foodManufacturerId); + } + @Get('/:donationId') async getDonation( @Param('donationId', ParseIntPipe) donationId: number, @@ -42,46 +49,30 @@ 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: 'integer', example: 500 }, totalEstimatedValue: { type: 'integer', example: 1000 }, + recurrence: { + type: 'string', + enum: Object.values(RecurrenceEnum), + example: RecurrenceEnum.NONE, + }, + recurrenceFreq: { type: 'integer', example: 1, nullable: true }, + nextDonationDates: { + type: 'array', + items: { type: 'string', format: 'date-time' }, + example: ['2024-07-01T00:00:00Z', '2024-08-01T00:00:00Z'], + nullable: true, + }, + occurrencesRemaining: { type: 'integer', example: 2, nullable: true }, }, }, }) async createDonation( @Body() - body: { - foodManufacturerId: number; - dateDonated: Date; - status: DonationStatus; - totalItems: number; - totalOz: number; - totalEstimatedValue: number; - }, + body: CreateDonationDto, ): Promise { - if ( - body.status && - !Object.values(DonationStatus).includes(body.status as DonationStatus) - ) { - throw new BadRequestException('Invalid status'); - } - return this.donationService.create( - body.foodManufacturerId, - body.dateDonated, - body.status ?? DonationStatus.AVAILABLE, - body.totalItems, - body.totalOz, - body.totalEstimatedValue, - ); + return this.donationService.create(body); } @Patch('/:donationId/fulfill') diff --git a/apps/backend/src/donations/donations.entity.ts b/apps/backend/src/donations/donations.entity.ts index 3666c56b..e0fbd33f 100644 --- a/apps/backend/src/donations/donations.entity.ts +++ b/apps/backend/src/donations/donations.entity.ts @@ -6,26 +6,26 @@ import { JoinColumn, ManyToOne, } from 'typeorm'; +import { DonationStatus, RecurrenceEnum } from './types'; import { FoodManufacturer } from '../foodManufacturers/manufacturers.entity'; -import { DonationStatus } from './types'; @Entity('donations') export class Donation { @PrimaryGeneratedColumn({ name: 'donation_id' }) - donationId: number; + donationId!: number; @ManyToOne(() => FoodManufacturer, (manufacturer) => manufacturer.donations, { nullable: false, }) @JoinColumn({ name: 'food_manufacturer_id' }) - foodManufacturer: FoodManufacturer; + foodManufacturer!: FoodManufacturer; @CreateDateColumn({ name: 'date_donated', type: 'timestamp', default: () => 'NOW()', }) - dateDonated: Date; + dateDonated!: Date; @Column({ name: 'status', @@ -34,14 +34,37 @@ export class Donation { enumName: 'donations_status_enum', default: DonationStatus.AVAILABLE, }) - status: DonationStatus; + status!: DonationStatus; @Column({ name: 'total_items', type: 'int', nullable: true }) - totalItems: number; + totalItems?: number; @Column({ name: 'total_oz', type: 'int', nullable: true }) - totalOz: number; + totalOz?: number; @Column({ name: 'total_estimated_value', type: 'int', nullable: true }) - totalEstimatedValue: number; + totalEstimatedValue?: number; + + @Column({ + name: 'recurrence', + type: 'enum', + enum: RecurrenceEnum, + enumName: 'donation_recurrence_enum', + default: RecurrenceEnum.NONE, + }) + recurrence!: RecurrenceEnum; + + @Column({ name: 'recurrence_freq', type: 'int', nullable: true }) + recurrenceFreq?: number; + + @Column({ + name: 'next_donation_dates', + type: 'timestamptz', + array: true, + nullable: true, + }) + nextDonationDates?: Date[]; + + @Column({ name: 'occurrences_remaining', type: 'int', nullable: true }) + occurrencesRemaining?: number; } diff --git a/apps/backend/src/donations/donations.service.ts b/apps/backend/src/donations/donations.service.ts index 44793840..fe6aa82c 100644 --- a/apps/backend/src/donations/donations.service.ts +++ b/apps/backend/src/donations/donations.service.ts @@ -3,8 +3,9 @@ import { InjectRepository } from '@nestjs/typeorm'; import { Repository } from 'typeorm'; import { Donation } from './donations.entity'; import { validateId } from '../utils/validation.utils'; -import { FoodManufacturer } from '../foodManufacturers/manufacturers.entity'; import { DonationStatus } from './types'; +import { CreateDonationDto } from './dtos/create-donation.dto'; +import { FoodManufacturer } from '../foodManufacturers/manufacturers.entity'; @Injectable() export class DonationService { @@ -38,15 +39,9 @@ export class DonationService { return this.repo.count(); } - async create( - foodManufacturerId: number, - dateDonated: Date, - status: DonationStatus, - totalItems: number, - totalOz: number, - totalEstimatedValue: number, - ) { + async getByFoodManufacturer(foodManufacturerId: number): Promise { validateId(foodManufacturerId, 'Food Manufacturer'); + const manufacturer = await this.manufacturerRepo.findOne({ where: { foodManufacturerId }, }); @@ -56,13 +51,35 @@ export class DonationService { `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({ + where: { foodManufacturerId: donationData.foodManufacturerId }, + }); + + if (!manufacturer) { + throw new NotFoundException( + `Food Manufacturer ${donationData.foodManufacturerId} not found`, + ); + } const donation = this.repo.create({ foodManufacturer: manufacturer, - dateDonated, - status, - totalItems, - totalOz, - totalEstimatedValue, + dateDonated: new Date(), + status: DonationStatus.AVAILABLE, + totalItems: donationData.totalItems, + totalOz: donationData.totalOz, + totalEstimatedValue: donationData.totalEstimatedValue, + recurrence: donationData.recurrence, + recurrenceFreq: donationData.recurrenceFreq, + nextDonationDates: donationData.nextDonationDates, + occurrencesRemaining: donationData.occurrencesRemaining, }); return this.repo.save(donation); diff --git a/apps/backend/src/donations/dtos/create-donation.dto.ts b/apps/backend/src/donations/dtos/create-donation.dto.ts new file mode 100644 index 00000000..1e454ab0 --- /dev/null +++ b/apps/backend/src/donations/dtos/create-donation.dto.ts @@ -0,0 +1,55 @@ +import { + ArrayNotEmpty, + IsArray, + IsDate, + IsEnum, + IsNotEmpty, + IsNumber, + IsOptional, + Min, + ValidateIf, +} from 'class-validator'; +import { DonationStatus, RecurrenceEnum } from '../types'; +import { Type } from 'class-transformer'; + +export class CreateDonationDto { + @IsNumber() + @Min(1) + foodManufacturerId!: number; + + @IsNumber() + @Min(1) + @IsOptional() + totalItems?: number; + + @IsNumber({ maxDecimalPlaces: 2 }) + @Min(0.01) + @IsOptional() + totalOz?: number; + + @IsNumber({ maxDecimalPlaces: 2 }) + @Min(0.01) + @IsOptional() + totalEstimatedValue?: number; + + @IsNotEmpty() + @IsEnum(RecurrenceEnum) + recurrence!: RecurrenceEnum; + + @IsNumber() + @ValidateIf((o) => o.recurrence !== RecurrenceEnum.NONE) + @Min(1) + recurrenceFreq?: number; + + @Type(() => Date) + @IsArray() + @ArrayNotEmpty() + @IsDate({ each: true }) + @ValidateIf((o) => o.recurrence !== RecurrenceEnum.NONE) + nextDonationDates?: Date[]; + + @IsNumber() + @ValidateIf((o) => o.recurrence !== RecurrenceEnum.NONE) + @Min(1) + occurrencesRemaining?: number; +} diff --git a/apps/backend/src/donations/types.ts b/apps/backend/src/donations/types.ts index 16387987..cb63fda3 100644 --- a/apps/backend/src/donations/types.ts +++ b/apps/backend/src/donations/types.ts @@ -3,3 +3,10 @@ export enum DonationStatus { FULFILLED = 'fulfilled', MATCHING = 'matching', } + +export enum RecurrenceEnum { + NONE = 'none', + WEEKLY = 'weekly', + MONTHLY = 'monthly', + YEARLY = 'yearly', +} diff --git a/apps/backend/src/migrations/1770080947285-AddDonationRecurrenceFields.ts b/apps/backend/src/migrations/1770080947285-AddDonationRecurrenceFields.ts new file mode 100644 index 00000000..25c28622 --- /dev/null +++ b/apps/backend/src/migrations/1770080947285-AddDonationRecurrenceFields.ts @@ -0,0 +1,52 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class AddDonationRecurrenceFields1770080947285 + implements MigrationInterface +{ + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + CREATE TYPE donation_recurrence_enum AS ENUM ( + 'none', + 'weekly', + 'monthly', + 'yearly' + ); + `); + + await queryRunner.query(` + ALTER TABLE donations + ADD COLUMN recurrence donation_recurrence_enum NOT NULL DEFAULT 'none', + ADD COLUMN recurrence_freq INTEGER, + ADD COLUMN next_donation_dates TIMESTAMP WITH TIME ZONE[], + ADD COLUMN occurrences_remaining INTEGER; + `); + + await queryRunner.query(` + ALTER TABLE donations + ADD CONSTRAINT recurrence_fields_not_null CHECK ( + (recurrence = 'none' + AND recurrence_freq IS NULL + AND next_donation_dates IS NULL + AND occurrences_remaining IS NULL) + OR + (recurrence != 'none' + AND recurrence_freq IS NOT NULL + AND next_donation_dates IS NOT NULL + AND occurrences_remaining IS NOT NULL) + ); + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE donations + DROP CONSTRAINT recurrence_fields_not_null, + DROP COLUMN recurrence, + DROP COLUMN recurrence_freq, + DROP COLUMN next_donation_dates, + DROP COLUMN occurrences_remaining; + + DROP TYPE donation_recurrence_enum; + `); + } +} 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/frontend/src/api/apiClient.ts b/apps/frontend/src/api/apiClient.ts index 7d33ae3e..4746d05b 100644 --- a/apps/frontend/src/api/apiClient.ts +++ b/apps/frontend/src/api/apiClient.ts @@ -70,6 +70,14 @@ export class ApiClient { .then((response) => response.data); } + public async getAllDonationsByFoodManufacturer( + foodManufacturerId: number, + ): Promise { + return this.axiosInstance + .get(`/api/donations/donations/${foodManufacturerId}`) + .then((response) => response.data); + } + public async fulfillDonation( donationId: number, body?: unknown, diff --git a/apps/frontend/src/app.tsx b/apps/frontend/src/app.tsx index e31dc284..5b4540d8 100644 --- a/apps/frontend/src/app.tsx +++ b/apps/frontend/src/app.tsx @@ -29,6 +29,7 @@ import { Authenticator } from '@aws-amplify/ui-react'; import { Amplify } from 'aws-amplify'; import CognitoAuthConfig from './aws-exports'; import { Button } from '@chakra-ui/react'; +import FoodManufacturerDonationManagement from '@containers/foodManufacturerDonationManagement'; Amplify.configure(CognitoAuthConfig); @@ -149,7 +150,7 @@ const router = createBrowserRouter([ path: '/donation-management', element: ( - + ), }, diff --git a/apps/frontend/src/components/forms/donationDetailsModal.tsx b/apps/frontend/src/components/forms/donationDetailsModal.tsx index 80b8d15c..98d06c13 100644 --- a/apps/frontend/src/components/forms/donationDetailsModal.tsx +++ b/apps/frontend/src/components/forms/donationDetailsModal.tsx @@ -22,6 +22,7 @@ const DonationDetailsModal: React.FC = ({ isOpen, onClose, }) => { + // TODO: We are passing in the donation, so we should not be using loadedDonation anywhere here. const [loadedDonation, setLoadedDonation] = useState(); const [items, setItems] = useState([]); diff --git a/apps/frontend/src/components/forms/newDonationFormModal.tsx b/apps/frontend/src/components/forms/newDonationFormModal.tsx index cfc22116..c312726d 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 } from '../../types/types'; +import { FoodType, FoodTypes, RecurrenceEnum } from '../../types/types'; +import { Minus } from 'lucide-react'; interface NewDonationFormModalProps { onDonationSuccess: () => void; @@ -27,6 +31,12 @@ const NewDonationFormModal: React.FC = ({ isOpen, onClose, }) => { + const RECURRENCE_MAP: Record = { + Week: RecurrenceEnum.WEEKLY, + Month: RecurrenceEnum.MONTHLY, + Year: RecurrenceEnum.YEARLY, + }; + const [rows, setRows] = useState([ { id: 1, @@ -35,14 +45,32 @@ const NewDonationFormModal: React.FC = ({ numItems: '', ozPerItem: '', valuePerItem: '', + foodRescue: false, }, ]); + const [isRecurring, setIsRecurring] = useState(false); + // Defaults for the recurring section + const [repeatEvery, setRepeatEvery] = useState('1'); + const [repeatInterval, setRepeatInterval] = useState('Week'); + const [repeatOn, setRepeatOn] = useState({ + Monday: false, + Tuesday: false, + Wednesday: true, + Thursday: false, + Friday: false, + Saturday: false, + Sunday: false, + }); + const [endsAfter, setEndsAfter] = useState('1'); + + // Totals accumulated from the item rows const [totalItems, setTotalItems] = useState(0); const [totalOz, setTotalOz] = useState(0); const [totalValue, setTotalValue] = useState(0); - const handleChange = (id: number, field: string, value: string) => { + // Adjust the appropriate field in a row and recalculate totals if needed + const handleChange = (id: number, field: string, value: string | boolean) => { const updatedRows = rows.map((row) => row.id === id ? { ...row, [field]: value } : row, ); @@ -51,6 +79,7 @@ const NewDonationFormModal: React.FC = ({ calculateTotals(updatedRows); }; + // Calculate totals based on the current rows const calculateTotals = (updatedRows: typeof rows) => { let totalItems = 0, totalOz = 0, @@ -70,27 +99,137 @@ const NewDonationFormModal: React.FC = ({ setTotalValue(parseFloat(totalValue.toFixed(2))); }; + // Adjust the repeatOn state for weekly recurrence when a day is toggled + const handleDayToggle = (day: string) => { + setRepeatOn((prev) => ({ + ...prev, + [day]: !prev[day], + })); + }; + + // Create a new row with all null values const addRow = () => { setRows([ ...rows, { + // Unique id for the row to keep track of them throughout changes id: Date.now(), foodItem: '', foodType: '', numItems: '', ozPerItem: '', valuePerItem: '', + foodRescue: false, }, ]); }; - const deleteRow = () => { - const newRows = rows.slice(0, -1); - setRows(newRows); - calculateTotals(newRows); + // Filter out the row with the matching id and recalculate totals + 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[] = []; + + // For weeks, use the repeatCount and selected days to calculate the next dates + if (repeatInterval === 'Week') { + const selectedDays = Object.keys(repeatOn).filter((day) => repeatOn[day]); + if (selectedDays.length === 0) return []; + + const dayOfWeek = today.getDay(); + const daysOfWeek = [ + 'Sunday', + 'Monday', + 'Tuesday', + 'Wednesday', + 'Thursday', + 'Friday', + 'Saturday', + ]; + + // Calculate the start of the next occurrence window + const baseWeeksToAdd = repeatCount; + const baseDaysToAdd = baseWeeksToAdd * 7; + + // If repeat is more than 1 week OR no days found this week, start from next interval + const startDay = repeatCount > 1 ? baseDaysToAdd : 1; + + // Collect all matching days in the next occurrence window + 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); + // Default the time to now + nextDate.setHours( + today.getHours(), + today.getMinutes(), + today.getSeconds(), + today.getMilliseconds(), + ); + dates.push(nextDate.toISOString()); + } + } + } else if (repeatInterval === '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 === '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; + }; + + // Get the specific text display for the next donation date based on the selected recurrence pattern + const getNextDonationDateDisplay = (): string => { + const dates = generateNextDonationDates(); + if (dates.length === 0) return ''; + + const firstDate = new Date(dates[0]); + return firstDate.toLocaleDateString('en-US', { + weekday: 'long', // Full name + year: 'numeric', // Year + month: 'long', // Full month name + day: 'numeric', // Day of the month + }); + }; + + const getSelectedDaysText = () => { + const selected = Object.keys(repeatOn).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 () => { + // Ensure all fields are filled in const hasEmpty = rows.some( (row) => !row.foodItem || @@ -99,19 +238,25 @@ const NewDonationFormModal: React.FC = ({ !row.ozPerItem || !row.valuePerItem, ); - if (hasEmpty) { alert('Please fill in all fields before submitting.'); return; } + // Create the donation first + const nextDonationDates = isRecurring ? generateNextDonationDates() : null; const donation_body = { foodManufacturerId: 1, totalItems, totalOz, totalEstimatedValue: totalValue, + recurrence: RECURRENCE_MAP[repeatInterval], + recurrenceFreq: isRecurring ? parseInt(repeatEvery) : null, + nextDonationDates: nextDonationDates, + occurrencesRemaining: isRecurring ? parseInt(endsAfter) : null, }; + // Submit all donation items at once try { const donationResponse = await ApiClient.postDonation(donation_body); const donationId = donationResponse?.donationId; @@ -123,7 +268,8 @@ const NewDonationFormModal: React.FC = ({ reservedQuantity: 0, ozPerItem: parseFloat(row.ozPerItem), estimatedValue: parseFloat(row.valuePerItem), - foodType: row.foodType, + foodType: row.foodType as FoodType, + foodRescue: row.foodRescue, })); await ApiClient.postMultipleDonationItems({ donationId, items }); @@ -137,11 +283,13 @@ const NewDonationFormModal: React.FC = ({ numItems: '', ozPerItem: '', valuePerItem: '', + foodRescue: false, }, ]); setTotalItems(0); setTotalOz(0); setTotalValue(0); + setIsRecurring(false); onClose(); } else { alert('Failed to submit donation'); @@ -151,10 +299,12 @@ const NewDonationFormModal: React.FC = ({ } }; + const isRepeatOnDisabled = repeatInterval !== 'Week'; + return ( { if (!e.open) onClose(); }} @@ -163,49 +313,269 @@ 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} - - - + + + + + setIsRecurring(e.checked)} + > + + + + + + Make Donation Recurring + + + + {isRecurring && ( + + + + + Repeat every + + + setRepeatEvery(e.value)} + min={1} + > + + + + + + setRepeatInterval(e.target.value) + } + > + + + + + + + + + + + + Repeat on + + + + + + + + + + + + + {!isRepeatOnDisabled && ( + + + + {Object.keys(repeatOn).map((day) => ( + handleDayToggle(day)} + p={2} + > + + + + + + + + {day} + + + ))} + + + + )} + + + + + + Ends after + + + setEndsAfter(e.value)} + min={1} + > + + + + + {parseInt(endsAfter) > 1 + ? 'Occurrences' + : 'Occurrence'} + + + + + {(repeatInterval === 'Week' + ? Object.values(repeatOn).some(Boolean) + : true) && ( + + Next Donation scheduled for{' '} + {getNextDonationDateDisplay()} + + )} + + )} - - 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) } @@ -213,14 +583,17 @@ const NewDonationFormModal: React.FC = ({ - + handleChange(row.id, 'foodType', e.target.value) } > - {FoodTypes.map((type) => ( ))} - - - + + + diff --git a/apps/frontend/src/containers/foodManufacturerDonationManagement.tsx b/apps/frontend/src/containers/foodManufacturerDonationManagement.tsx new file mode 100644 index 00000000..424f14c1 --- /dev/null +++ b/apps/frontend/src/containers/foodManufacturerDonationManagement.tsx @@ -0,0 +1,412 @@ +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.MATCHING]: [], + [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.MATCHING]: 1, + [DonationStatus.AVAILABLE]: 1, + [DonationStatus.FULFILLED]: 1, + }); + + const STATUS_DONATIONS = [ + DonationStatus.MATCHING, + DonationStatus.AVAILABLE, + DonationStatus.FULFILLED, + ]; + + // Color mapping for statuses + const STATUS_COLORS = new Map([ + [DonationStatus.MATCHING, ['#FEECD1', '#9C5D00']], + [DonationStatus.AVAILABLE, ['#D5DCDF', '#2B4E60']], + [DonationStatus.FULFILLED, ['#D4EAED', '#19717D']], + ]); + + 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.MATCHING]: [], + }; + + data.forEach((donation: Donation) => { + if (donation.status in grouped) { + grouped[donation.status].push(donation); + } + }); + + setStatusDonations(grouped); + + // Initialize current page for each status + const initialPages: Record = { + [DonationStatus.AVAILABLE]: 1, + [DonationStatus.FULFILLED]: 1, + [DonationStatus.MATCHING]: 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)} + /> + )} + + {STATUS_DONATIONS.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/types/types.ts b/apps/frontend/src/types/types.ts index 4b6d5052..56c49b2f 100644 --- a/apps/frontend/src/types/types.ts +++ b/apps/frontend/src/types/types.ts @@ -102,6 +102,13 @@ export enum DonationStatus { MATCHING = 'matching', } +export enum RecurrenceEnum { + NONE = 'none', + WEEKLY = 'weekly', + MONTHLY = 'monthly', + YEARLY = 'yearly', +} + export interface Donation { donationId: number; dateDonated: string; @@ -110,6 +117,10 @@ export interface Donation { totalOz: number; totalEstimatedValue: number; foodManufacturer?: FoodManufacturer; + recurrence: RecurrenceEnum; + recurrenceFreq?: number; + nextDonationDates?: string[]; + occurrencesRemaining?: number; } export interface DonationItem { @@ -121,6 +132,7 @@ export interface DonationItem { ozPerItem: number; estimatedValue: number; foodType: FoodType; + foodRescue: boolean; } export const FoodTypes = [ @@ -241,6 +253,7 @@ export interface CreateMultipleDonationItemsBody { ozPerItem: number; estimatedValue: number; foodType: FoodType; + foodRescue: boolean; }[]; }