diff --git a/apps/backend/src/foodManufacturers/dtos/update-manufacturer-application.dto.ts b/apps/backend/src/foodManufacturers/dtos/update-manufacturer-application.dto.ts index 58d7f70fa..a1078bdd0 100644 --- a/apps/backend/src/foodManufacturers/dtos/update-manufacturer-application.dto.ts +++ b/apps/backend/src/foodManufacturers/dtos/update-manufacturer-application.dto.ts @@ -26,7 +26,10 @@ export class UpdateFoodManufacturerApplicationDto { secondaryContactLastName?: string; @IsOptional() - @IsEmail() + @IsEmail( + {}, + { message: 'Secondary contact email must be a valid email address.' }, + ) @IsNotEmpty() @MaxLength(255) secondaryContactEmail?: string; diff --git a/apps/backend/src/foodManufacturers/manufacturers.controller.spec.ts b/apps/backend/src/foodManufacturers/manufacturers.controller.spec.ts index 204d32bd3..bc86024a5 100644 --- a/apps/backend/src/foodManufacturers/manufacturers.controller.spec.ts +++ b/apps/backend/src/foodManufacturers/manufacturers.controller.spec.ts @@ -246,4 +246,23 @@ describe('FoodManufacturersController', () => { expect(mockManufacturersService.deny).toHaveBeenCalledWith(1); }); }); + + describe('getCurrentUserFoodManufacturerId', () => { + it('returns foodManufacturerId for authenticated user', async () => { + const req = { user: { id: 1 } }; + const manufacturer: Partial = { + foodManufacturerId: 10, + }; + mockManufacturersService.findByUserId.mockResolvedValueOnce( + manufacturer as FoodManufacturer, + ); + + const result = await controller.getCurrentUserFoodManufacturerId( + req as AuthenticatedRequest, + ); + + expect(result).toEqual(10); + expect(mockManufacturersService.findByUserId).toHaveBeenCalledWith(1); + }); + }); }); diff --git a/apps/backend/src/foodManufacturers/manufacturers.controller.ts b/apps/backend/src/foodManufacturers/manufacturers.controller.ts index 198fcf412..8f2b352f3 100644 --- a/apps/backend/src/foodManufacturers/manufacturers.controller.ts +++ b/apps/backend/src/foodManufacturers/manufacturers.controller.ts @@ -30,6 +30,17 @@ export class FoodManufacturersController { return this.foodManufacturersService.getPendingManufacturers(); } + @Roles(Role.FOODMANUFACTURER) + @Get('/my-id') + async getCurrentUserFoodManufacturerId( + @Req() req: AuthenticatedRequest, + ): Promise { + const manufacturer = await this.foodManufacturersService.findByUserId( + req.user.id, + ); + return manufacturer.foodManufacturerId; + } + @Get('/:foodManufacturerId') async getFoodManufacturer( @Param('foodManufacturerId', ParseIntPipe) foodManufacturerId: number, diff --git a/apps/backend/src/foodManufacturers/manufacturers.service.spec.ts b/apps/backend/src/foodManufacturers/manufacturers.service.spec.ts index 12997849e..4cd5c221a 100644 --- a/apps/backend/src/foodManufacturers/manufacturers.service.spec.ts +++ b/apps/backend/src/foodManufacturers/manufacturers.service.spec.ts @@ -531,6 +531,21 @@ describe('FoodManufacturersService', () => { }); }); + describe('findByUserId', () => { + it('findByUserId success', async () => { + const manufacturer = await service.findOne(1); + const userId = manufacturer.foodManufacturerRepresentative.id; + const result = await service.findByUserId(userId); + expect(result.foodManufacturerId).toBe(1); + }); + + it('findByUserId with non-existent user throws NotFoundException', async () => { + await expect(service.findByUserId(9999)).rejects.toThrow( + new NotFoundException('Food Manufacturer for User 9999 not found'), + ); + }); + }); + describe('getStats', () => { it('returns proper stats for manufacturer', async () => { const manufacturerId = 1; diff --git a/apps/backend/src/foodManufacturers/manufacturers.service.ts b/apps/backend/src/foodManufacturers/manufacturers.service.ts index f3fae07d7..cd04726a0 100644 --- a/apps/backend/src/foodManufacturers/manufacturers.service.ts +++ b/apps/backend/src/foodManufacturers/manufacturers.service.ts @@ -57,6 +57,21 @@ export class FoodManufacturersService { return foodManufacturer; } + async findByUserId(userId: number): Promise { + validateId(userId, 'User'); + + const manufacturer = await this.repo.findOne({ + where: { foodManufacturerRepresentative: { id: userId } }, + }); + + if (!manufacturer) { + throw new NotFoundException( + `Food Manufacturer for User ${userId} not found`, + ); + } + return manufacturer; + } + async getFMDonations( foodManufacturerId: number, currentUserId: number, diff --git a/apps/backend/src/pantries/dtos/update-pantry-application.dto.ts b/apps/backend/src/pantries/dtos/update-pantry-application.dto.ts index efb1d9f6b..a5325e1ff 100644 --- a/apps/backend/src/pantries/dtos/update-pantry-application.dto.ts +++ b/apps/backend/src/pantries/dtos/update-pantry-application.dto.ts @@ -33,7 +33,10 @@ export class UpdatePantryApplicationDto { secondaryContactLastName?: string; @IsOptional() - @IsEmail() + @IsEmail( + {}, + { message: 'Secondary contact email must be a valid email address.' }, + ) @IsNotEmpty() @MaxLength(255) secondaryContactEmail?: string; @@ -42,7 +45,7 @@ export class UpdatePantryApplicationDto { @IsString() @IsPhoneNumber('US', { message: - 'secondaryContactPhone must be a valid phone number (make sure all the digits are correct)', + 'Secondary contact phone must be a valid phone number (make sure all the digits are correct)', }) @IsNotEmpty() secondaryContactPhone?: string; @@ -132,6 +135,15 @@ export class UpdatePantryApplicationDto { @MaxLength(255, { each: true }) restrictions?: string[]; + @IsBoolean() + @IsOptional() + acceptFoodDeliveries?: boolean; + + @IsOptional() + @IsString() + @IsNotEmpty() + deliveryWindowInstructions?: string; + @IsEnum(RefrigeratedDonation) @IsOptional() refrigeratedDonation?: RefrigeratedDonation; @@ -140,6 +152,10 @@ export class UpdatePantryApplicationDto { @IsOptional() reserveFoodForAllergic?: ReserveFoodForAllergic; + @IsOptional() + @IsString() + reservationExplanation?: string | null; + @IsBoolean() @IsOptional() dedicatedAllergyFriendly?: boolean; diff --git a/apps/backend/src/pantries/pantries.controller.ts b/apps/backend/src/pantries/pantries.controller.ts index ae038ea23..e6d04f282 100644 --- a/apps/backend/src/pantries/pantries.controller.ts +++ b/apps/backend/src/pantries/pantries.controller.ts @@ -368,7 +368,7 @@ export class PantriesController { } @Roles(Role.PANTRY) - @Patch('/:pantryId/update') + @Patch('/:pantryId/application') async updatePantryApplication( @Req() req: AuthenticatedRequest, @Param('pantryId', ParseIntPipe) pantryId: number, diff --git a/apps/frontend/src/api/apiClient.ts b/apps/frontend/src/api/apiClient.ts index 26ef842ae..ffed2ae0d 100644 --- a/apps/frontend/src/api/apiClient.ts +++ b/apps/frontend/src/api/apiClient.ts @@ -31,6 +31,8 @@ import { TotalStats, CreateDonationDto, UpdateProfileFields, + UpdatePantryApplicationDto, + UpdateFoodManufacturerApplicationDto, MatchingManufacturersDto, MatchingItemsDto, CreateOrderDto, @@ -379,6 +381,15 @@ export class ApiClient { }); } + public async updatePantryApplicationData( + pantryId: number, + data: UpdatePantryApplicationDto, + ): Promise { + return this.axiosInstance + .patch(`/api/pantries/${pantryId}/application`, data) + .then((response) => response.data); + } + public async createOrder( dto: CreateOrderDto, ): Promise { @@ -436,6 +447,21 @@ export class ApiClient { .then((response) => response.data); } + public async getCurrentUserFoodManufacturerId(): Promise { + return this.axiosInstance + .get('/api/manufacturers/my-id') + .then((response) => response.data); + } + + public async updateFoodManufacturerApplicationData( + manufacturerId: number, + data: UpdateFoodManufacturerApplicationDto, + ): Promise { + return this.axiosInstance + .patch(`/api/manufacturers/${manufacturerId}/application`, data) + .then((response) => response.data); + } + public async getMe(): Promise { return this.axiosInstance .get('/api/users/me') diff --git a/apps/frontend/src/components/editableComponents.tsx b/apps/frontend/src/components/editableComponents.tsx new file mode 100644 index 000000000..464bddaa6 --- /dev/null +++ b/apps/frontend/src/components/editableComponents.tsx @@ -0,0 +1,377 @@ +import React from 'react'; +import { + Box, + Text, + Input, + Textarea, + RadioGroup, + Stack, + NativeSelect, + NativeSelectIndicator, + Menu, + Button, + Grid, +} from '@chakra-ui/react'; +import { ChevronDownIcon } from 'lucide-react'; +import { TagGroup } from '@components/forms/tagGroup'; + +export const fieldHeaderStyles = { + fontSize: '14px', + color: 'neutral.800', + fontWeight: 600, + mb: 1, +}; + +export const fieldContentStyles = { + fontSize: '14px', + color: 'neutral.800', +}; + +export const sectionLabelStyles = { + fontSize: '16px', + fontWeight: 600, + fontFamily: 'inter', + color: 'neutral.800', + mb: 8, +}; + +export const inputStyles = { + borderColor: 'neutral.100', + color: 'neutral.600', + size: 'sm' as const, +}; + +interface SectionProps { + title: string; + children: React.ReactNode; +} +export const Section: React.FC = ({ title, children }) => ( + + {title} + {children} + +); + +interface FieldProps { + label: string; + value?: string | null; + fallback?: string; +} +export const Field: React.FC = ({ + label, + value, + fallback = '-', +}) => ( + + {label} + {value || fallback} + +); + +interface EditFieldProps { + label: string; + name: string; + value: string; + onChange: (v: string) => void; + textarea?: boolean; + helperText?: string; + required?: boolean; +} +export const EditField: React.FC = ({ + label, + name, + value, + onChange, + textarea, + helperText, + required, +}) => ( + + + {label} + {required && ( + + * + + )} + + {textarea ? ( +