diff --git a/apps/backend/src/config/migrations.ts b/apps/backend/src/config/migrations.ts index b4c465a2f..0385198cc 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 bd9a50982..7e2e5c357 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 971ec1881..9f5b99095 100644 --- a/apps/backend/src/donations/donations.controller.spec.ts +++ b/apps/backend/src/donations/donations.controller.spec.ts @@ -2,6 +2,7 @@ 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'; const mockDonationService = mock(); @@ -38,4 +39,17 @@ describe('DonationsController', () => { expect(mockDonationService.getNumberOfDonations).toHaveBeenCalled(); }); }); + + describe('GET /:donationId', () => { + it('should return a donation for a given donation ID', async () => { + const mockDonation: Partial = { donationId: 1 }; + + mockDonationService.findOne.mockResolvedValue(mockDonation as Donation); + + const result = await controller.getDonation(1); + + expect(result).toBe(mockDonation); + expect(mockDonationService.findOne).toHaveBeenCalledWith(1); + }); + }); }); diff --git a/apps/backend/src/donations/donations.controller.ts b/apps/backend/src/donations/donations.controller.ts index e3b0ffcb6..dd662651e 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') @@ -43,15 +42,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 }, @@ -61,11 +51,18 @@ export class DonationsController { 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'], + repeatOnDays: { + type: 'object', nullable: true, + properties: { + Sunday: { type: 'boolean', example: false }, + Monday: { type: 'boolean', example: true }, + Tuesday: { type: 'boolean', example: false }, + Wednesday: { type: 'boolean', example: false }, + Thursday: { type: 'boolean', example: false }, + Friday: { type: 'boolean', example: false }, + Saturday: { type: 'boolean', example: false }, + }, }, occurrencesRemaining: { type: 'integer', example: 2, nullable: true }, }, diff --git a/apps/backend/src/donations/donations.module.ts b/apps/backend/src/donations/donations.module.ts index 55f74e60b..0b813de8f 100644 --- a/apps/backend/src/donations/donations.module.ts +++ b/apps/backend/src/donations/donations.module.ts @@ -3,17 +3,12 @@ import { TypeOrmModule } from '@nestjs/typeorm'; import { Donation } from './donations.entity'; import { DonationService } from './donations.service'; import { DonationsController } from './donations.controller'; -import { ManufacturerModule } from '../foodManufacturers/manufacturer.module'; import { AuthModule } from '../auth/auth.module'; import { FoodManufacturer } from '../foodManufacturers/manufacturers.entity'; import { DonationsSchedulerService } from './donations.scheduler'; @Module({ - imports: [ - TypeOrmModule.forFeature([Donation, FoodManufacturer]), - ManufacturerModule, - AuthModule, - ], + imports: [TypeOrmModule.forFeature([Donation, FoodManufacturer]), AuthModule], controllers: [DonationsController], providers: [DonationService, DonationsSchedulerService], }) diff --git a/apps/backend/src/donations/donations.service.spec.ts b/apps/backend/src/donations/donations.service.spec.ts index 161655368..9a7e265b5 100644 --- a/apps/backend/src/donations/donations.service.spec.ts +++ b/apps/backend/src/donations/donations.service.spec.ts @@ -5,10 +5,39 @@ import { Donation } from './donations.entity'; import { DonationService } from './donations.service'; import { mock } from 'jest-mock-extended'; import { FoodManufacturer } from '../foodManufacturers/manufacturers.entity'; +import { RecurrenceEnum, DayOfWeek } from './types'; +import { RepeatOnDaysDto } from './dtos/create-donation.dto'; const mockDonationRepository = mock>(); const mockFoodManufacturerRepository = mock>(); +const allFalse: RepeatOnDaysDto = { + Sunday: false, + Monday: false, + Tuesday: false, + Wednesday: false, + Thursday: false, + Friday: false, + Saturday: false, +}; + +// Pin "today" to a known day so tests are deterministic. +// 2025-01-06 is a Monday. +const MOCK_MONDAY = new Date('2025-01-06T12:00:00.000Z'); + +const toDayOfWeek = (iso: string): DayOfWeek => { + const days: DayOfWeek[] = [ + 'Sunday', + 'Monday', + 'Tuesday', + 'Wednesday', + 'Thursday', + 'Friday', + 'Saturday', + ]; + return days[new Date(iso).getDay()]; +}; + describe('DonationService', () => { let service: DonationService; @@ -32,12 +61,21 @@ describe('DonationService', () => { service = module.get(DonationService); }); + beforeEach(() => { + jest.useFakeTimers(); + jest.setSystemTime(MOCK_MONDAY); + }); + + afterEach(() => { + jest.useRealTimers(); + }); + it('should be defined', () => { expect(service).toBeDefined(); }); describe('getDonationCount', () => { - it.each([[0], [5]])('should return %i of donations', async (count) => { + it.each([[0], [5]])('should return %i donations', async (count) => { mockDonationRepository.count.mockResolvedValue(count); const donationCount: number = await service.getNumberOfDonations(); @@ -46,4 +84,256 @@ describe('DonationService', () => { expect(mockDonationRepository.count).toHaveBeenCalled(); }); }); + + describe('generateNextDonationDates', () => { + it('WEEKLY - returns empty array when no days are selected', async () => { + const result = await service.generateNextDonationDates( + 1, + RecurrenceEnum.WEEKLY, + allFalse, + ); + expect(result).toHaveLength(0); + }); + + it('WEEKLY - returns one date when exactly one day is selected (freq = 1)', async () => { + const repeatOnDays: RepeatOnDaysDto = { ...allFalse, Wednesday: true }; + const result = await service.generateNextDonationDates( + 1, + RecurrenceEnum.WEEKLY, + repeatOnDays, + ); + + expect(result).toHaveLength(1); + expect(toDayOfWeek(result[0])).toBe('Wednesday'); + }); + + it('WEEKLY - returns dates only for selected days within the target week window', async () => { + const repeatOnDays: RepeatOnDaysDto = { + ...allFalse, + Wednesday: true, + Friday: true, + }; + const result = await service.generateNextDonationDates( + 1, + RecurrenceEnum.WEEKLY, + repeatOnDays, + ); + + expect(result).toHaveLength(2); + const resultDays = result.map(toDayOfWeek); + expect(resultDays).toContain('Wednesday'); + expect(resultDays).toContain('Friday'); + }); + + it('WEEKLY - offsets dates correctly when freq = 2', async () => { + // Today is Monday 2025-01-06, day 14 = Monday 2025-01-20, +2 for Wed = Jan 22. + const repeatOnDays: RepeatOnDaysDto = { ...allFalse, Wednesday: true }; + const result = await service.generateNextDonationDates( + 2, + RecurrenceEnum.WEEKLY, + repeatOnDays, + ); + + expect(result).toHaveLength(1); + expect(toDayOfWeek(result[0])).toBe('Wednesday'); + + const resultDate = new Date(result[0]); + expect(resultDate.getUTCDate()).toBe(22); + expect(resultDate.getUTCMonth()).toBe(0); + }); + + it('WEEKLY - returns dates in ascending order', async () => { + const repeatOnDays: RepeatOnDaysDto = { + ...allFalse, + Tuesday: true, + Thursday: true, + Saturday: true, + }; + const result = await service.generateNextDonationDates( + 1, + RecurrenceEnum.WEEKLY, + repeatOnDays, + ); + + const timestamps = result.map((d) => new Date(d).getTime()); + expect(timestamps).toEqual([...timestamps].sort((a, b) => a - b)); + }); + + it("WEEKLY - does not include today's DOW if selected", async () => { + const repeatOnDays: RepeatOnDaysDto = { ...allFalse, Monday: true }; + const result = await service.generateNextDonationDates( + 1, + RecurrenceEnum.WEEKLY, + repeatOnDays, + ); + + expect(result.every((d) => new Date(d) > MOCK_MONDAY)).toBe(true); + }); + + it('MONTHLY - returns exactly one date', async () => { + const result = await service.generateNextDonationDates( + 1, + RecurrenceEnum.MONTHLY, + null, + ); + expect(result).toHaveLength(1); + }); + + it('MONTHLY - adds correct number of months for freq = 1', async () => { + const result = await service.generateNextDonationDates( + 1, + RecurrenceEnum.MONTHLY, + null, + ); + const resultDate = new Date(result[0]); + + // 2025-01-06 + 1 month = 2025-02-06 + expect(resultDate.getUTCFullYear()).toBe(2025); + expect(resultDate.getUTCMonth()).toBe(1); // February + expect(resultDate.getUTCDate()).toBe(6); + }); + + it('MONTHLY - adds correct number of months for freq = 3', async () => { + const result = await service.generateNextDonationDates( + 3, + RecurrenceEnum.MONTHLY, + null, + ); + const resultDate = new Date(result[0]); + + // 2025-01-06 + 3 months = 2025-04-06 + expect(resultDate.getUTCMonth()).toBe(3); // April + expect(resultDate.getUTCDate()).toBe(6); + }); + + it('MONTHLY - rolls over the year correctly', async () => { + const result = await service.generateNextDonationDates( + 12, + RecurrenceEnum.MONTHLY, + null, + ); + const resultDate = new Date(result[0]); + + // 2025-01-06 + 12 months = 2026-01-06 + expect(resultDate.getUTCFullYear()).toBe(2026); + expect(resultDate.getUTCMonth()).toBe(0); // January + }); + + it('MONTHLY - ignores repeatOnDays', async () => { + const repeatOnDays: RepeatOnDaysDto = { + ...allFalse, + Monday: true, + Friday: true, + }; + const withDays = await service.generateNextDonationDates( + 1, + RecurrenceEnum.MONTHLY, + repeatOnDays, + ); + const withoutDays = await service.generateNextDonationDates( + 1, + RecurrenceEnum.MONTHLY, + null, + ); + + expect(withDays).toEqual(withoutDays); + }); + + it('MONTHLY - clamps to 28th when today is the 29th', async () => { + jest.setSystemTime(new Date('2025-01-29T12:00:00.000Z')); + const result = await service.generateNextDonationDates( + 1, + RecurrenceEnum.MONTHLY, + null, + ); + + expect(new Date(result[0]).getUTCDate()).toBe(28); + expect(new Date(result[0]).getUTCMonth()).toBe(1); // February + }); + + it('YEARLY - returns exactly one date', async () => { + const result = await service.generateNextDonationDates( + 1, + RecurrenceEnum.YEARLY, + null, + ); + expect(result).toHaveLength(1); + }); + + it('YEARLY - adds correct number of years for freq = 1', async () => { + const result = await service.generateNextDonationDates( + 1, + RecurrenceEnum.YEARLY, + null, + ); + const resultDate = new Date(result[0]); + + // 2025-01-06 + 1 year = 2026-01-06 + expect(resultDate.getUTCFullYear()).toBe(2026); + expect(resultDate.getUTCMonth()).toBe(0); // January + expect(resultDate.getUTCDate()).toBe(6); + }); + + it('YEARLY - adds correct number of years for freq = 5', async () => { + const result = await service.generateNextDonationDates( + 5, + RecurrenceEnum.YEARLY, + null, + ); + + expect(new Date(result[0]).getUTCFullYear()).toBe(2030); + }); + + it('YEARLY - ignores repeatOnDays', async () => { + const repeatOnDays: RepeatOnDaysDto = { ...allFalse, Wednesday: true }; + const withDays = await service.generateNextDonationDates( + 1, + RecurrenceEnum.YEARLY, + repeatOnDays, + ); + const withoutDays = await service.generateNextDonationDates( + 1, + RecurrenceEnum.YEARLY, + null, + ); + + expect(withDays).toEqual(withoutDays); + }); + + it('YEARLY - clamps to 28th when today is the 29th', async () => { + jest.setSystemTime(new Date('2025-01-29T12:00:00.000Z')); + const result = await service.generateNextDonationDates( + 1, + RecurrenceEnum.YEARLY, + null, + ); + + expect(new Date(result[0]).getUTCFullYear()).toBe(2026); + expect(new Date(result[0]).getUTCDate()).toBe(28); + expect(new Date(result[0]).getUTCMonth()).toBe(0); // January + }); + + it('NONE - returns empty array', async () => { + const result = await service.generateNextDonationDates( + 1, + RecurrenceEnum.NONE, + null, + ); + expect(result).toHaveLength(0); + }); + + it('NONE - returns empty array regardless of repeatOnDays', async () => { + const repeatOnDays: RepeatOnDaysDto = { + ...allFalse, + Monday: true, + Friday: true, + }; + const result = await service.generateNextDonationDates( + 1, + RecurrenceEnum.NONE, + repeatOnDays, + ); + expect(result).toHaveLength(0); + }); + }); }); diff --git a/apps/backend/src/donations/donations.service.ts b/apps/backend/src/donations/donations.service.ts index 1dd719d58..18e389b4e 100644 --- a/apps/backend/src/donations/donations.service.ts +++ b/apps/backend/src/donations/donations.service.ts @@ -3,8 +3,8 @@ import { InjectRepository } from '@nestjs/typeorm'; import { Repository } from 'typeorm'; import { Donation } from './donations.entity'; import { validateId } from '../utils/validation.utils'; -import { DonationStatus } from './types'; -import { CreateDonationDto } from './dtos/create-donation.dto'; +import { DayOfWeek, DonationStatus, RecurrenceEnum } from './types'; +import { CreateDonationDto, RepeatOnDaysDto } from './dtos/create-donation.dto'; import { FoodManufacturer } from '../foodManufacturers/manufacturers.entity'; @Injectable() @@ -22,7 +22,6 @@ export class DonationService { where: { donationId }, relations: ['foodManufacturer'], }); - if (!donation) { throw new NotFoundException(`Donation ${donationId} not found`); } @@ -50,16 +49,26 @@ export class DonationService { `Food Manufacturer ${donationData.foodManufacturerId} not found`, ); } + + const nextDonationDates = + donationData.recurrence !== RecurrenceEnum.NONE + ? await this.generateNextDonationDates( + donationData.recurrenceFreq, + donationData.recurrence, + donationData.repeatOnDays ?? null, + ) + : null; + 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, recurrence: donationData.recurrence, recurrenceFreq: donationData.recurrenceFreq, - nextDonationDates: donationData.nextDonationDates, + nextDonationDates: nextDonationDates, occurrencesRemaining: donationData.occurrencesRemaining, }); @@ -81,4 +90,56 @@ export class DonationService { console.log('Accessing donation service from cron job'); // TODO: Implement logic for sending reminder emails } + + async generateNextDonationDates( + recurrenceFreq: number, + recurrence: RecurrenceEnum, + repeatOnDays: RepeatOnDaysDto | null, + ): Promise { + const today = new Date(); + const dates: string[] = []; + + if (recurrence === RecurrenceEnum.WEEKLY) { + const selectedDays = repeatOnDays + ? (Object.keys(repeatOnDays) as DayOfWeek[]).filter( + (day) => repeatOnDays[day], + ) + : []; + if (selectedDays.length === 0) return []; + + const daysOfWeek: DayOfWeek[] = [ + 'Sunday', + 'Monday', + 'Tuesday', + 'Wednesday', + 'Thursday', + 'Friday', + 'Saturday', + ]; + + const startDay = recurrenceFreq > 1 ? recurrenceFreq * 7 : 1; + + for (let i = startDay; i <= startDay + 6; i++) { + const nextDay = daysOfWeek[(today.getDay() + i) % 7]; + if (selectedDays.includes(nextDay)) { + const nextDate = new Date(today); + nextDate.setDate(today.getDate() + i); + dates.push(nextDate.toISOString()); + } + } + } else if (recurrence === RecurrenceEnum.MONTHLY) { + const nextDate = new Date(today); + // Date clamp if the day is later than 28th + if (nextDate.getDate() > 28) nextDate.setDate(28); + nextDate.setMonth(today.getMonth() + recurrenceFreq); + dates.push(nextDate.toISOString()); + } else if (recurrence === RecurrenceEnum.YEARLY) { + const nextDate = new Date(today); + // Date clamp if the day is later than 28th + if (nextDate.getDate() > 28) nextDate.setDate(28); + nextDate.setFullYear(today.getFullYear() + recurrenceFreq); + dates.push(nextDate.toISOString()); + } + return dates; + } } diff --git a/apps/backend/src/donations/dtos/create-donation.dto.ts b/apps/backend/src/donations/dtos/create-donation.dto.ts index 771ba2e68..43286878f 100644 --- a/apps/backend/src/donations/dtos/create-donation.dto.ts +++ b/apps/backend/src/donations/dtos/create-donation.dto.ts @@ -1,31 +1,68 @@ import { - ArrayNotEmpty, - IsArray, - IsDate, + IsBoolean, IsEnum, IsNotEmpty, IsNumber, + IsObject, IsOptional, Min, ValidateIf, + ValidateNested, + registerDecorator, } from 'class-validator'; -import { DonationStatus, RecurrenceEnum } from '../types'; +import { RecurrenceEnum } from '../types'; import { Type } from 'class-transformer'; +function AtLeastOneDaySelected() { + return function (object: object, propertyName: string) { + registerDecorator({ + name: 'atLeastOneDaySelected', + target: object.constructor, + propertyName, + validator: { + validate(value: Record) { + return !!value && Object.values(value).some((v) => v === true); + }, + }, + }); + }; +} + +export class RepeatOnDaysDto { + @IsBoolean() + @IsOptional() + Monday?: boolean; + + @IsBoolean() + @IsOptional() + Tuesday?: boolean; + + @IsBoolean() + @IsOptional() + Wednesday?: boolean; + + @IsBoolean() + @IsOptional() + Thursday?: boolean; + + @IsBoolean() + @IsOptional() + Friday?: boolean; + + @IsBoolean() + @IsOptional() + Saturday?: boolean; + + @IsBoolean() + @IsOptional() + Sunday?: boolean; +} + export class CreateDonationDto { @IsNumber() @Min(1) foodManufacturerId!: number; - @Type(() => Date) - @IsDate() - @IsNotEmpty() - dateDonated!: Date; - - @IsNotEmpty() - @IsEnum(DonationStatus) - status!: DonationStatus; - @IsNumber() @Min(1) @IsOptional() @@ -50,12 +87,12 @@ export class CreateDonationDto { @Min(1) recurrenceFreq?: number; - @Type(() => Date) - @IsArray() - @ArrayNotEmpty() - @IsDate({ each: true }) - @ValidateIf((o) => o.recurrence !== RecurrenceEnum.NONE) - nextDonationDates?: Date[]; + @IsObject() + @ValidateNested() + @Type(() => RepeatOnDaysDto) + @AtLeastOneDaySelected() + @ValidateIf((o) => o.recurrence === RecurrenceEnum.WEEKLY) + repeatOnDays?: RepeatOnDaysDto; @IsNumber() @ValidateIf((o) => o.recurrence !== RecurrenceEnum.NONE) diff --git a/apps/backend/src/donations/types.ts b/apps/backend/src/donations/types.ts index cb63fda33..f99809818 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 { @@ -10,3 +10,14 @@ export enum RecurrenceEnum { MONTHLY = 'monthly', YEARLY = 'yearly', } + +export type DayOfWeek = + | 'Monday' + | 'Tuesday' + | 'Wednesday' + | 'Thursday' + | 'Friday' + | 'Saturday' + | 'Sunday'; + +export type RepeatOnState = Record; diff --git a/apps/backend/src/foodManufacturers/manufacturer.module.ts b/apps/backend/src/foodManufacturers/manufacturer.module.ts deleted file mode 100644 index 20d30bbac..000000000 --- a/apps/backend/src/foodManufacturers/manufacturer.module.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { Module } from '@nestjs/common'; -import { TypeOrmModule } from '@nestjs/typeorm'; -import { FoodManufacturer } from './manufacturers.entity'; -import { AuthModule } from '../auth/auth.module'; - -@Module({ - imports: [TypeOrmModule.forFeature([FoodManufacturer]), AuthModule], -}) -export class ManufacturerModule {} diff --git a/apps/backend/src/foodManufacturers/manufacturers.controller.spec.ts b/apps/backend/src/foodManufacturers/manufacturers.controller.spec.ts index db068644f..3739aede0 100644 --- a/apps/backend/src/foodManufacturers/manufacturers.controller.spec.ts +++ b/apps/backend/src/foodManufacturers/manufacturers.controller.spec.ts @@ -6,6 +6,8 @@ import { FoodManufacturer } from './manufacturers.entity'; import { Allergen, DonateWastedFood } from './types'; import { ApplicationStatus } from '../shared/types'; import { FoodManufacturerApplicationDto } from './dtos/manufacturer-application.dto'; +import { Donation } from '../donations/donations.entity'; +import { DonationService } from '../donations/donations.service'; const mockManufacturersService = mock(); @@ -82,6 +84,29 @@ describe('FoodManufacturersController', () => { }); }); + describe('GET /:foodManufacturerId/donations', () => { + 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, + }, + ]; + mockManufacturersService.getFMDonations.mockResolvedValue( + mockDonations as Donation[], + ); + + const result = await controller.getFoodManufacturerDonations(1); + + expect(result).toBe(mockDonations); + expect(mockManufacturersService.getFMDonations).toHaveBeenCalledWith(1); + }); + }); + describe('POST /application', () => { it('should submit a food manufacturer application', async () => { const mockApplicationData: FoodManufacturerApplicationDto = { diff --git a/apps/backend/src/foodManufacturers/manufacturers.controller.ts b/apps/backend/src/foodManufacturers/manufacturers.controller.ts index a7e08bcbc..17e651b70 100644 --- a/apps/backend/src/foodManufacturers/manufacturers.controller.ts +++ b/apps/backend/src/foodManufacturers/manufacturers.controller.ts @@ -13,6 +13,7 @@ import { FoodManufacturer } from './manufacturers.entity'; import { FoodManufacturerApplicationDto } from './dtos/manufacturer-application.dto'; import { ApiBody } from '@nestjs/swagger'; import { Allergen, DonateWastedFood, ManufacturerAttribute } from './types'; +import { Donation } from '../donations/donations.entity'; @Controller('manufacturers') export class FoodManufacturersController { @@ -30,6 +31,13 @@ export class FoodManufacturersController { return this.foodManufacturersService.findOne(foodManufacturerId); } + @Get('/:foodManufacturerId/donations') + async getFoodManufacturerDonations( + @Param('foodManufacturerId', ParseIntPipe) foodManufacturerId: number, + ): Promise { + return this.foodManufacturersService.getFMDonations(foodManufacturerId); + } + @ApiBody({ description: 'Details for submitting a manufacturer application', schema: { diff --git a/apps/backend/src/foodManufacturers/manufacturers.module.ts b/apps/backend/src/foodManufacturers/manufacturers.module.ts index 2d9da5dc2..f21588e33 100644 --- a/apps/backend/src/foodManufacturers/manufacturers.module.ts +++ b/apps/backend/src/foodManufacturers/manufacturers.module.ts @@ -3,9 +3,10 @@ import { TypeOrmModule } from '@nestjs/typeorm'; import { FoodManufacturer } from './manufacturers.entity'; import { FoodManufacturersController } from './manufacturers.controller'; import { FoodManufacturersService } from './manufacturers.service'; +import { Donation } from '../donations/donations.entity'; @Module({ - imports: [TypeOrmModule.forFeature([FoodManufacturer])], + imports: [TypeOrmModule.forFeature([FoodManufacturer, Donation])], controllers: [FoodManufacturersController], providers: [FoodManufacturersService], }) diff --git a/apps/backend/src/foodManufacturers/manufacturers.service.ts b/apps/backend/src/foodManufacturers/manufacturers.service.ts index 22cf3e3c6..959992560 100644 --- a/apps/backend/src/foodManufacturers/manufacturers.service.ts +++ b/apps/backend/src/foodManufacturers/manufacturers.service.ts @@ -7,12 +7,15 @@ import { FoodManufacturerApplicationDto } from './dtos/manufacturer-application. import { User } from '../users/user.entity'; import { Role } from '../users/types'; import { ApplicationStatus } from '../shared/types'; +import { Donation } from '../donations/donations.entity'; @Injectable() export class FoodManufacturersService { constructor( @InjectRepository(FoodManufacturer) private repo: Repository, + @InjectRepository(Donation) + private donationsRepo: Repository, ) {} async findOne(foodManufacturerId: number): Promise { @@ -30,6 +33,25 @@ export class FoodManufacturersService { return foodManufacturer; } + async getFMDonations(foodManufacturerId: number): Promise { + validateId(foodManufacturerId, 'Food Manufacturer'); + + const manufacturer = await this.repo.findOne({ + where: { foodManufacturerId }, + }); + + if (!manufacturer) { + throw new NotFoundException( + `Food Manufacturer ${foodManufacturerId} not found`, + ); + } + + return this.donationsRepo.find({ + where: { foodManufacturer: { foodManufacturerId } }, + relations: ['foodManufacturer'], + }); + } + async getPendingManufacturers(): Promise { return await this.repo.find({ where: { status: ApplicationStatus.PENDING }, diff --git a/apps/backend/src/migrations/1770679339809-AddFoodRescueToDonationItems.ts b/apps/backend/src/migrations/1770679339809-AddFoodRescueToDonationItems.ts new file mode 100644 index 000000000..8d5f8994c --- /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 000000000..df25b6236 --- /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 808ef5107..8d89fe1cf 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 cfa2ce7b1..25a96a7ba 100644 --- a/apps/frontend/src/api/apiClient.ts +++ b/apps/frontend/src/api/apiClient.ts @@ -102,6 +102,14 @@ export class ApiClient { .then((response) => response.data); } + public async getAllDonationsByFoodManufacturer( + foodManufacturerId: number, + ): Promise { + return this.axiosInstance + .get(`/api/manufacturers/${foodManufacturerId}/donations`) + .then((response) => response.data); + } + public async fulfillDonation( donationId: number, body?: unknown, @@ -181,11 +189,11 @@ 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 { + public async getDonation(donationId: number): Promise { return this.get(`/api/donations/${donationId}`) as Promise; } diff --git a/apps/frontend/src/app.tsx b/apps/frontend/src/app.tsx index de8b9e4a7..c844de298 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'; @@ -160,10 +159,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 2e7b4e975..d4db3faea 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 a3aa1731e..e33792c73 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 11d1d9f3e..35fc13244 100644 --- a/apps/frontend/src/components/forms/newDonationFormModal.tsx +++ b/apps/frontend/src/components/forms/newDonationFormModal.tsx @@ -11,10 +11,21 @@ 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 { + DayOfWeek, + FoodType, + FoodTypes, + RecurrenceEnum, + RepeatOnState, +} from '../../types/types'; +import { Minus } from 'lucide-react'; +import { generateNextDonationDate } from '@utils/utils'; interface NewDonationFormModalProps { onDonationSuccess: () => void; @@ -29,8 +40,17 @@ interface DonationRow { numItems: string; ozPerItem: string; valuePerItem: string; + foodRescue: boolean; } +// Display labels for RecurrenceEnum values in the UI +const RECURRENCE_LABELS: Record = { + [RecurrenceEnum.NONE]: 'None', + [RecurrenceEnum.WEEKLY]: 'Week', + [RecurrenceEnum.MONTHLY]: 'Month', + [RecurrenceEnum.YEARLY]: 'Year', +}; + const NewDonationFormModal: React.FC = ({ onDonationSuccess, isOpen, @@ -44,18 +64,34 @@ const NewDonationFormModal: React.FC = ({ numItems: '', ozPerItem: '', valuePerItem: '', + foodRescue: false, }, ]); + const [isRecurring, setIsRecurring] = useState(false); + const [repeatEvery, setRepeatEvery] = useState('1'); + const [repeatInterval, setRepeatInterval] = useState( + RecurrenceEnum.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); }; @@ -64,7 +100,6 @@ const NewDonationFormModal: React.FC = ({ let totalItems = 0, totalOz = 0, totalValue = 0; - updatedRows.forEach((row) => { if (row.numItems && row.ozPerItem && row.valuePerItem) { const qty = parseInt(row.numItems); @@ -73,12 +108,15 @@ const NewDonationFormModal: React.FC = ({ totalValue += parseFloat(row.valuePerItem) * qty; } }); - setTotalItems(totalItems); setTotalOz(parseFloat(totalOz.toFixed(2))); setTotalValue(parseFloat(totalValue.toFixed(2))); }; + const handleDayToggle = (day: DayOfWeek) => { + setRepeatOn((prev) => ({ ...prev, [day]: !prev[day] })); + }; + const addRow = () => { setRows([ ...rows, @@ -89,14 +127,42 @@ 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 getNextDonationDateDisplay = (): string => { + const date = generateNextDonationDate( + repeatEvery, + repeatInterval, + repeatOn, + ); + if (!date) return ''; + return new Date(date).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(', '); + return `${selected.slice(0, 4).join(', ')} + ${selected.length - 4}`; }; const handleSubmit = async () => { @@ -108,17 +174,32 @@ const NewDonationFormModal: React.FC = ({ !row.ozPerItem || !row.valuePerItem, ); - if (hasEmpty) { alert('Please fill in all fields before submitting.'); return; } + if ( + isRecurring && + repeatInterval === RecurrenceEnum.WEEKLY && + !Object.values(repeatOn).some(Boolean) + ) { + alert('Please select at least one day for weekly recurrence.'); + return; + } + const donation_body = { foodManufacturerId: 1, totalItems, totalOz, totalEstimatedValue: totalValue, + recurrenceFreq: isRecurring ? parseInt(repeatEvery) : null, + recurrence: isRecurring ? repeatInterval : RecurrenceEnum.NONE, + repeatOnDays: + isRecurring && repeatInterval === RecurrenceEnum.WEEKLY + ? repeatOn + : null, + occurrencesRemaining: isRecurring ? parseInt(endsAfter) : null, }; try { @@ -133,6 +214,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 +228,14 @@ const NewDonationFormModal: React.FC = ({ numItems: '', ozPerItem: '', valuePerItem: '', + foodRescue: false, }, ]); setTotalItems(0); setTotalOz(0); setTotalValue(0); + setIsRecurring(false); + setRepeatInterval(RecurrenceEnum.NONE); onClose(); } else { alert('Failed to submit donation'); @@ -160,10 +245,19 @@ const NewDonationFormModal: React.FC = ({ } }; + const isRepeatOnDisabled = repeatInterval !== RecurrenceEnum.WEEKLY; + + const placeholderStyles = { + color: 'neutral.300', + fontFamily: 'inter', + fontSize: 'sm', + fontWeight: '400', + }; + return ( { if (!e.open) onClose(); }} @@ -172,48 +266,141 @@ 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); + setRepeatInterval( + e.checked + ? RecurrenceEnum.WEEKLY + : RecurrenceEnum.NONE, + ); + }} + > + + + + + + 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 +409,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 RecurrenceEnum, + ) + } + > + {(Object.values(RecurrenceEnum) as RecurrenceEnum[]) + .filter((v) => v !== RecurrenceEnum.NONE) + .map((v) => ( + + ))} + + + + + + + + + Repeat on + + + {!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 !== RecurrenceEnum.WEEKLY || + Object.values(repeatOn).some(Boolean)) && ( + + Next Donation scheduled for {getNextDonationDateDisplay()} + + )} + + )} + + + + diff --git a/apps/frontend/src/containers/adminOrderManagement.tsx b/apps/frontend/src/containers/adminOrderManagement.tsx index 6edc92894..849e25b9a 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 000000000..6e2c3aa49 --- /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 3de7f7d3a..74604c688 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 791737873..ca831b9a1 100644 --- a/apps/frontend/src/containers/signupPage.tsx +++ b/apps/frontend/src/containers/signupPage.tsx @@ -34,7 +34,7 @@ const SignupPage: React.FC = () => {