diff --git a/apps/backend/src/users/users.controller.spec.ts b/apps/backend/src/users/users.controller.spec.ts index 3fd20753..e54045d7 100644 --- a/apps/backend/src/users/users.controller.spec.ts +++ b/apps/backend/src/users/users.controller.spec.ts @@ -6,7 +6,6 @@ import { userSchemaDto } from './dtos/userSchema.dto'; import { Test, TestingModule } from '@nestjs/testing'; import { mock } from 'jest-mock-extended'; import { UpdateUserInfoDto } from './dtos/update-user-info.dto'; -import { Pantry } from '../pantries/pantries.entity'; import { BadRequestException } from '@nestjs/common'; import { AuthenticatedRequest } from '../auth/authenticated-request'; @@ -48,13 +47,18 @@ describe('UsersController', () => { expect(controller).toBeDefined(); }); - describe('GET /my-id', () => { - it('should return the current user id', () => { - const req = { user: { id: 1 } } as AuthenticatedRequest; + describe('GET /me', () => { + it('should return the current user', async () => { + const req = { + user: { + id: 1, + }, + } as AuthenticatedRequest; - const result = controller.getCurrentUserId(req); + mockUserService.findOne.mockResolvedValueOnce(mockUser1 as User); + const result = await controller.getCurrentUser(req); - expect(result).toBe(1); + expect(result).toEqual(mockUser1); }); }); diff --git a/apps/backend/src/users/users.controller.ts b/apps/backend/src/users/users.controller.ts index bd05e123..c1f50cfa 100644 --- a/apps/backend/src/users/users.controller.ts +++ b/apps/backend/src/users/users.controller.ts @@ -8,7 +8,6 @@ import { Body, Patch, Req, - ValidationPipe, } from '@nestjs/common'; import { UsersService } from './users.service'; import { User } from './users.entity'; @@ -23,9 +22,9 @@ export class UsersController { constructor(private usersService: UsersService) {} @UseGuards(JwtAuthGuard) - @Get('/my-id') - getCurrentUserId(@Req() req: AuthenticatedRequest): number { - return req.user.id; + @Get('/me') + getCurrentUser(@Req() req: AuthenticatedRequest): Promise { + return this.usersService.findOne(req.user.id); } @Get('/:id') diff --git a/apps/frontend/src/api/apiClient.ts b/apps/frontend/src/api/apiClient.ts index 37e4ed11..c92cb45d 100644 --- a/apps/frontend/src/api/apiClient.ts +++ b/apps/frontend/src/api/apiClient.ts @@ -25,6 +25,7 @@ import { Assignments, FoodRequestSummaryDto, PantryWithUser, + UpdateProfileFields, } from 'types/types'; const defaultBaseUrl = @@ -153,10 +154,6 @@ export class ApiClient { return this.axiosInstance.post(`/api/users`, data); } - public async getPantrySSFRep(pantryId: number): Promise { - return this.get(`/api/pantries/${pantryId}/ssf-contact`) as Promise; - } - public async getAllPendingPantries(): Promise { return this.axiosInstance .get('/api/pantries/pending') @@ -195,11 +192,11 @@ export class ApiClient { return this.get(`/api/volunteers/${userId}/pantries`) as Promise; } - public async updateUserVolunteerRole( + public async updateUser( userId: number, - body: { role: string }, - ): Promise { - await this.axiosInstance.put(`/api/users/${userId}/role`, body); + fields: UpdateProfileFields, + ): Promise { + return this.axiosInstance.patch(`/api/users/${userId}`, fields); } public async getFoodRequest(requestId: number): Promise { @@ -324,9 +321,9 @@ export class ApiClient { return data as number; } - public async getMyId(): Promise { - const data = await this.get('/api/users/my-id'); - return data as number; + public async getMe(): Promise { + const data = await this.get('/api/users/me'); + return data as User; } } diff --git a/apps/frontend/src/app.tsx b/apps/frontend/src/app.tsx index d679ca6e..5ab7a281 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 FoodManufacturerApplication from '@containers/foodManufacturerApplication'; import { submitManufacturerApplicationForm } from '@components/forms/manufacturerApplicationForm'; import AssignedPantries from '@containers/volunteerAssignedPantries'; +import ProfilePage from '@containers/profilePage'; Amplify.configure(CognitoAuthConfig); @@ -186,6 +187,14 @@ const router = createBrowserRouter([ ), }, + { + path: '/profile', + element: ( + + + + ), + }, { path: '/confirm-delivery', action: submitDeliveryConfirmationFormModal, diff --git a/apps/frontend/src/components/profile/profileAccountInfo.tsx b/apps/frontend/src/components/profile/profileAccountInfo.tsx new file mode 100644 index 00000000..f1b90d80 --- /dev/null +++ b/apps/frontend/src/components/profile/profileAccountInfo.tsx @@ -0,0 +1,172 @@ +import React, { useEffect, useState } from 'react'; +import { Box, Text, SimpleGrid, Input, Button, HStack } from '@chakra-ui/react'; +import { UpdateProfileFields } from 'types/types'; + +interface AccountInfoSectionProps { + firstName: string; + lastName: string; + email: string; + phoneNumber: string; + onSave: (fields: UpdateProfileFields) => Promise; + isEditing: boolean; + onEditToggle: () => void; +} + +interface ProfileFieldProps { + label: string; + value: string; + name: string; + isEditing: boolean; + readOnly?: boolean; + onChange: (e: React.ChangeEvent) => void; +} + +const headerStyles = { + fontSize: '14px', + fontWeight: 600, + fontFamily: 'inter', + color: 'neutral.800', +}; + +const ProfileField: React.FC = ({ + label, + value, + name, + isEditing, + readOnly = false, + onChange, +}) => ( + + + {label} + + {isEditing && !readOnly ? ( + + ) : ( + + {value} + + )} + +); + +const ProfileAccountInfo: React.FC = ({ + firstName, + lastName, + email, + phoneNumber, + onSave, + isEditing, + onEditToggle, +}) => { + const [isSaving, setIsSaving] = useState(false); + const [form, setForm] = useState({ firstName, lastName, phoneNumber }); + + useEffect(() => { + setForm({ firstName, lastName, phoneNumber }); + }, [firstName, lastName, phoneNumber]); + + const handleChange = (e: React.ChangeEvent) => + setForm((prev) => ({ ...prev, [e.target.name]: e.target.value })); + + const handleCancel = () => { + setForm({ firstName, lastName, phoneNumber }); + onEditToggle(); + }; + + const handleSave = async () => { + const changed: UpdateProfileFields = {}; + if (form.firstName !== firstName) changed.firstName = form.firstName; + if (form.lastName !== lastName) changed.lastName = form.lastName; + if (form.phoneNumber !== phoneNumber) changed.phone = form.phoneNumber; + + if (Object.keys(changed).length === 0) { + onEditToggle(); + return; + } + + setIsSaving(true); + await onSave(changed); + setIsSaving(false); + onEditToggle(); + }; + + return ( + + + + + + + + + {isEditing && ( + + + + + )} + + ); +}; + +export default ProfileAccountInfo; diff --git a/apps/frontend/src/components/profile/profileLayout.tsx b/apps/frontend/src/components/profile/profileLayout.tsx new file mode 100644 index 00000000..cf65a78b --- /dev/null +++ b/apps/frontend/src/components/profile/profileLayout.tsx @@ -0,0 +1,111 @@ +import React, { useState } from 'react'; +import { Box, Tabs, HStack, Text } from '@chakra-ui/react'; +import { Pencil } from 'lucide-react'; + +interface Tab { + label: string; + content: React.ReactNode; +} + +interface ProfileLayoutProps { + leftPanel: React.ReactNode; + tabs: Tab[]; + isEditing: boolean; + onEditToggle: () => void; +} + +const EditButton: React.FC<{ + isEditing: boolean; + onEditToggle: () => void; +}> = ({ isEditing, onEditToggle }) => ( + + + + {isEditing ? 'Editing' : 'Edit'} + + +); + +const ProfileLayout: React.FC = ({ + leftPanel, + tabs, + isEditing, + onEditToggle, +}) => { + const [activeTab, setActiveTab] = useState(tabs[0].label); + + return ( + + + {leftPanel} + + + + + {tabs.length > 1 ? ( + setActiveTab(e.value)} + variant="line" + > + + + {tabs.map((tab) => ( + + {tab.label} + + ))} + + + + + {tabs.map((tab) => ( + + {tab.content} + + ))} + + ) : ( + <> + + + Account Details + + + + {tabs[0]?.content} + + )} + + + + ); +}; + +export default ProfileLayout; diff --git a/apps/frontend/src/components/profile/profileLeftPanel.tsx b/apps/frontend/src/components/profile/profileLeftPanel.tsx new file mode 100644 index 00000000..5ff4d0a1 --- /dev/null +++ b/apps/frontend/src/components/profile/profileLeftPanel.tsx @@ -0,0 +1,65 @@ +import React from 'react'; +import { VStack, Text, Button, Box } from '@chakra-ui/react'; +import { LockKeyhole } from 'lucide-react'; + +interface ProfileLeftPanelProps { + name: string; + roleLabel: string; + initials: string; + avatarBg: string; +} + +const ProfileLeftPanel: React.FC = ({ + name, + roleLabel, + initials, + avatarBg, +}) => ( + + + + {initials} + + + + + + {name} + + + + {roleLabel} + + + + + +); + +export default ProfileLeftPanel; diff --git a/apps/frontend/src/containers/adminOrderManagement.tsx b/apps/frontend/src/containers/adminOrderManagement.tsx index ebf5a866..b49fa7eb 100644 --- a/apps/frontend/src/containers/adminOrderManagement.tsx +++ b/apps/frontend/src/containers/adminOrderManagement.tsx @@ -20,7 +20,7 @@ import { CircleCheck, Search, } from 'lucide-react'; -import { capitalize, formatDate } from '@utils/utils'; +import { capitalize, formatDate, getInitials } from '@utils/utils'; import ApiClient from '@api/apiClient'; import { OrderStatus, OrderSummary } from '../types/types'; import OrderDetailsModal from '@components/forms/orderDetailsModal'; @@ -95,7 +95,7 @@ const AdminOrderManagement: React.FC = () => { const MAX_PER_STATUS = 5; - const ASSIGNEE_COLORS = ['yellow.ssf', 'red', 'cyan', 'blue.ssf']; + const ASSIGNEE_COLORS = ['yellow.ssf', 'red', 'teal.ssf', 'blue.ssf']; useEffect(() => { // Fetch all orders on component mount and sorts them into their appropriate status lists @@ -684,8 +684,10 @@ const OrderStatusSection: React.FC = ({ p={2} > {/* TODO: Change logic later to only get one volunteer */} - {volunteers[0].firstName.charAt(0).toUpperCase()} - {volunteers[0].lastName.charAt(0).toUpperCase()} + {getInitials( + volunteers[0].firstName, + volunteers[0].lastName, + )} ) : ( No Assignees diff --git a/apps/frontend/src/containers/homepage.tsx b/apps/frontend/src/containers/homepage.tsx index d37eee02..5f2d52e9 100644 --- a/apps/frontend/src/containers/homepage.tsx +++ b/apps/frontend/src/containers/homepage.tsx @@ -23,6 +23,11 @@ const Homepage: React.FC = () => { Site Navigation + + + Profile View + + Pantry View diff --git a/apps/frontend/src/containers/profilePage.tsx b/apps/frontend/src/containers/profilePage.tsx new file mode 100644 index 00000000..c178eb0b --- /dev/null +++ b/apps/frontend/src/containers/profilePage.tsx @@ -0,0 +1,132 @@ +import React, { useEffect, useState } from 'react'; +import { Box, Center, Heading, Spinner, Text } from '@chakra-ui/react'; +import ApiClient from '../api/apiClient'; +import { Role, UpdateProfileFields, User } from '../types/types'; +import ProfileLeftPanel from '@components/profile/profileLeftPanel'; +import { getInitials } from '@utils/utils'; +import ProfileAccountInfo from '@components/profile/profileAccountInfo'; +import ProfileLayout from '@components/profile/profileLayout'; +import { useAlert } from '../hooks/alert'; +import { FloatingAlert } from '@components/floatingAlert'; + +const ROLE_CONFIG: Record = { + [Role.ADMIN]: { label: 'Admin', avatarBg: 'yellow.ssf' }, + [Role.VOLUNTEER]: { label: 'Volunteer', avatarBg: 'red' }, + [Role.PANTRY]: { label: 'Pantry', avatarBg: 'blue.400' }, + [Role.FOODMANUFACTURER]: { label: 'Food Manufacturer', avatarBg: 'teal.ssf' }, +}; + +const ApplicationTabPlaceholder: React.FC = () => ( +
+ Application details coming soon. +
+); + +const ProfilePage: React.FC = () => { + const [profile, setProfile] = useState(null); + const [isLoading, setIsLoading] = useState(true); + const [isEditing, setIsEditing] = useState(false); + const [alertState, setAlertMessage] = useAlert(); + + useEffect(() => { + const fetchProfile = async () => { + try { + const user: User = await ApiClient.getMe(); + setProfile(user); + } catch { + setAlertMessage('Authentication error. Please log in and try again.'); + } finally { + setIsLoading(false); + } + }; + fetchProfile(); + }, [setAlertMessage]); + + const handleSave = async (fields: UpdateProfileFields) => { + if (!profile) return; + try { + const updated: User = await ApiClient.updateUser(profile.id, fields); + setProfile(updated); + } catch { + setAlertMessage('Profile unable to be edited. Please try again later.'); + } + }; + + if (isLoading) { + return ( +
+ +
+ ); + } + + if (!profile) { + return ( +
+ Profile not found. +
+ ); + } + + const { firstName, lastName, email, phone } = profile; + const role = profile.role; + const config = ROLE_CONFIG[role]; + + const leftPanel = ( + + ); + + const accountTab = { + label: 'Account', + showEdit: true, + content: ( + setIsEditing((e) => !e)} + /> + ), + }; + + // TODO: add application tab content + const tabs = + role === Role.PANTRY || role === Role.FOODMANUFACTURER + ? [ + accountTab, + { label: 'Application', content: }, + ] + : [accountTab]; + + return ( + + {alertState && ( + + )} + + Profile + + setIsEditing((e) => !e)} + /> + + ); +}; + +export default ProfilePage; diff --git a/apps/frontend/src/containers/volunteerAssignedPantries.tsx b/apps/frontend/src/containers/volunteerAssignedPantries.tsx index 7190d718..433fc838 100644 --- a/apps/frontend/src/containers/volunteerAssignedPantries.tsx +++ b/apps/frontend/src/containers/volunteerAssignedPantries.tsx @@ -12,7 +12,7 @@ import { Input, } from '@chakra-ui/react'; import ApiClient from '@api/apiClient'; -import { Pantry } from 'types/types'; +import { Pantry, User } from 'types/types'; import { RefrigeratedDonation } from '../types/pantryEnums'; import { FloatingAlert } from '@components/floatingAlert'; import { useNavigate } from 'react-router-dom'; @@ -31,9 +31,11 @@ const AssignedPantries: React.FC = () => { useEffect(() => { const fetchAssignedPantries = async () => { + let user: User; let userId: number; try { - userId = await ApiClient.getMyId(); + user = await ApiClient.getMe(); + userId = user.id; } catch { setAlertMessage('Authentication error. Please log in and try again.'); setIsLoading(false); diff --git a/apps/frontend/src/containers/volunteerManagement.tsx b/apps/frontend/src/containers/volunteerManagement.tsx index a597f83c..58022a20 100644 --- a/apps/frontend/src/containers/volunteerManagement.tsx +++ b/apps/frontend/src/containers/volunteerManagement.tsx @@ -18,6 +18,7 @@ import ApiClient from '@api/apiClient'; import NewVolunteerModal from '@components/forms/addNewVolunteerModal'; import { FloatingAlert } from '@components/floatingAlert'; import { useAlert } from '../hooks/alert'; +import { getInitials } from '@utils/utils'; const VolunteerManagement: React.FC = () => { const [currentPage, setCurrentPage] = useState(1); @@ -162,8 +163,7 @@ const VolunteerManagement: React.FC = () => { color="white" p={2} > - {volunteer.firstName.charAt(0).toUpperCase()} - {volunteer.lastName.charAt(0).toUpperCase()} + {getInitials(volunteer.firstName, volunteer.lastName)}
{volunteer.firstName} {volunteer.lastName} diff --git a/apps/frontend/src/theme.ts b/apps/frontend/src/theme.ts index 0b320004..900fb8bd 100644 --- a/apps/frontend/src/theme.ts +++ b/apps/frontend/src/theme.ts @@ -67,7 +67,10 @@ const customConfig = defineConfig({ textStyles, tokens: { colors: { - white: { value: '#fff' }, + white: { + value: '#fff', + core: { value: '#FEFEFE' }, + }, black: { value: '#000' }, blue: { ssf: { value: '#2B5061' }, @@ -75,6 +78,7 @@ const customConfig = defineConfig({ hover: { value: '#213C4A' }, 100: { value: '#bee3f8' }, 200: { value: '#D5DCDF' }, + 400: { value: '#AAB8BF' }, }, red: { value: '#CC3538' }, yellow: { @@ -82,7 +86,6 @@ const customConfig = defineConfig({ hover: { value: '#9C5D00' }, 200: { value: '#FEECD1' }, }, - cyan: { value: '#2795A5' }, neutral: { 50: { value: '#FAFAFA' }, 100: { value: '#E7E7E7' }, @@ -99,6 +102,7 @@ const customConfig = defineConfig({ dark: { value: '#111' }, }, teal: { + ssf: { value: '#2795A5' }, 100: { value: '#E9F4F6' }, 200: { value: '#D4EAED' }, 400: { value: '#A9D5DB' }, diff --git a/apps/frontend/src/types/types.ts b/apps/frontend/src/types/types.ts index 40a9ff21..a06dbf84 100644 --- a/apps/frontend/src/types/types.ts +++ b/apps/frontend/src/types/types.ts @@ -162,7 +162,7 @@ export enum FoodType { export interface User { id: number; - role: string; + role: Role; firstName: string; lastName: string; email: string; @@ -170,6 +170,10 @@ export interface User { pantries?: Pantry[]; } +export type UpdateProfileFields = Partial< + Pick +>; + export interface UserDto { email: string; firstName: string; diff --git a/apps/frontend/src/utils/utils.ts b/apps/frontend/src/utils/utils.ts index 4e704f80..755ab20e 100644 --- a/apps/frontend/src/utils/utils.ts +++ b/apps/frontend/src/utils/utils.ts @@ -76,3 +76,6 @@ export const generateNextDonationDate = ( } return next.toISOString(); }; + +export const getInitials = (first: string, last: string) => + `${first[0] ?? ''}${last[0] ?? ''}`.toUpperCase();