From 95628a12182badb1f197fcf8d122000845fcbff6 Mon Sep 17 00:00:00 2001 From: Ethan Chen Date: Thu, 9 Oct 2025 21:42:10 -0400 Subject: [PATCH 01/18] create foundation for admin directory page Co-authored-by: Ryan Gunawan --- backend/app/routes/user.py | 3 +- .../admin/DirectoryDataProvider.tsx | 30 ++ .../ui/directory-progress-slider.tsx | 72 ++++ frontend/src/pages/admin/directory.tsx | 337 ++++++++++++++++++ 4 files changed, 441 insertions(+), 1 deletion(-) create mode 100644 frontend/src/components/admin/DirectoryDataProvider.tsx create mode 100644 frontend/src/components/ui/directory-progress-slider.tsx create mode 100644 frontend/src/pages/admin/directory.tsx diff --git a/backend/app/routes/user.py b/backend/app/routes/user.py index eec68444..cd199c41 100644 --- a/backend/app/routes/user.py +++ b/backend/app/routes/user.py @@ -44,7 +44,8 @@ async def create_user( async def get_users( admin: Optional[bool] = Query(False, description="If true, returns admin users only"), user_service: UserService = Depends(get_user_service), - authorized: bool = has_roles([UserRole.ADMIN]), + # authorized: bool = has_roles([UserRole.ADMIN]), + authorized: bool = True, ): try: if admin: diff --git a/frontend/src/components/admin/DirectoryDataProvider.tsx b/frontend/src/components/admin/DirectoryDataProvider.tsx new file mode 100644 index 00000000..54199b58 --- /dev/null +++ b/frontend/src/components/admin/DirectoryDataProvider.tsx @@ -0,0 +1,30 @@ +import baseAPIClient from '@/APIClients/baseAPIClient'; +import { useEffect, useState, ReactNode } from 'react'; + +interface DirectoryDataProviderProps { + children: (users: any[], loading: boolean, error: Error | null) => ReactNode; +} + +export function DirectoryDataProvider({ children }: DirectoryDataProviderProps) { + const [users, setUsers] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + useEffect(() => { + const fetchUsers = async () => { + try { + const response = await baseAPIClient.get('/users'); + setUsers(response.data.users || response.data); + } catch (err) { + setError(err as Error); + console.error('Failed to fetch users:', err); + } finally { + setLoading(false); + } + }; + + fetchUsers(); + }, []); + + return <>{children(users, loading, error)}; +} diff --git a/frontend/src/components/ui/directory-progress-slider.tsx b/frontend/src/components/ui/directory-progress-slider.tsx new file mode 100644 index 00000000..11effa4a --- /dev/null +++ b/frontend/src/components/ui/directory-progress-slider.tsx @@ -0,0 +1,72 @@ +import React from 'react'; +import { Box, Flex, Text } from '@chakra-ui/react'; +import { FiCheck } from 'react-icons/fi'; + +interface DirectoryProgressSliderProps { + value: number; // 0-100 +} + +export const DirectoryProgressSlider: React.FC = ({ value }) => { + const milestones = [25, 50, 75, 100]; + + return ( + + + {/* Track */} + + + {/* Filled Track */} + + + {/* Milestone Markers */} + + {milestones.map((milestone) => { + const isCompleted = value >= milestone; + const isCurrent = value < milestone && value >= (milestones[milestones.indexOf(milestone) - 1] || 0); + + return ( + + {isCompleted && ( + + )} + + ); + })} + + + + {/* Percentage Display */} + + {value}% + + + ); +}; diff --git a/frontend/src/pages/admin/directory.tsx b/frontend/src/pages/admin/directory.tsx new file mode 100644 index 00000000..28dc21f0 --- /dev/null +++ b/frontend/src/pages/admin/directory.tsx @@ -0,0 +1,337 @@ +import React, { useState } from 'react'; +import { Box, Flex, Heading, Text, Table, IconButton, Input, Badge } from '@chakra-ui/react'; +import { FiSearch, FiMenu } from 'react-icons/fi'; +import { ProtectedPage } from '@/components/auth/ProtectedPage'; +import { UserRole } from '@/types/authTypes'; +import { Checkbox } from '@/components/ui/checkbox'; +import { DirectoryProgressSlider } from '@/components/ui/directory-progress-slider'; +import { DirectoryDataProvider } from '@/components/admin/DirectoryDataProvider'; +import { + MenuContent, + MenuItem, + MenuRoot, + MenuTrigger, +} from '@chakra-ui/react'; +import { + LightMode +} from "@/components/ui/color-mode" + +const veniceBlue = '#1d3448'; + +type FormStatus = + | 'intake-todo' + | 'intake-submitted' + | 'ranking-todo' + | 'ranking-submitted' + | 'secondary-application-todo' + | 'secondary-application-submitted'; + +interface DirectoryUser { + id: number; + firstName?: string; + lastName?: string; + email?: string; + roleId: number; + formStatus: FormStatus; +} +// Mock data - replace with API call +// const mockUsers = [ +// { id: '1', name: 'Randy Philips', language: 'English', assigned: 'Volunteer', status: '0%', progress: 0, currentStep: 'Intake form' }, +// { id: '2', name: 'Ann Vaccaro', language: 'English', assigned: 'Volunteer', status: '0%', progress: 0, currentStep: 'Intake form' }, +// { id: '3', name: 'Kaylynn Dias', language: 'French', assigned: 'Participant', status: '0%', progress: 0, currentStep: 'Intake form' }, +// { id: '4', name: 'Kierra Calzoni', language: 'English', assigned: 'Volunteer', status: '25%', progress: 25, currentStep: 'Screen calling' }, +// { id: '5', name: 'Terry Baptista', language: 'French', assigned: 'Participant', status: '50%', progress: 50, currentStep: 'Screen calling' }, +// { id: '6', name: 'Kaylynn Curtis', language: 'English', assigned: 'Volunteer', status: '25%', progress: 25, currentStep: 'Intake form' }, +// { id: '7', name: 'Livia Siphron', language: 'French', assigned: 'Participant', status: '25%', progress: 25, currentStep: 'Screen calling' }, +// { id: '8', name: 'James Levin', language: 'French', assigned: 'Participant', status: '100%', progress: 100, currentStep: 'Ranking' }, +// { id: '9', name: 'Desirae Dias', language: 'French', assigned: 'Participant', status: '100%', progress: 100, currentStep: 'Ranking' }, +// { id: '10', name: 'Desirae Franci', language: 'English', assigned: 'Volunteer', status: '100%', progress: 100, currentStep: 'Secondary application' }, +// { id: '11', name: 'Lincoln Rosser', language: 'English', assigned: 'Volunteer', status: '100%', progress: 100, currentStep: 'Secondary application' }, +// { id: '12', name: 'Gretchen Carder', language: 'French', assigned: 'Participant', status: '0%', progress: 0, currentStep: 'Matched' }, +// { id: '13', name: 'Miracle Kenter', language: 'English', assigned: 'Volunteer', status: '0%', progress: 0, currentStep: 'Training' }, +// { id: '14', name: 'Aspen Vaccaro', language: 'French', assigned: 'Participant', status: '0%', progress: 0, currentStep: 'Rejected' }, +// { id: '15', name: 'Kierra Boscch', language: 'English', assigned: 'Volunteer', status: '0%', progress: 0, currentStep: 'Rejected' }, +// ]; + +const formStatusMap: Record = { + 'intake-todo': { + label: 'Intake form', + progress: 25 + }, + 'intake-submitted': { + label: 'Screen calling', + progress: 50 + }, + 'ranking-todo': { + label: 'Ranking', + progress: 75 + }, + 'ranking-submitted': { + label: 'Matched', + progress: 100 + }, + 'secondary-application-todo': { + label: 'Secondary Application', + progress: 75, + }, + 'secondary-application-submitted': { + label: 'Training', + progress: 100, + } +} + +const getStatusColor = (step: string) => { + const lowerStep = step.toLowerCase(); + if (lowerStep.includes('training')) return 'blue'; + if (lowerStep.includes('rejected')) return 'red'; + if (lowerStep.includes('matched')) return 'green'; + if (lowerStep.includes('ranking')) return 'teal'; + if (lowerStep.includes('secondary')) return 'purple'; + if (lowerStep.includes('screen')) return 'orange'; + return 'gray'; +}; + +export default function Directory() { + const [searchQuery, setSearchQuery] = useState(''); + const [selectedUsers, setSelectedUsers] = useState>(new Set()); + const [statusFilter, setStatusFilter] = useState(null); + const [sortBy, setSortBy] = useState<'nameAsc' | 'nameDsc' | 'statusAsc' | 'statusDsc'>('nameAsc'); + + return ( + + + {(users, loading, error) => { + const filteredUsers = users.filter((user: any) => { + const fullName = `${user.first_name || ''} ${user.last_name || ''}`.trim().toLowerCase(); + const matchesSearch = fullName.includes(searchQuery.toLowerCase()) || + user.email?.toLowerCase().includes(searchQuery.toLowerCase()); + const matchesStatus = !statusFilter || user.form_status === statusFilter; + return matchesSearch && matchesStatus; + }); + + const handleSelectAll = (e: any) => { + if (e.checked) { + setSelectedUsers(new Set(filteredUsers.map((u: any) => u.id))); + } else { + setSelectedUsers(new Set()); + } + }; + + const handleSelectUser = (userId: string, checked: boolean) => { + const newSelected = new Set(selectedUsers); + if (checked) { + newSelected.add(userId); + } else { + newSelected.delete(userId); + } + setSelectedUsers(newSelected); + }; + + return ( + + + + Directory + + + {/* Search and Actions Bar */} + + + + + + setSearchQuery(e.target.value)} + /> + + + + + + + + + + setStatusFilter(null)}> + All + + setStatusFilter('Intake form')}> + Intake form + + setStatusFilter('Screen calling')}> + Screen calling + + setStatusFilter('Ranking')}> + Ranking + + setStatusFilter('Secondary application')}> + Secondary application + + setStatusFilter('Matched')}> + Matched + + setStatusFilter('Training')}> + Training + + setStatusFilter('Rejected')}> + Rejected + + + + + + {/* Table */} + + + + + + 0 && selectedUsers.size === filteredUsers.length} + onCheckedChange={handleSelectAll} + /> + + { + if (sortBy == 'nameDsc') { + setSortBy("nameAsc") + } else { + setSortBy('nameDsc') + } + }}>Name {sortBy == 'nameAsc' ? '↑' : '↓'} + Language + Assigned + { + if (sortBy == 'statusDsc') { + setSortBy("statusAsc") + } else { + setSortBy('statusDsc') + } + }}>Status {sortBy == 'statusAsc' ? '↑' : '↓'} + {/* Search and Actions Bar */} + + + + + + setSearchQuery(e.target.value)} + /> + + + + + + + + + + setStatusFilter(null)}> + All + + setStatusFilter('Intake form')}> + Intake form + + setStatusFilter('Screen calling')}> + Screen calling + + setStatusFilter('Ranking')}> + Ranking + + setStatusFilter('Secondary application')}> + Secondary application + + setStatusFilter('Matched')}> + Matched + + setStatusFilter('Training')}> + Training + + setStatusFilter('Rejected')}> + Rejected + + + + + + + + + {filteredUsers.map((user: DirectoryUser) => { + console.log(user); + const displayName = `${user.firstName || ''} ${user.lastName || ''}`.trim(); + const roleName = (user.roleId === 2 ? 'Volunteer' : 'Participant'); + + return ( + + + handleSelectUser(user.id.toString(), !!e.checked)} + /> + + {displayName} + + + English + + + + + {roleName} + + + + + + + + {formStatusMap[user.formStatus]?.label || 'intake-submitted'} + + + + ); + })} + + + + + + ); + }} + + + ); +} From 99f40c2ccca8c327c4a6c01e57578faf029f8cd5 Mon Sep 17 00:00:00 2001 From: Ethan Chen Date: Mon, 27 Oct 2025 20:51:32 -0400 Subject: [PATCH 02/18] add designated status property for FormStatus --- frontend/src/pages/admin/directory.tsx | 476 ++++++++++++++++++------- 1 file changed, 347 insertions(+), 129 deletions(-) diff --git a/frontend/src/pages/admin/directory.tsx b/frontend/src/pages/admin/directory.tsx index 28dc21f0..a86f79df 100644 --- a/frontend/src/pages/admin/directory.tsx +++ b/frontend/src/pages/admin/directory.tsx @@ -1,6 +1,6 @@ import React, { useState } from 'react'; -import { Box, Flex, Heading, Text, Table, IconButton, Input, Badge } from '@chakra-ui/react'; -import { FiSearch, FiMenu } from 'react-icons/fi'; +import { Box, Flex, Heading, Text, Table, IconButton, Input, Badge, Button, VStack } from '@chakra-ui/react'; +import { FiSearch, FiMenu, FiMail, FiFolder, FiLoader } from 'react-icons/fi'; import { ProtectedPage } from '@/components/auth/ProtectedPage'; import { UserRole } from '@/types/authTypes'; import { Checkbox } from '@/components/ui/checkbox'; @@ -8,15 +8,45 @@ import { DirectoryProgressSlider } from '@/components/ui/directory-progress-slid import { DirectoryDataProvider } from '@/components/admin/DirectoryDataProvider'; import { MenuContent, - MenuItem, MenuRoot, MenuTrigger, } from '@chakra-ui/react'; import { LightMode } from "@/components/ui/color-mode" +import { COLORS } from '@/constants/form'; -const veniceBlue = '#1d3448'; +// Directory-specific colors from Figma design system +const DIRECTORY_COLORS = { + // Language badges + languageEnglishBg: '#EEEEEC', + languageEnglishText: '#414651', + languageFrenchBg: '#EDF6FD', + languageFrenchText: '#2171AB', + + // Role badges + roleVolunteerBg: '#FCCEEE', + roleVolunteerText: '#C11574', + roleParticipantBg: '#D9D6FE', + roleParticipantText: '#5925DC', + + // Status badges + statusSuccessBg: '#ECFDF3', + statusSuccessText: '#027A48', + statusRejectedBg: 'rgba(232, 188, 189, 0.3)', + statusRejectedText: '#B42318', + statusDefaultBg: '#F2F4F7', + statusDefaultText: '#344054', + + // Borders + tableBorder: '#D5D7DA', + + // Navbar + navbarGray: '#414651', // Gray/700 + iconGray: '#181D27', // Gray/900 + menuButtonBg: '#EAEAE6', + applyButtonBg: '#056067', // Teal from Figma +} as const; type FormStatus = | 'intake-todo' @@ -24,7 +54,9 @@ type FormStatus = | 'ranking-todo' | 'ranking-submitted' | 'secondary-application-todo' - | 'secondary-application-submitted'; + | 'secondary-application-submitted' + | 'completed' + | 'rejected'; interface DirectoryUser { id: number; @@ -53,50 +85,90 @@ interface DirectoryUser { // { id: '15', name: 'Kierra Boscch', language: 'English', assigned: 'Volunteer', status: '0%', progress: 0, currentStep: 'Rejected' }, // ]; -const formStatusMap: Record = { +const formStatusMap: Record = { 'intake-todo': { + status: 'Not started', label: 'Intake form', - progress: 25 + progress: 0, }, 'intake-submitted': { + status: 'In-progress', label: 'Screen calling', - progress: 50 + progress: 25, }, 'ranking-todo': { + status: 'In-progress', label: 'Ranking', - progress: 75 + progress: 50, }, 'ranking-submitted': { + status: 'In-progress', label: 'Matched', - progress: 100 + progress: 75, }, 'secondary-application-todo': { + status: 'In-progress', label: 'Secondary Application', - progress: 75, + progress: 50, }, 'secondary-application-submitted': { + status: 'In-progress', label: 'Training', + progress: 75, + }, + 'completed': { + status: 'Completed', + label: 'Completed', progress: 100, + }, + 'rejected': { + status: 'Rejected', + label: 'Rejected', + progress: 0, } } -const getStatusColor = (step: string) => { +const getStatusColor = (step: string): { bg: string; color: string } => { const lowerStep = step.toLowerCase(); - if (lowerStep.includes('training')) return 'blue'; - if (lowerStep.includes('rejected')) return 'red'; - if (lowerStep.includes('matched')) return 'green'; - if (lowerStep.includes('ranking')) return 'teal'; - if (lowerStep.includes('secondary')) return 'purple'; - if (lowerStep.includes('screen')) return 'orange'; - return 'gray'; + if (lowerStep.includes('rejected')) return { bg: DIRECTORY_COLORS.statusRejectedBg, color: DIRECTORY_COLORS.statusRejectedText }; + return { bg: DIRECTORY_COLORS.statusSuccessBg, color: DIRECTORY_COLORS.statusSuccessText }; }; export default function Directory() { const [searchQuery, setSearchQuery] = useState(''); const [selectedUsers, setSelectedUsers] = useState>(new Set()); - const [statusFilter, setStatusFilter] = useState(null); const [sortBy, setSortBy] = useState<'nameAsc' | 'nameDsc' | 'statusAsc' | 'statusDsc'>('nameAsc'); + // Filter state + const [userTypeFilters, setUserTypeFilters] = useState({ + participant: false, + volunteer: false + }); + const [statusFilters, setStatusFilters] = useState({ + notStarted: false, + inProgress: false, + completed: false, + rejected: false + }); + + // Applied filters (only update on "Apply" click) + const [appliedUserTypeFilters, setAppliedUserTypeFilters] = useState(userTypeFilters); + const [appliedStatusFilters, setAppliedStatusFilters] = useState(statusFilters); + + const handleApplyFilters = () => { + setAppliedUserTypeFilters(userTypeFilters); + setAppliedStatusFilters(statusFilters); + }; + + const handleClearFilters = () => { + const clearedUserTypes = { participant: false, volunteer: false }; + const clearedStatuses = { notStarted: false, inProgress: false, completed: false, rejected: false }; + setUserTypeFilters(clearedUserTypes); + setStatusFilters(clearedStatuses); + setAppliedUserTypeFilters(clearedUserTypes); + setAppliedStatusFilters(clearedStatuses); + }; + return ( @@ -105,8 +177,24 @@ export default function Directory() { const fullName = `${user.first_name || ''} ${user.last_name || ''}`.trim().toLowerCase(); const matchesSearch = fullName.includes(searchQuery.toLowerCase()) || user.email?.toLowerCase().includes(searchQuery.toLowerCase()); - const matchesStatus = !statusFilter || user.form_status === statusFilter; - return matchesSearch && matchesStatus; + + // User type filtering + const hasUserTypeFilter = appliedUserTypeFilters.participant || appliedUserTypeFilters.volunteer; + const matchesUserType = !hasUserTypeFilter || + (appliedUserTypeFilters.participant && user.roleId === 1) || + (appliedUserTypeFilters.volunteer && user.roleId === 2); + + // Status filtering + const hasStatusFilter = appliedStatusFilters.notStarted || appliedStatusFilters.inProgress || + appliedStatusFilters.completed || appliedStatusFilters.rejected; + const userStatus = user.formStatus && formStatusMap[user.formStatus as FormStatus]?.status; + const matchesStatus = !hasStatusFilter || + (appliedStatusFilters.notStarted && userStatus === 'Not started') || + (appliedStatusFilters.inProgress && userStatus === 'In-progress') || + (appliedStatusFilters.completed && userStatus === 'Completed') || + (appliedStatusFilters.rejected && userStatus === 'Rejected'); + + return matchesSearch && matchesUserType && matchesStatus; }); const handleSelectAll = (e: any) => { @@ -129,19 +217,80 @@ export default function Directory() { return ( - + {/* Navbar */} + + {/* Logo */} + + {/* Placeholder for logo - replace with actual logo */} + + LLSC + + + + {/* Navigation Items */} + + + + + Task List + + + + + + Directory + + + + + + {/* Main Content */} + Directory - {/* Search and Actions Bar */} + {/* Search Bar */} @@ -155,124 +304,186 @@ export default function Directory() { onChange={(e) => setSearchQuery(e.target.value)} /> + + {/* Table */} + + {/* Icon Group - positioned at top right of table header */} - - - + + + - - - setStatusFilter(null)}> - All - - setStatusFilter('Intake form')}> - Intake form - - setStatusFilter('Screen calling')}> - Screen calling - - setStatusFilter('Ranking')}> - Ranking - - setStatusFilter('Secondary application')}> - Secondary application - - setStatusFilter('Matched')}> - Matched - - setStatusFilter('Training')}> - Training - - setStatusFilter('Rejected')}> - Rejected - + + + + + + + + + + + + + {/* User Type Section */} + + + User type + + + + setUserTypeFilters({ ...userTypeFilters, participant: !!e.checked }) + } + > + Participant + + + setUserTypeFilters({ ...userTypeFilters, volunteer: !!e.checked }) + } + > + Volunteer + + + + + {/* Status Section */} + + + Status + + + + setStatusFilters({ ...statusFilters, notStarted: !!e.checked }) + } + > + Not Started + + + setStatusFilters({ ...statusFilters, inProgress: !!e.checked }) + } + > + In-progress + + + setStatusFilters({ ...statusFilters, completed: !!e.checked }) + } + > + Completed + + + setStatusFilters({ ...statusFilters, rejected: !!e.checked }) + } + > + Rejected + + + + + {/* Action Buttons */} + + + + + - - {/* Table */} - - - - - + + + + 0 && selectedUsers.size === filteredUsers.length} onCheckedChange={handleSelectAll} /> - { + { if (sortBy == 'nameDsc') { setSortBy("nameAsc") } else { setSortBy('nameDsc') } }}>Name {sortBy == 'nameAsc' ? '↑' : '↓'} - Language - Assigned - { + Language + Assigned + { if (sortBy == 'statusDsc') { setSortBy("statusAsc") } else { setSortBy('statusDsc') } }}>Status {sortBy == 'statusAsc' ? '↑' : '↓'} - {/* Search and Actions Bar */} - - - - - - setSearchQuery(e.target.value)} - /> - - - - - - - - - - setStatusFilter(null)}> - All - - setStatusFilter('Intake form')}> - Intake form - - setStatusFilter('Screen calling')}> - Screen calling - - setStatusFilter('Ranking')}> - Ranking - - setStatusFilter('Secondary application')}> - Secondary application - - setStatusFilter('Matched')}> - Matched - - setStatusFilter('Training')}> - Training - - setStatusFilter('Rejected')}> - Rejected - - - - - + + {filteredUsers.map((user: DirectoryUser) => { - console.log(user); const displayName = `${user.firstName || ''} ${user.lastName || ''}`.trim(); const roleName = (user.roleId === 2 ? 'Volunteer' : 'Participant'); @@ -287,8 +498,8 @@ export default function Directory() { {displayName} - - {formStatusMap[user.formStatus]?.label || 'intake-submitted'} - + {(() => { + const statusLabel = formStatusMap[user.formStatus]?.label || 'intake-submitted'; + const statusLevel = formStatusMap[user.formStatus].status; + const statusColors = getStatusColor(statusLevel); + return ( + + {statusLabel} + + ); + })()} ); From ea3381ff7b84b6d934daf30b05adb7d2e06b429c Mon Sep 17 00:00:00 2001 From: Ethan Chen Date: Mon, 27 Oct 2025 21:10:46 -0400 Subject: [PATCH 03/18] fix filters menu content to be absolutely positioned as seen in the figma --- frontend/src/pages/admin/directory.tsx | 45 ++++++++++++++++---------- 1 file changed, 28 insertions(+), 17 deletions(-) diff --git a/frontend/src/pages/admin/directory.tsx b/frontend/src/pages/admin/directory.tsx index a86f79df..192b460a 100644 --- a/frontend/src/pages/admin/directory.tsx +++ b/frontend/src/pages/admin/directory.tsx @@ -307,7 +307,7 @@ export default function Directory() { {/* Table */} - + {/* Icon Group - positioned at top right of table header */} - - - + + + - + {/* User Type Section */} @@ -446,14 +455,15 @@ export default function Directory() { - + + @@ -544,6 +554,7 @@ export default function Directory() { })} + From 0eb419ac9c821df8637e712555b45ddbe4fc9a27 Mon Sep 17 00:00:00 2001 From: Ethan Chen Date: Thu, 30 Oct 2025 18:51:51 -0400 Subject: [PATCH 04/18] fix name and status sorting --- frontend/src/pages/admin/directory.tsx | 27 +++++++++++++++++++++----- 1 file changed, 22 insertions(+), 5 deletions(-) diff --git a/frontend/src/pages/admin/directory.tsx b/frontend/src/pages/admin/directory.tsx index 192b460a..e803d56f 100644 --- a/frontend/src/pages/admin/directory.tsx +++ b/frontend/src/pages/admin/directory.tsx @@ -197,9 +197,26 @@ export default function Directory() { return matchesSearch && matchesUserType && matchesStatus; }); + // Sort the filtered users + const sortedUsers = [...filteredUsers].sort((a: DirectoryUser, b: DirectoryUser) => { + if (sortBy === 'nameAsc' || sortBy === 'nameDsc') { + // Sort by name + const nameA = `${a.firstName || ''} ${a.lastName || ''}`.trim().toLowerCase(); + const nameB = `${b.firstName || ''} ${b.lastName || ''}`.trim().toLowerCase(); + const comparison = nameA.localeCompare(nameB); + return sortBy === 'nameAsc' ? comparison : -comparison; + } else { + // Sort by status (using progress values) + const progressA = formStatusMap[a.formStatus]?.progress ?? 0; + const progressB = formStatusMap[b.formStatus]?.progress ?? 0; + const comparison = progressA - progressB; + return sortBy === 'statusAsc' ? comparison : -comparison; + } + }); + const handleSelectAll = (e: any) => { if (e.checked) { - setSelectedUsers(new Set(filteredUsers.map((u: any) => u.id))); + setSelectedUsers(new Set(sortedUsers.map((u: any) => u.id))); } else { setSelectedUsers(new Set()); } @@ -468,7 +485,7 @@ export default function Directory() { 0 && selectedUsers.size === filteredUsers.length} + checked={sortedUsers.length > 0 && selectedUsers.size === sortedUsers.length} onCheckedChange={handleSelectAll} /> @@ -478,7 +495,7 @@ export default function Directory() { } else { setSortBy('nameDsc') } - }}>Name {sortBy == 'nameAsc' ? '↑' : '↓'} + }}>Name {sortBy == 'nameAsc' ? '↑' : sortBy == 'nameDsc' ? '↓' : ''} Language Assigned { @@ -487,13 +504,13 @@ export default function Directory() { } else { setSortBy('statusDsc') } - }}>Status {sortBy == 'statusAsc' ? '↑' : '↓'} + }}>Status {sortBy == 'statusAsc' ? '↑' : sortBy == 'statusDsc' ? '↓' : ''} - {filteredUsers.map((user: DirectoryUser) => { + {sortedUsers.map((user: DirectoryUser) => { const displayName = `${user.firstName || ''} ${user.lastName || ''}`.trim(); const roleName = (user.roleId === 2 ? 'Volunteer' : 'Participant'); From b75b9b5f28278710a404aefb7ecfa45a93c62eac Mon Sep 17 00:00:00 2001 From: Ethan Chen Date: Thu, 30 Oct 2025 19:42:41 -0400 Subject: [PATCH 05/18] update user table typography and spacing --- .../ui/directory-progress-slider.tsx | 29 +- frontend/src/pages/admin/directory.tsx | 1148 +++++++++-------- 2 files changed, 655 insertions(+), 522 deletions(-) diff --git a/frontend/src/components/ui/directory-progress-slider.tsx b/frontend/src/components/ui/directory-progress-slider.tsx index 11effa4a..5e7d7f6b 100644 --- a/frontend/src/components/ui/directory-progress-slider.tsx +++ b/frontend/src/components/ui/directory-progress-slider.tsx @@ -11,7 +11,7 @@ export const DirectoryProgressSlider: React.FC = ( return ( - + {/* Track */} = ( /> {/* Milestone Markers */} - + {milestones.map((milestone) => { const isCompleted = value >= milestone; - const isCurrent = value < milestone && value >= (milestones[milestones.indexOf(milestone) - 1] || 0); + const isCurrent = + value < milestone && value >= (milestones[milestones.indexOf(milestone) - 1] || 0); return ( = ( border="3px solid white" boxShadow="sm" > - {isCompleted && ( - - )} + {isCompleted && } ); })} @@ -64,7 +72,14 @@ export const DirectoryProgressSlider: React.FC = ( {/* Percentage Display */} - + {value}% diff --git a/frontend/src/pages/admin/directory.tsx b/frontend/src/pages/admin/directory.tsx index e803d56f..501c7c3e 100644 --- a/frontend/src/pages/admin/directory.tsx +++ b/frontend/src/pages/admin/directory.tsx @@ -1,70 +1,83 @@ import React, { useState } from 'react'; -import { Box, Flex, Heading, Text, Table, IconButton, Input, Badge, Button, VStack } from '@chakra-ui/react'; -import { FiSearch, FiMenu, FiMail, FiFolder, FiLoader } from 'react-icons/fi'; +import { + Box, + Flex, + Heading, + Text, + Table, + IconButton, + Input, + Badge, + Button, + VStack, +} from '@chakra-ui/react'; +import { + FiSearch, + FiMenu, + FiMail, + FiFolder, + FiLoader, + FiChevronDown, + FiChevronUp, +} from 'react-icons/fi'; import { ProtectedPage } from '@/components/auth/ProtectedPage'; import { UserRole } from '@/types/authTypes'; import { Checkbox } from '@/components/ui/checkbox'; import { DirectoryProgressSlider } from '@/components/ui/directory-progress-slider'; import { DirectoryDataProvider } from '@/components/admin/DirectoryDataProvider'; -import { - MenuContent, - MenuRoot, - MenuTrigger, -} from '@chakra-ui/react'; -import { - LightMode -} from "@/components/ui/color-mode" +import { MenuContent, MenuRoot, MenuTrigger } from '@chakra-ui/react'; +import { LightMode } from '@/components/ui/color-mode'; import { COLORS } from '@/constants/form'; // Directory-specific colors from Figma design system const DIRECTORY_COLORS = { - // Language badges - languageEnglishBg: '#EEEEEC', - languageEnglishText: '#414651', - languageFrenchBg: '#EDF6FD', - languageFrenchText: '#2171AB', + // Language badges + languageEnglishBg: '#EEEEEC', + languageEnglishText: '#414651', + languageFrenchBg: '#EDF6FD', + languageFrenchText: '#2171AB', - // Role badges - roleVolunteerBg: '#FCCEEE', - roleVolunteerText: '#C11574', - roleParticipantBg: '#D9D6FE', - roleParticipantText: '#5925DC', + // Role badges + roleVolunteerBg: '#FCCEEE', + roleVolunteerText: '#C11574', + roleParticipantBg: '#D9D6FE', + roleParticipantText: '#5925DC', - // Status badges - statusSuccessBg: '#ECFDF3', - statusSuccessText: '#027A48', - statusRejectedBg: 'rgba(232, 188, 189, 0.3)', - statusRejectedText: '#B42318', - statusDefaultBg: '#F2F4F7', - statusDefaultText: '#344054', + // Status badges + statusSuccessBg: '#ECFDF3', + statusSuccessText: '#027A48', + statusRejectedBg: 'rgba(232, 188, 189, 0.3)', + statusRejectedText: '#B42318', + statusDefaultBg: '#F2F4F7', + statusDefaultText: '#344054', - // Borders - tableBorder: '#D5D7DA', + // Borders + tableBorder: '#D5D7DA', - // Navbar - navbarGray: '#414651', // Gray/700 - iconGray: '#181D27', // Gray/900 - menuButtonBg: '#EAEAE6', - applyButtonBg: '#056067', // Teal from Figma + // Navbar + navbarGray: '#414651', // Gray/700 + iconGray: '#181D27', // Gray/900 + menuButtonBg: '#EAEAE6', + applyButtonBg: '#056067', // Teal from Figma } as const; type FormStatus = - | 'intake-todo' - | 'intake-submitted' - | 'ranking-todo' - | 'ranking-submitted' - | 'secondary-application-todo' - | 'secondary-application-submitted' - | 'completed' - | 'rejected'; + | 'intake-todo' + | 'intake-submitted' + | 'ranking-todo' + | 'ranking-submitted' + | 'secondary-application-todo' + | 'secondary-application-submitted' + | 'completed' + | 'rejected'; interface DirectoryUser { - id: number; - firstName?: string; - lastName?: string; - email?: string; - roleId: number; - formStatus: FormStatus; + id: number; + firstName?: string; + lastName?: string; + email?: string; + roleId: number; + formStatus: FormStatus; } // Mock data - replace with API call // const mockUsers = [ @@ -86,498 +99,603 @@ interface DirectoryUser { // ]; const formStatusMap: Record = { - 'intake-todo': { - status: 'Not started', - label: 'Intake form', - progress: 0, - }, - 'intake-submitted': { - status: 'In-progress', - label: 'Screen calling', - progress: 25, - }, - 'ranking-todo': { - status: 'In-progress', - label: 'Ranking', - progress: 50, - }, - 'ranking-submitted': { - status: 'In-progress', - label: 'Matched', - progress: 75, - }, - 'secondary-application-todo': { - status: 'In-progress', - label: 'Secondary Application', - progress: 50, - }, - 'secondary-application-submitted': { - status: 'In-progress', - label: 'Training', - progress: 75, - }, - 'completed': { - status: 'Completed', - label: 'Completed', - progress: 100, - }, - 'rejected': { - status: 'Rejected', - label: 'Rejected', - progress: 0, - } -} + 'intake-todo': { + status: 'Not started', + label: 'Intake form', + progress: 0, + }, + 'intake-submitted': { + status: 'In-progress', + label: 'Screen calling', + progress: 25, + }, + 'ranking-todo': { + status: 'In-progress', + label: 'Ranking', + progress: 50, + }, + 'ranking-submitted': { + status: 'In-progress', + label: 'Matched', + progress: 75, + }, + 'secondary-application-todo': { + status: 'In-progress', + label: 'Secondary Application', + progress: 50, + }, + 'secondary-application-submitted': { + status: 'In-progress', + label: 'Training', + progress: 75, + }, + completed: { + status: 'Completed', + label: 'Completed', + progress: 100, + }, + rejected: { + status: 'Rejected', + label: 'Rejected', + progress: 0, + }, +}; const getStatusColor = (step: string): { bg: string; color: string } => { - const lowerStep = step.toLowerCase(); - if (lowerStep.includes('rejected')) return { bg: DIRECTORY_COLORS.statusRejectedBg, color: DIRECTORY_COLORS.statusRejectedText }; - return { bg: DIRECTORY_COLORS.statusSuccessBg, color: DIRECTORY_COLORS.statusSuccessText }; + const lowerStep = step.toLowerCase(); + if (lowerStep.includes('rejected')) + return { bg: DIRECTORY_COLORS.statusRejectedBg, color: DIRECTORY_COLORS.statusRejectedText }; + return { bg: DIRECTORY_COLORS.statusSuccessBg, color: DIRECTORY_COLORS.statusSuccessText }; }; export default function Directory() { - const [searchQuery, setSearchQuery] = useState(''); - const [selectedUsers, setSelectedUsers] = useState>(new Set()); - const [sortBy, setSortBy] = useState<'nameAsc' | 'nameDsc' | 'statusAsc' | 'statusDsc'>('nameAsc'); + const [searchQuery, setSearchQuery] = useState(''); + const [selectedUsers, setSelectedUsers] = useState>(new Set()); + const [sortBy, setSortBy] = useState<'nameAsc' | 'nameDsc' | 'statusAsc' | 'statusDsc'>( + 'nameAsc', + ); - // Filter state - const [userTypeFilters, setUserTypeFilters] = useState({ - participant: false, - volunteer: false - }); - const [statusFilters, setStatusFilters] = useState({ - notStarted: false, - inProgress: false, - completed: false, - rejected: false - }); + // Filter state + const [userTypeFilters, setUserTypeFilters] = useState({ + participant: false, + volunteer: false, + }); + const [statusFilters, setStatusFilters] = useState({ + notStarted: false, + inProgress: false, + completed: false, + rejected: false, + }); - // Applied filters (only update on "Apply" click) - const [appliedUserTypeFilters, setAppliedUserTypeFilters] = useState(userTypeFilters); - const [appliedStatusFilters, setAppliedStatusFilters] = useState(statusFilters); + // Applied filters (only update on "Apply" click) + const [appliedUserTypeFilters, setAppliedUserTypeFilters] = useState(userTypeFilters); + const [appliedStatusFilters, setAppliedStatusFilters] = useState(statusFilters); - const handleApplyFilters = () => { - setAppliedUserTypeFilters(userTypeFilters); - setAppliedStatusFilters(statusFilters); - }; + const handleApplyFilters = () => { + setAppliedUserTypeFilters(userTypeFilters); + setAppliedStatusFilters(statusFilters); + }; - const handleClearFilters = () => { - const clearedUserTypes = { participant: false, volunteer: false }; - const clearedStatuses = { notStarted: false, inProgress: false, completed: false, rejected: false }; - setUserTypeFilters(clearedUserTypes); - setStatusFilters(clearedStatuses); - setAppliedUserTypeFilters(clearedUserTypes); - setAppliedStatusFilters(clearedStatuses); + const handleClearFilters = () => { + const clearedUserTypes = { participant: false, volunteer: false }; + const clearedStatuses = { + notStarted: false, + inProgress: false, + completed: false, + rejected: false, }; + setUserTypeFilters(clearedUserTypes); + setStatusFilters(clearedStatuses); + setAppliedUserTypeFilters(clearedUserTypes); + setAppliedStatusFilters(clearedStatuses); + }; - return ( - - - {(users, loading, error) => { - const filteredUsers = users.filter((user: any) => { - const fullName = `${user.first_name || ''} ${user.last_name || ''}`.trim().toLowerCase(); - const matchesSearch = fullName.includes(searchQuery.toLowerCase()) || - user.email?.toLowerCase().includes(searchQuery.toLowerCase()); + return ( + + + {(users, loading, error) => { + const filteredUsers = users.filter((user: any) => { + const fullName = `${user.first_name || ''} ${user.last_name || ''}` + .trim() + .toLowerCase(); + const matchesSearch = + fullName.includes(searchQuery.toLowerCase()) || + user.email?.toLowerCase().includes(searchQuery.toLowerCase()); - // User type filtering - const hasUserTypeFilter = appliedUserTypeFilters.participant || appliedUserTypeFilters.volunteer; - const matchesUserType = !hasUserTypeFilter || - (appliedUserTypeFilters.participant && user.roleId === 1) || - (appliedUserTypeFilters.volunteer && user.roleId === 2); + // User type filtering + const hasUserTypeFilter = + appliedUserTypeFilters.participant || appliedUserTypeFilters.volunteer; + const matchesUserType = + !hasUserTypeFilter || + (appliedUserTypeFilters.participant && user.roleId === 1) || + (appliedUserTypeFilters.volunteer && user.roleId === 2); - // Status filtering - const hasStatusFilter = appliedStatusFilters.notStarted || appliedStatusFilters.inProgress || - appliedStatusFilters.completed || appliedStatusFilters.rejected; - const userStatus = user.formStatus && formStatusMap[user.formStatus as FormStatus]?.status; - const matchesStatus = !hasStatusFilter || - (appliedStatusFilters.notStarted && userStatus === 'Not started') || - (appliedStatusFilters.inProgress && userStatus === 'In-progress') || - (appliedStatusFilters.completed && userStatus === 'Completed') || - (appliedStatusFilters.rejected && userStatus === 'Rejected'); + // Status filtering + const hasStatusFilter = + appliedStatusFilters.notStarted || + appliedStatusFilters.inProgress || + appliedStatusFilters.completed || + appliedStatusFilters.rejected; + const userStatus = + user.formStatus && formStatusMap[user.formStatus as FormStatus]?.status; + const matchesStatus = + !hasStatusFilter || + (appliedStatusFilters.notStarted && userStatus === 'Not started') || + (appliedStatusFilters.inProgress && userStatus === 'In-progress') || + (appliedStatusFilters.completed && userStatus === 'Completed') || + (appliedStatusFilters.rejected && userStatus === 'Rejected'); - return matchesSearch && matchesUserType && matchesStatus; - }); + return matchesSearch && matchesUserType && matchesStatus; + }); - // Sort the filtered users - const sortedUsers = [...filteredUsers].sort((a: DirectoryUser, b: DirectoryUser) => { - if (sortBy === 'nameAsc' || sortBy === 'nameDsc') { - // Sort by name - const nameA = `${a.firstName || ''} ${a.lastName || ''}`.trim().toLowerCase(); - const nameB = `${b.firstName || ''} ${b.lastName || ''}`.trim().toLowerCase(); - const comparison = nameA.localeCompare(nameB); - return sortBy === 'nameAsc' ? comparison : -comparison; - } else { - // Sort by status (using progress values) - const progressA = formStatusMap[a.formStatus]?.progress ?? 0; - const progressB = formStatusMap[b.formStatus]?.progress ?? 0; - const comparison = progressA - progressB; - return sortBy === 'statusAsc' ? comparison : -comparison; - } - }); + // Sort the filtered users + const sortedUsers = [...filteredUsers].sort((a: DirectoryUser, b: DirectoryUser) => { + if (sortBy === 'nameAsc' || sortBy === 'nameDsc') { + // Sort by name + const nameA = `${a.firstName || ''} ${a.lastName || ''}`.trim().toLowerCase(); + const nameB = `${b.firstName || ''} ${b.lastName || ''}`.trim().toLowerCase(); + const comparison = nameA.localeCompare(nameB); + return sortBy === 'nameAsc' ? comparison : -comparison; + } else { + // Sort by status (using progress values) + const progressA = formStatusMap[a.formStatus]?.progress ?? 0; + const progressB = formStatusMap[b.formStatus]?.progress ?? 0; + const comparison = progressA - progressB; + return sortBy === 'statusAsc' ? comparison : -comparison; + } + }); - const handleSelectAll = (e: any) => { - if (e.checked) { - setSelectedUsers(new Set(sortedUsers.map((u: any) => u.id))); - } else { - setSelectedUsers(new Set()); - } - }; + const handleSelectAll = (e: any) => { + if (e.checked) { + setSelectedUsers(new Set(sortedUsers.map((u: any) => u.id))); + } else { + setSelectedUsers(new Set()); + } + }; - const handleSelectUser = (userId: string, checked: boolean) => { - const newSelected = new Set(selectedUsers); - if (checked) { - newSelected.add(userId); - } else { - newSelected.delete(userId); - } - setSelectedUsers(newSelected); - }; + const handleSelectUser = (userId: string, checked: boolean) => { + const newSelected = new Set(selectedUsers); + if (checked) { + newSelected.add(userId); + } else { + newSelected.delete(userId); + } + setSelectedUsers(newSelected); + }; - return ( - - {/* Navbar */} - - {/* Logo */} - - {/* Placeholder for logo - replace with actual logo */} - - LLSC - - + return ( + + {/* Navbar */} + + {/* Logo */} + + {/* Placeholder for logo - replace with actual logo */} + + LLSC + + - {/* Navigation Items */} - - - - - Task List - - - - - - Directory - - - - + {/* Navigation Items */} + + + + + Task List + + + + + + Directory + + + + - {/* Main Content */} - - - Directory - + {/* Main Content */} + + + Directory + - {/* Search Bar */} - - - - - - setSearchQuery(e.target.value)} - /> - - + {/* Search Bar */} + + + + + + setSearchQuery(e.target.value)} + /> + + - {/* Table */} - - {/* Icon Group - positioned at top right of table header */} - - - - - - - - - - - - - - + {/* Table */} + + {/* Icon Group - positioned at top right of table header */} + + + + + + + + + + + + + + - - - {/* User Type Section */} - - - User type - - - - setUserTypeFilters({ ...userTypeFilters, participant: !!e.checked }) - } - > - Participant - - - setUserTypeFilters({ ...userTypeFilters, volunteer: !!e.checked }) - } - > - Volunteer - - - + + + {/* User Type Section */} + + + User type + + + + setUserTypeFilters({ ...userTypeFilters, participant: !!e.checked }) + } + > + Participant + + + setUserTypeFilters({ ...userTypeFilters, volunteer: !!e.checked }) + } + > + Volunteer + + + - {/* Status Section */} - - - Status - - - - setStatusFilters({ ...statusFilters, notStarted: !!e.checked }) - } - > - Not Started - - - setStatusFilters({ ...statusFilters, inProgress: !!e.checked }) - } - > - In-progress - - - setStatusFilters({ ...statusFilters, completed: !!e.checked }) - } - > - Completed - - - setStatusFilters({ ...statusFilters, rejected: !!e.checked }) - } - > - Rejected - - - + {/* Status Section */} + + + Status + + + + setStatusFilters({ ...statusFilters, notStarted: !!e.checked }) + } + > + Not Started + + + setStatusFilters({ ...statusFilters, inProgress: !!e.checked }) + } + > + In-progress + + + setStatusFilters({ ...statusFilters, completed: !!e.checked }) + } + > + Completed + + + setStatusFilters({ ...statusFilters, rejected: !!e.checked }) + } + > + Rejected + + + - {/* Action Buttons */} - - - - - - - + {/* Action Buttons */} + + + + + + + - - - - - - 0 && selectedUsers.size === sortedUsers.length} - onCheckedChange={handleSelectAll} - /> - - { - if (sortBy == 'nameDsc') { - setSortBy("nameAsc") - } else { - setSortBy('nameDsc') - } - }}>Name {sortBy == 'nameAsc' ? '↑' : sortBy == 'nameDsc' ? '↓' : ''} - Language - Assigned - { - if (sortBy == 'statusDsc') { - setSortBy("statusAsc") - } else { - setSortBy('statusDsc') - } - }}>Status {sortBy == 'statusAsc' ? '↑' : sortBy == 'statusDsc' ? '↓' : ''} - - - - - - {sortedUsers.map((user: DirectoryUser) => { - const displayName = `${user.firstName || ''} ${user.lastName || ''}`.trim(); - const roleName = (user.roleId === 2 ? 'Volunteer' : 'Participant'); + + + + + + 0 && selectedUsers.size === sortedUsers.length + } + onCheckedChange={handleSelectAll} + /> + + { + if (sortBy == 'nameDsc') { + setSortBy('nameAsc'); + } else { + setSortBy('nameDsc'); + } + }} + cursor="pointer" + > + + Name + {sortBy == 'nameAsc' && } + {sortBy == 'nameDsc' && } + + + Language + Assigned + { + if (sortBy == 'statusDsc') { + setSortBy('statusAsc'); + } else { + setSortBy('statusDsc'); + } + }} + cursor="pointer" + > + + Status + {sortBy == 'statusAsc' && } + {sortBy == 'statusDsc' && } + + + + + + + + {sortedUsers.map((user: DirectoryUser) => { + const displayName = + `${user.firstName || ''} ${user.lastName || ''}`.trim(); + const roleName = user.roleId === 2 ? 'Volunteer' : 'Participant'; - return ( - - - handleSelectUser(user.id.toString(), !!e.checked)} - /> - - {displayName} - - - English - - - - - {roleName} - - - - - - - {(() => { - const statusLabel = formStatusMap[user.formStatus]?.label || 'intake-submitted'; - const statusLevel = formStatusMap[user.formStatus].status; - const statusColors = getStatusColor(statusLevel); - return ( - - {statusLabel} - - ); - })()} - - - ); - })} - - - - - - - ); - }} - - - ); + return ( + + + + handleSelectUser(user.id.toString(), !!e.checked) + } + /> + + + {displayName} + + + + English + + + + + {roleName} + + + + + + + {(() => { + const statusLabel = + formStatusMap[user.formStatus]?.label || 'intake-submitted'; + const statusLevel = formStatusMap[user.formStatus].status; + const statusColors = getStatusColor(statusLevel); + return ( + + {statusLabel} + + ); + })()} + + + ); + })} + + + + + + + ); + }} + + + ); } From 8b514a7dd466ea714dcdf154be8dcadf09b40c69 Mon Sep 17 00:00:00 2001 From: Ethan Chen Date: Fri, 31 Oct 2025 18:41:53 -0400 Subject: [PATCH 06/18] add the 'rejected' enum type to the user form status column add 'rejected' enum value to user pydantic schema, adjust progress rating for 'rejected' --- backend/app/models/User.py | 1 + backend/app/schemas/user.py | 1 + ...8_add_rejected_enum_value_to_user_model.py | 36 +++++++++++++++++++ frontend/src/pages/admin/directory.tsx | 22 ++---------- 4 files changed, 40 insertions(+), 20 deletions(-) create mode 100644 backend/migrations/versions/8d2cd99b9eb8_add_rejected_enum_value_to_user_model.py diff --git a/backend/app/models/User.py b/backend/app/models/User.py index 1a57d8ce..12b5395b 100644 --- a/backend/app/models/User.py +++ b/backend/app/models/User.py @@ -18,6 +18,7 @@ class FormStatus(str, PyEnum): SECONDARY_APPLICATION_TODO = "secondary-application-todo" SECONDARY_APPLICATION_SUBMITTED = "secondary-application-submitted" COMPLETED = "completed" + REJECTED = "rejected" class User(Base): diff --git a/backend/app/schemas/user.py b/backend/app/schemas/user.py index 0508ab57..dc084c09 100644 --- a/backend/app/schemas/user.py +++ b/backend/app/schemas/user.py @@ -43,6 +43,7 @@ class FormStatus(str, Enum): SECONDARY_APPLICATION_TODO = "secondary-application-todo" SECONDARY_APPLICATION_SUBMITTED = "secondary-application-submitted" COMPLETED = "completed" + REJECTED = "rejected" class UserBase(BaseModel): diff --git a/backend/migrations/versions/8d2cd99b9eb8_add_rejected_enum_value_to_user_model.py b/backend/migrations/versions/8d2cd99b9eb8_add_rejected_enum_value_to_user_model.py new file mode 100644 index 00000000..7e2201d3 --- /dev/null +++ b/backend/migrations/versions/8d2cd99b9eb8_add_rejected_enum_value_to_user_model.py @@ -0,0 +1,36 @@ +"""add rejected enum value to User model + +Revision ID: 8d2cd99b9eb8 +Revises: b56e0bf600a2 +Create Date: 2025-10-30 19:50:23.495788 + +""" + +from typing import Sequence, Union + +from alembic import op + +# revision identifiers, used by Alembic. +revision: str = "8d2cd99b9eb8" +down_revision: Union[str, None] = "b56e0bf600a2" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.execute(""" + DO $$ BEGIN + IF NOT EXISTS (SELECT 1 FROM pg_enum WHERE enumlabel = 'rejected' AND enumtypid = 'form_status_enum'::regtype) THEN + ALTER TYPE form_status_enum ADD VALUE 'rejected'; + END IF; + END $$; + """) + # ### commands auto generated by Alembic - please adjust! ### + pass + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + pass + # ### end Alembic commands ### diff --git a/frontend/src/pages/admin/directory.tsx b/frontend/src/pages/admin/directory.tsx index 501c7c3e..4804b2a9 100644 --- a/frontend/src/pages/admin/directory.tsx +++ b/frontend/src/pages/admin/directory.tsx @@ -79,24 +79,6 @@ interface DirectoryUser { roleId: number; formStatus: FormStatus; } -// Mock data - replace with API call -// const mockUsers = [ -// { id: '1', name: 'Randy Philips', language: 'English', assigned: 'Volunteer', status: '0%', progress: 0, currentStep: 'Intake form' }, -// { id: '2', name: 'Ann Vaccaro', language: 'English', assigned: 'Volunteer', status: '0%', progress: 0, currentStep: 'Intake form' }, -// { id: '3', name: 'Kaylynn Dias', language: 'French', assigned: 'Participant', status: '0%', progress: 0, currentStep: 'Intake form' }, -// { id: '4', name: 'Kierra Calzoni', language: 'English', assigned: 'Volunteer', status: '25%', progress: 25, currentStep: 'Screen calling' }, -// { id: '5', name: 'Terry Baptista', language: 'French', assigned: 'Participant', status: '50%', progress: 50, currentStep: 'Screen calling' }, -// { id: '6', name: 'Kaylynn Curtis', language: 'English', assigned: 'Volunteer', status: '25%', progress: 25, currentStep: 'Intake form' }, -// { id: '7', name: 'Livia Siphron', language: 'French', assigned: 'Participant', status: '25%', progress: 25, currentStep: 'Screen calling' }, -// { id: '8', name: 'James Levin', language: 'French', assigned: 'Participant', status: '100%', progress: 100, currentStep: 'Ranking' }, -// { id: '9', name: 'Desirae Dias', language: 'French', assigned: 'Participant', status: '100%', progress: 100, currentStep: 'Ranking' }, -// { id: '10', name: 'Desirae Franci', language: 'English', assigned: 'Volunteer', status: '100%', progress: 100, currentStep: 'Secondary application' }, -// { id: '11', name: 'Lincoln Rosser', language: 'English', assigned: 'Volunteer', status: '100%', progress: 100, currentStep: 'Secondary application' }, -// { id: '12', name: 'Gretchen Carder', language: 'French', assigned: 'Participant', status: '0%', progress: 0, currentStep: 'Matched' }, -// { id: '13', name: 'Miracle Kenter', language: 'English', assigned: 'Volunteer', status: '0%', progress: 0, currentStep: 'Training' }, -// { id: '14', name: 'Aspen Vaccaro', language: 'French', assigned: 'Participant', status: '0%', progress: 0, currentStep: 'Rejected' }, -// { id: '15', name: 'Kierra Boscch', language: 'English', assigned: 'Volunteer', status: '0%', progress: 0, currentStep: 'Rejected' }, -// ]; const formStatusMap: Record = { 'intake-todo': { @@ -121,7 +103,7 @@ const formStatusMap: Record Date: Fri, 31 Oct 2025 20:57:48 -0400 Subject: [PATCH 07/18] update directory navbar to use same component as admin dashboard --- frontend/src/pages/admin/directory.tsx | 80 ++------------------------ 1 file changed, 4 insertions(+), 76 deletions(-) diff --git a/frontend/src/pages/admin/directory.tsx b/frontend/src/pages/admin/directory.tsx index 4804b2a9..ff5972c0 100644 --- a/frontend/src/pages/admin/directory.tsx +++ b/frontend/src/pages/admin/directory.tsx @@ -11,15 +11,7 @@ import { Button, VStack, } from '@chakra-ui/react'; -import { - FiSearch, - FiMenu, - FiMail, - FiFolder, - FiLoader, - FiChevronDown, - FiChevronUp, -} from 'react-icons/fi'; +import { FiSearch, FiMenu, FiMail, FiChevronDown, FiChevronUp } from 'react-icons/fi'; import { ProtectedPage } from '@/components/auth/ProtectedPage'; import { UserRole } from '@/types/authTypes'; import { Checkbox } from '@/components/ui/checkbox'; @@ -28,6 +20,7 @@ import { DirectoryDataProvider } from '@/components/admin/DirectoryDataProvider' import { MenuContent, MenuRoot, MenuTrigger } from '@chakra-ui/react'; import { LightMode } from '@/components/ui/color-mode'; import { COLORS } from '@/constants/form'; +import { AdminHeader } from '@/components/admin/AdminHeader'; // Directory-specific colors from Figma design system const DIRECTORY_COLORS = { @@ -247,75 +240,10 @@ export default function Directory() { return ( - {/* Navbar */} - - {/* Logo */} - - {/* Placeholder for logo - replace with actual logo */} - - LLSC - - - - {/* Navigation Items */} - - - - - Task List - - - - - - Directory - - - - + {/* Main Content */} - + Date: Sat, 1 Nov 2025 13:53:20 -0400 Subject: [PATCH 08/18] add expandable search menu to admin directory table --- frontend/src/pages/admin/directory.tsx | 331 ++++++++++++++----------- 1 file changed, 189 insertions(+), 142 deletions(-) diff --git a/frontend/src/pages/admin/directory.tsx b/frontend/src/pages/admin/directory.tsx index ff5972c0..b6dcaa0b 100644 --- a/frontend/src/pages/admin/directory.tsx +++ b/frontend/src/pages/admin/directory.tsx @@ -255,46 +255,32 @@ export default function Directory() { Directory - {/* Search Bar */} - - - - - - setSearchQuery(e.target.value)} - /> - - - {/* Table */} {/* Icon Group - positioned at top right of table header */} - - + - - - + + + + {/* Filter Menu */} + - - - - + + + {/* User Type Section */} + + + User type + + + + setUserTypeFilters({ + ...userTypeFilters, + participant: !!e.checked, + }) + } + > + Participant + + + setUserTypeFilters({ ...userTypeFilters, volunteer: !!e.checked }) + } + > + Volunteer + + + - - - {/* User Type Section */} - - - User type - - - - setUserTypeFilters({ ...userTypeFilters, participant: !!e.checked }) - } + {/* Status Section */} + + - Participant - - - setUserTypeFilters({ ...userTypeFilters, volunteer: !!e.checked }) - } + Status + + + + setStatusFilters({ ...statusFilters, notStarted: !!e.checked }) + } + > + Not Started + + + setStatusFilters({ ...statusFilters, inProgress: !!e.checked }) + } + > + In-progress + + + setStatusFilters({ ...statusFilters, completed: !!e.checked }) + } + > + Completed + + + setStatusFilters({ ...statusFilters, rejected: !!e.checked }) + } + > + Rejected + + + + + {/* Action Buttons */} + + + - + + + - {/* Status Section */} - + {/* Search Menu */} + + + + + + + + - Status + Search users - - - setStatusFilters({ ...statusFilters, notStarted: !!e.checked }) - } - > - Not Started - - - setStatusFilters({ ...statusFilters, inProgress: !!e.checked }) - } - > - In-progress - - - setStatusFilters({ ...statusFilters, completed: !!e.checked }) - } - > - Completed - - - setStatusFilters({ ...statusFilters, rejected: !!e.checked }) - } + setSearchQuery(e.target.value)} + size="md" + borderRadius="8px" + bg="white" + borderColor="#D5D7DA" + paddingX="0.75em" + _focus={{ + borderColor: DIRECTORY_COLORS.applyButtonBg, + boxShadow: `0 0 0 1px ${DIRECTORY_COLORS.applyButtonBg}`, + }} + /> + {searchQuery && ( + - + Clear search + + )} - - - + + + Date: Sat, 1 Nov 2025 14:39:51 -0400 Subject: [PATCH 09/18] add inactive sorting arrows to indicate which columns are sortable --- frontend/src/pages/admin/directory.tsx | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/frontend/src/pages/admin/directory.tsx b/frontend/src/pages/admin/directory.tsx index b6dcaa0b..eeffcb6b 100644 --- a/frontend/src/pages/admin/directory.tsx +++ b/frontend/src/pages/admin/directory.tsx @@ -12,6 +12,7 @@ import { VStack, } from '@chakra-ui/react'; import { FiSearch, FiMenu, FiMail, FiChevronDown, FiChevronUp } from 'react-icons/fi'; +import { TbSelector } from 'react-icons/tb'; import { ProtectedPage } from '@/components/auth/ProtectedPage'; import { UserRole } from '@/types/authTypes'; import { Checkbox } from '@/components/ui/checkbox'; @@ -516,6 +517,9 @@ export default function Directory() { Name {sortBy == 'nameAsc' && } {sortBy == 'nameDsc' && } + {sortBy !== 'nameAsc' && sortBy !== 'nameDsc' && ( + + )} Language @@ -534,6 +538,9 @@ export default function Directory() { Status {sortBy == 'statusAsc' && } {sortBy == 'statusDsc' && } + {sortBy !== 'statusAsc' && sortBy !== 'statusDsc' && ( + + )} From 5bc14ee86fc0b4f3ca9cb2781d75926e57f32ff5 Mon Sep 17 00:00:00 2001 From: Ethan Chen Date: Sun, 2 Nov 2025 11:55:51 -0500 Subject: [PATCH 10/18] fix directory linting warnings --- .../admin/DirectoryDataProvider.tsx | 42 ++++++------- .../ui/directory-progress-slider.tsx | 4 +- frontend/src/pages/admin/directory.tsx | 60 ++++++++----------- 3 files changed, 49 insertions(+), 57 deletions(-) diff --git a/frontend/src/components/admin/DirectoryDataProvider.tsx b/frontend/src/components/admin/DirectoryDataProvider.tsx index 54199b58..dedb39f2 100644 --- a/frontend/src/components/admin/DirectoryDataProvider.tsx +++ b/frontend/src/components/admin/DirectoryDataProvider.tsx @@ -1,30 +1,32 @@ import baseAPIClient from '@/APIClients/baseAPIClient'; -import { useEffect, useState, ReactNode } from 'react'; +import { useEffect, useState } from 'react'; +import type { ReactNode } from 'react'; +import type { UserResponse } from '@/APIClients/authAPIClient'; interface DirectoryDataProviderProps { - children: (users: any[], loading: boolean, error: Error | null) => ReactNode; + children: (users: UserResponse[], loading: boolean, error: Error | null) => ReactNode; } export function DirectoryDataProvider({ children }: DirectoryDataProviderProps) { - const [users, setUsers] = useState([]); - const [loading, setLoading] = useState(true); - const [error, setError] = useState(null); + const [users, setUsers] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); - useEffect(() => { - const fetchUsers = async () => { - try { - const response = await baseAPIClient.get('/users'); - setUsers(response.data.users || response.data); - } catch (err) { - setError(err as Error); - console.error('Failed to fetch users:', err); - } finally { - setLoading(false); - } - }; + useEffect(() => { + const fetchUsers = async () => { + try { + const response = await baseAPIClient.get('/users'); + setUsers(response.data.users || response.data); + } catch (err) { + setError(err as Error); + console.error('Failed to fetch users:', err); + } finally { + setLoading(false); + } + }; - fetchUsers(); - }, []); + fetchUsers(); + }, []); - return <>{children(users, loading, error)}; + return <>{children(users, loading, error)}; } diff --git a/frontend/src/components/ui/directory-progress-slider.tsx b/frontend/src/components/ui/directory-progress-slider.tsx index 5e7d7f6b..47dda6c5 100644 --- a/frontend/src/components/ui/directory-progress-slider.tsx +++ b/frontend/src/components/ui/directory-progress-slider.tsx @@ -1,4 +1,4 @@ -import React from 'react'; +import type React from 'react'; import { Box, Flex, Text } from '@chakra-ui/react'; import { FiCheck } from 'react-icons/fi'; @@ -49,8 +49,6 @@ export const DirectoryProgressSlider: React.FC = ( > {milestones.map((milestone) => { const isCompleted = value >= milestone; - const isCurrent = - value < milestone && value >= (milestones[milestones.indexOf(milestone) - 1] || 0); return ( = { 'intake-todo': { status: 'Not started', @@ -169,11 +161,9 @@ export default function Directory() { return ( - {(users, loading, error) => { - const filteredUsers = users.filter((user: any) => { - const fullName = `${user.first_name || ''} ${user.last_name || ''}` - .trim() - .toLowerCase(); + {(users) => { + const filteredUsers = users.filter((user: UserResponse) => { + const fullName = `${user.firstName || ''} ${user.lastName || ''}`.trim().toLowerCase(); const matchesSearch = fullName.includes(searchQuery.toLowerCase()) || user.email?.toLowerCase().includes(searchQuery.toLowerCase()); @@ -205,7 +195,7 @@ export default function Directory() { }); // Sort the filtered users - const sortedUsers = [...filteredUsers].sort((a: DirectoryUser, b: DirectoryUser) => { + const sortedUsers = [...filteredUsers].sort((a: UserResponse, b: UserResponse) => { if (sortBy === 'nameAsc' || sortBy === 'nameDsc') { // Sort by name const nameA = `${a.firstName || ''} ${a.lastName || ''}`.trim().toLowerCase(); @@ -214,16 +204,16 @@ export default function Directory() { return sortBy === 'nameAsc' ? comparison : -comparison; } else { // Sort by status (using progress values) - const progressA = formStatusMap[a.formStatus]?.progress ?? 0; - const progressB = formStatusMap[b.formStatus]?.progress ?? 0; + const progressA = formStatusMap[a.formStatus as FormStatus]?.progress ?? 0; + const progressB = formStatusMap[b.formStatus as FormStatus]?.progress ?? 0; const comparison = progressA - progressB; return sortBy === 'statusAsc' ? comparison : -comparison; } }); - const handleSelectAll = (e: any) => { + const handleSelectAll = (e: { checked: boolean | 'indeterminate' }) => { if (e.checked) { - setSelectedUsers(new Set(sortedUsers.map((u: any) => u.id))); + setSelectedUsers(new Set(sortedUsers.map((u) => u.id))); } else { setSelectedUsers(new Set()); } @@ -505,7 +495,7 @@ export default function Directory() { { - if (sortBy == 'nameDsc') { + if (sortBy === 'nameDsc') { setSortBy('nameAsc'); } else { setSortBy('nameDsc'); @@ -515,8 +505,8 @@ export default function Directory() { > Name - {sortBy == 'nameAsc' && } - {sortBy == 'nameDsc' && } + {sortBy === 'nameAsc' && } + {sortBy === 'nameDsc' && } {sortBy !== 'nameAsc' && sortBy !== 'nameDsc' && ( )} @@ -526,7 +516,7 @@ export default function Directory() { Assigned { - if (sortBy == 'statusDsc') { + if (sortBy === 'statusDsc') { setSortBy('statusAsc'); } else { setSortBy('statusDsc'); @@ -536,8 +526,8 @@ export default function Directory() { > Status - {sortBy == 'statusAsc' && } - {sortBy == 'statusDsc' && } + {sortBy === 'statusAsc' && } + {sortBy === 'statusDsc' && } {sortBy !== 'statusAsc' && sortBy !== 'statusDsc' && ( )} @@ -548,7 +538,7 @@ export default function Directory() { - {sortedUsers.map((user: DirectoryUser) => { + {sortedUsers.map((user: UserResponse) => { const displayName = `${user.firstName || ''} ${user.lastName || ''}`.trim(); const roleName = user.roleId === 2 ? 'Volunteer' : 'Participant'; @@ -622,14 +612,16 @@ export default function Directory() { {(() => { const statusLabel = - formStatusMap[user.formStatus]?.label || 'intake-submitted'; - const statusLevel = formStatusMap[user.formStatus].status; + formStatusMap[user.formStatus as FormStatus]?.label || + 'intake-submitted'; + const statusLevel = + formStatusMap[user.formStatus as FormStatus].status; const statusColors = getStatusColor(statusLevel); return ( Date: Sun, 2 Nov 2025 12:06:20 -0500 Subject: [PATCH 11/18] make directory table more responsive --- .../src/components/ui/directory-progress-slider.tsx | 2 +- frontend/src/pages/admin/directory.tsx | 12 +++++++++--- 2 files changed, 10 insertions(+), 4 deletions(-) diff --git a/frontend/src/components/ui/directory-progress-slider.tsx b/frontend/src/components/ui/directory-progress-slider.tsx index 47dda6c5..cd6405b4 100644 --- a/frontend/src/components/ui/directory-progress-slider.tsx +++ b/frontend/src/components/ui/directory-progress-slider.tsx @@ -11,7 +11,7 @@ export const DirectoryProgressSlider: React.FC = ( return ( - + {/* Track */} {/* Main Content */} - + - + Date: Mon, 3 Nov 2025 19:29:48 -0500 Subject: [PATCH 12/18] restrict access permissions to directory to admins --- frontend/src/pages/admin/directory.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/src/pages/admin/directory.tsx b/frontend/src/pages/admin/directory.tsx index 42f0e248..963618ea 100644 --- a/frontend/src/pages/admin/directory.tsx +++ b/frontend/src/pages/admin/directory.tsx @@ -159,7 +159,7 @@ export default function Directory() { }; return ( - + {(users) => { const filteredUsers = users.filter((user: UserResponse) => { From a52e5d8d7098e1dc9291206ae99863dc4e74e9fa Mon Sep 17 00:00:00 2001 From: Ethan Chen Date: Mon, 3 Nov 2025 20:33:29 -0500 Subject: [PATCH 13/18] restrict user api back to admin only --- backend/app/routes/user.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/backend/app/routes/user.py b/backend/app/routes/user.py index cd199c41..eec68444 100644 --- a/backend/app/routes/user.py +++ b/backend/app/routes/user.py @@ -44,8 +44,7 @@ async def create_user( async def get_users( admin: Optional[bool] = Query(False, description="If true, returns admin users only"), user_service: UserService = Depends(get_user_service), - # authorized: bool = has_roles([UserRole.ADMIN]), - authorized: bool = True, + authorized: bool = has_roles([UserRole.ADMIN]), ): try: if admin: From caa234fe20cbeed8ef88c262ea4fdcd9e2b31155 Mon Sep 17 00:00:00 2001 From: Ethan Chen Date: Mon, 3 Nov 2025 21:02:52 -0500 Subject: [PATCH 14/18] improve responsiveness of table and improve appearance of status progres tickmarks --- .../components/ui/directory-progress-slider.tsx | 16 ++++++++-------- frontend/src/pages/admin/directory.tsx | 4 ++-- 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/frontend/src/components/ui/directory-progress-slider.tsx b/frontend/src/components/ui/directory-progress-slider.tsx index cd6405b4..5f6691f1 100644 --- a/frontend/src/components/ui/directory-progress-slider.tsx +++ b/frontend/src/components/ui/directory-progress-slider.tsx @@ -11,7 +11,7 @@ export const DirectoryProgressSlider: React.FC = ( return ( - + {/* Track */} = ( left="0" right="0" h="2px" - bg="gray.300" + bg="#EAEAE6" transform="translateY(-50%)" zIndex={0} /> @@ -30,7 +30,7 @@ export const DirectoryProgressSlider: React.FC = ( top="50%" left="0" h="2px" - bg="teal.500" + bg="#027847" transform="translateY(-50%)" width={`${value}%`} zIndex={0} @@ -55,14 +55,14 @@ export const DirectoryProgressSlider: React.FC = ( key={milestone} align="center" justify="center" - w="16px" - h="16px" + w="20px" + h="20px" borderRadius="full" - bg={isCompleted ? 'teal.500' : 'gray.300'} - border="3px solid white" + bg={isCompleted ? '#E7F8EE' : '#EAEAE6'} + border={isCompleted ? '2px solid #027847' : 'none'} boxShadow="sm" > - {isCompleted && } + {isCompleted && } ); })} diff --git a/frontend/src/pages/admin/directory.tsx b/frontend/src/pages/admin/directory.tsx index 963618ea..331aca16 100644 --- a/frontend/src/pages/admin/directory.tsx +++ b/frontend/src/pages/admin/directory.tsx @@ -238,8 +238,8 @@ export default function Directory() { p={8} bg="white" minH="100vh" - marginLeft={{ base: 4, md: 8, lg: 157 }} - marginRight={{ base: 4, md: 8, lg: 130 }} + marginLeft={{ base: 4, lg: 8, xl: 157 }} + marginRight={{ base: 4, lg: 8, xl: 130 }} > Date: Wed, 5 Nov 2025 00:32:56 -0500 Subject: [PATCH 15/18] update labels for form status chip labels, add 'rejected' enum type to FormStatus in authTypes.ts, rewrite directory to use FormStatus single source of truth in authTypes.ts --- frontend/src/pages/admin/directory.tsx | 32 ++++++++++++++------------ frontend/src/types/authTypes.ts | 1 + 2 files changed, 18 insertions(+), 15 deletions(-) diff --git a/frontend/src/pages/admin/directory.tsx b/frontend/src/pages/admin/directory.tsx index 331aca16..5acf6c88 100644 --- a/frontend/src/pages/admin/directory.tsx +++ b/frontend/src/pages/admin/directory.tsx @@ -15,6 +15,7 @@ import { FiSearch, FiMenu, FiMail, FiChevronDown, FiChevronUp } from 'react-icon import { TbSelector } from 'react-icons/tb'; import { ProtectedPage } from '@/components/auth/ProtectedPage'; import { UserRole } from '@/types/authTypes'; +import type { FormStatus } from '@/types/authTypes'; import { Checkbox } from '@/components/ui/checkbox'; import { DirectoryProgressSlider } from '@/components/ui/directory-progress-slider'; import { DirectoryDataProvider } from '@/components/admin/DirectoryDataProvider'; @@ -56,50 +57,51 @@ const DIRECTORY_COLORS = { applyButtonBg: '#056067', // Teal from Figma } as const; -type FormStatus = - | 'intake-todo' - | 'intake-submitted' - | 'ranking-todo' - | 'ranking-submitted' - | 'secondary-application-todo' - | 'secondary-application-submitted' - | 'completed' - | 'rejected'; - const formStatusMap: Record = { 'intake-todo': { + // when participant/volunteer has made an account and is in progress of completing intake form status: 'Not started', label: 'Intake form', progress: 0, }, 'intake-submitted': { + // when participant/volunteer has submitted the intake form before admin sends them the ranking/secondaryapp form status: 'In-progress', label: 'Screen calling', progress: 25, }, 'ranking-todo': { + // PARTICIPANT ONLY + // when participant is in progress of doing ranking form status: 'In-progress', - label: 'Ranking', + label: 'Ranking form', progress: 50, }, 'ranking-submitted': { + // PARTICIPANT ONLY + // after all onboarding has been completed OR participant rematches status: 'In-progress', - label: 'Matched', + label: 'Matching', progress: 75, }, 'secondary-application-todo': { + // VOLUNTEER ONLY + // when volunteer is in progress of doing secondary app form status: 'In-progress', - label: 'Secondary application', + label: 'Secondary app. form', progress: 50, }, 'secondary-application-submitted': { + // VOLUNTEER ONLY + // when the volunteer does not have any scheduled calls status: 'In-progress', - label: 'Training', + label: 'Matching', progress: 75, }, completed: { + // when participant/volunteer has a match status: 'Completed', - label: 'Completed', + label: 'Matched', progress: 100, }, rejected: { diff --git a/frontend/src/types/authTypes.ts b/frontend/src/types/authTypes.ts index c6d5cf65..09fd3cbd 100644 --- a/frontend/src/types/authTypes.ts +++ b/frontend/src/types/authTypes.ts @@ -19,6 +19,7 @@ export enum FormStatus { SECONDARY_APPLICATION_TODO = 'secondary-application-todo', SECONDARY_APPLICATION_SUBMITTED = 'secondary-application-submitted', COMPLETED = 'completed', + REJECTED = 'rejected', } export interface UserBase { From a0069264a5937c400920df102ba69efed7e71ace Mon Sep 17 00:00:00 2001 From: Ethan Chen Date: Wed, 5 Nov 2025 00:46:23 -0500 Subject: [PATCH 16/18] update available user status filters for admin directory --- frontend/src/pages/admin/directory.tsx | 88 +++++++++++++++++++------- 1 file changed, 64 insertions(+), 24 deletions(-) diff --git a/frontend/src/pages/admin/directory.tsx b/frontend/src/pages/admin/directory.tsx index 5acf6c88..361cc228 100644 --- a/frontend/src/pages/admin/directory.tsx +++ b/frontend/src/pages/admin/directory.tsx @@ -131,9 +131,12 @@ export default function Directory() { volunteer: false, }); const [statusFilters, setStatusFilters] = useState({ - notStarted: false, - inProgress: false, - completed: false, + intakeForm: false, + screenCalling: false, + rankingForm: false, + secondaryAppForm: false, + matching: false, + matched: false, rejected: false, }); @@ -149,9 +152,12 @@ export default function Directory() { const handleClearFilters = () => { const clearedUserTypes = { participant: false, volunteer: false }; const clearedStatuses = { - notStarted: false, - inProgress: false, - completed: false, + intakeForm: false, + screenCalling: false, + rankingForm: false, + secondaryAppForm: false, + matching: false, + matched: false, rejected: false, }; setUserTypeFilters(clearedUserTypes); @@ -180,18 +186,25 @@ export default function Directory() { // Status filtering const hasStatusFilter = - appliedStatusFilters.notStarted || - appliedStatusFilters.inProgress || - appliedStatusFilters.completed || + appliedStatusFilters.intakeForm || + appliedStatusFilters.screenCalling || + appliedStatusFilters.rankingForm || + appliedStatusFilters.secondaryAppForm || + appliedStatusFilters.matching || + appliedStatusFilters.matched || appliedStatusFilters.rejected; - const userStatus = - user.formStatus && formStatusMap[user.formStatus as FormStatus]?.status; + const userStatusLabel = + user.formStatus && formStatusMap[user.formStatus as FormStatus]?.label; const matchesStatus = !hasStatusFilter || - (appliedStatusFilters.notStarted && userStatus === 'Not started') || - (appliedStatusFilters.inProgress && userStatus === 'In-progress') || - (appliedStatusFilters.completed && userStatus === 'Completed') || - (appliedStatusFilters.rejected && userStatus === 'Rejected'); + (appliedStatusFilters.intakeForm && userStatusLabel === 'Intake form') || + (appliedStatusFilters.screenCalling && userStatusLabel === 'Screen calling') || + (appliedStatusFilters.rankingForm && userStatusLabel === 'Ranking form') || + (appliedStatusFilters.secondaryAppForm && + userStatusLabel === 'Secondary app. form') || + (appliedStatusFilters.matching && userStatusLabel === 'Matching') || + (appliedStatusFilters.matched && userStatusLabel === 'Matched') || + (appliedStatusFilters.rejected && userStatusLabel === 'Rejected'); return matchesSearch && matchesUserType && matchesStatus; }); @@ -348,28 +361,55 @@ export default function Directory() { - setStatusFilters({ ...statusFilters, notStarted: !!e.checked }) + setStatusFilters({ ...statusFilters, intakeForm: !!e.checked }) } > - Not Started + Intake form - setStatusFilters({ ...statusFilters, inProgress: !!e.checked }) + setStatusFilters({ ...statusFilters, screenCalling: !!e.checked }) } > - In-progress + Screen calling - setStatusFilters({ ...statusFilters, completed: !!e.checked }) + setStatusFilters({ ...statusFilters, rankingForm: !!e.checked }) } > - Completed + Ranking form + + + setStatusFilters({ + ...statusFilters, + secondaryAppForm: !!e.checked, + }) + } + > + Secondary app. form + + + setStatusFilters({ ...statusFilters, matching: !!e.checked }) + } + > + Matching + + + setStatusFilters({ ...statusFilters, matched: !!e.checked }) + } + > + Matched Date: Thu, 6 Nov 2025 18:06:48 -0500 Subject: [PATCH 17/18] added loading/error states to admin directory, updated rejection screen --- ...8_add_rejected_enum_value_to_user_model.py | 4 +- .../admin/DirectoryDataProvider.tsx | 15 +- .../components/intake/rejection-screen.tsx | 112 +++- frontend/src/constants/formStatusRoutes.ts | 3 + frontend/src/pages/admin/directory.tsx | 477 +++++++++++------- 5 files changed, 397 insertions(+), 214 deletions(-) diff --git a/backend/migrations/versions/8d2cd99b9eb8_add_rejected_enum_value_to_user_model.py b/backend/migrations/versions/8d2cd99b9eb8_add_rejected_enum_value_to_user_model.py index 7e2201d3..8740cd23 100644 --- a/backend/migrations/versions/8d2cd99b9eb8_add_rejected_enum_value_to_user_model.py +++ b/backend/migrations/versions/8d2cd99b9eb8_add_rejected_enum_value_to_user_model.py @@ -1,7 +1,7 @@ """add rejected enum value to User model Revision ID: 8d2cd99b9eb8 -Revises: b56e0bf600a2 +Revises: 9f1a6d727929 Create Date: 2025-10-30 19:50:23.495788 """ @@ -12,7 +12,7 @@ # revision identifiers, used by Alembic. revision: str = "8d2cd99b9eb8" -down_revision: Union[str, None] = "b56e0bf600a2" +down_revision: Union[str, None] = "9f1a6d727929" branch_labels: Union[str, Sequence[str], None] = None depends_on: Union[str, Sequence[str], None] = None diff --git a/frontend/src/components/admin/DirectoryDataProvider.tsx b/frontend/src/components/admin/DirectoryDataProvider.tsx index dedb39f2..7ff54185 100644 --- a/frontend/src/components/admin/DirectoryDataProvider.tsx +++ b/frontend/src/components/admin/DirectoryDataProvider.tsx @@ -15,10 +15,21 @@ export function DirectoryDataProvider({ children }: DirectoryDataProviderProps) useEffect(() => { const fetchUsers = async () => { try { + setLoading(true); + setError(null); const response = await baseAPIClient.get('/users'); - setUsers(response.data.users || response.data); + const usersData = response.data?.users || response.data || []; + setUsers(Array.isArray(usersData) ? usersData : []); } catch (err) { - setError(err as Error); + const error = + err instanceof Error + ? err + : new Error( + typeof err === 'object' && err !== null && 'message' in err + ? String(err.message) + : 'Failed to fetch users. Please try again.', + ); + setError(error); console.error('Failed to fetch users:', err); } finally { setLoading(false); diff --git a/frontend/src/components/intake/rejection-screen.tsx b/frontend/src/components/intake/rejection-screen.tsx index 69635f88..08cc1515 100644 --- a/frontend/src/components/intake/rejection-screen.tsx +++ b/frontend/src/components/intake/rejection-screen.tsx @@ -1,6 +1,10 @@ -import React from 'react'; -import { Box, Text, VStack } from '@chakra-ui/react'; +import React, { useEffect, useMemo, useState } from 'react'; +import { Box, Link, Text, VStack } from '@chakra-ui/react'; import { COLORS } from '@/constants/form'; +import type { AuthenticatedUser, UserRole } from '@/types/authTypes'; +import { UserRole as UserRoleEnum } from '@/types/authTypes'; +import { getCurrentUser, syncCurrentUser } from '@/APIClients/authAPIClient'; +import { roleIdToUserRole } from '@/utils/roleUtils'; // X mark icon component const XMarkIcon: React.FC = () => ( @@ -28,6 +32,53 @@ const XMarkIcon: React.FC = () => ( ); export function RejectionScreen() { + const [userRole, setUserRole] = useState(null); + + useEffect(() => { + const resolveRole = (user: AuthenticatedUser): UserRole | null => { + if (!user) return null; + if ('role' in user && user.role) { + return user.role as UserRole; + } + const roleId = user.user?.roleId ?? (user.roleId as unknown as number | undefined) ?? null; + return roleIdToUserRole(roleId); + }; + + const hydrateRole = async () => { + const localUser = getCurrentUser(); + let resolved = resolveRole(localUser); + if (!resolved) { + const synced = await syncCurrentUser(); + resolved = resolveRole(synced); + } + setUserRole(resolved ?? UserRoleEnum.PARTICIPANT); + }; + + void hydrateRole(); + }, []); + + const content = useMemo(() => { + if (userRole === UserRoleEnum.VOLUNTEER) { + return { + title: "You're unable to continue this application.", + body: [ + 'Thank you for your interest in becoming a peer support volunteer.', + 'You must meet all of the eligibility criteria to continue.', + 'Please reach out to FirstConnections@lls.org for more information about volunteering with LLSC.', + ], + }; + } + + return { + title: "You're unable to continue this application.", + body: [ + 'Thank you for your interest in First Connections.', + 'To continue as a participant you must meet each of the eligibility requirements.', + 'Please reach out to FirstConnections@lls.org if you have questions or need help finding other support options.', + ], + }; + }, [userRole]); + return ( @@ -40,23 +91,50 @@ export function RejectionScreen() { color={COLORS.veniceBlue} mb={2} > - Your request has been declined. + {content.title} - - For any inquiries, please reach us at{' '} - - FirstConnections@lls.org - - . - + + {content.body.map((sentence, index) => { + const EMAIL_TOKEN = 'FirstConnections@lls.org'; + const hasEmail = sentence.includes(EMAIL_TOKEN); + let prefix = ''; + let suffix = ''; + if (hasEmail) { + const parts = sentence.split(EMAIL_TOKEN); + prefix = parts[0] ?? ''; + suffix = parts[1] ?? ''; + } + + return ( + + {hasEmail ? ( + <> + {prefix} + + FirstConnections@lls.org + + {suffix} + + ) : ( + sentence + )} + + ); + })} + ); diff --git a/frontend/src/constants/formStatusRoutes.ts b/frontend/src/constants/formStatusRoutes.ts index b11318ae..5a75f3f9 100644 --- a/frontend/src/constants/formStatusRoutes.ts +++ b/frontend/src/constants/formStatusRoutes.ts @@ -10,6 +10,7 @@ export const PARTICIPANT_STATUS_ROUTES: StatusRouteMap = { [FormStatus.SECONDARY_APPLICATION_TODO]: '/participant/ranking', [FormStatus.SECONDARY_APPLICATION_SUBMITTED]: '/participant/ranking/thank-you', [FormStatus.COMPLETED]: '/participant/dashboard', + [FormStatus.REJECTED]: '/rejection', }; export const VOLUNTEER_STATUS_ROUTES: StatusRouteMap = { @@ -20,6 +21,7 @@ export const VOLUNTEER_STATUS_ROUTES: StatusRouteMap = { [FormStatus.SECONDARY_APPLICATION_TODO]: '/volunteer/secondary-application', [FormStatus.SECONDARY_APPLICATION_SUBMITTED]: '/volunteer/secondary-application/thank-you', [FormStatus.COMPLETED]: '/volunteer/dashboard', + [FormStatus.REJECTED]: '/rejection', }; export const ADMIN_STATUS_ROUTES: StatusRouteMap = { @@ -30,6 +32,7 @@ export const ADMIN_STATUS_ROUTES: StatusRouteMap = { [FormStatus.SECONDARY_APPLICATION_TODO]: '/admin', [FormStatus.SECONDARY_APPLICATION_SUBMITTED]: '/admin', [FormStatus.COMPLETED]: '/admin', + [FormStatus.REJECTED]: '/admin', }; export const ROLE_STATUS_ROUTES: Record = { diff --git a/frontend/src/pages/admin/directory.tsx b/frontend/src/pages/admin/directory.tsx index 361cc228..62a506e0 100644 --- a/frontend/src/pages/admin/directory.tsx +++ b/frontend/src/pages/admin/directory.tsx @@ -2,10 +2,15 @@ import { Badge, Box, Button, + Center, Flex, Heading, IconButton, Input, + MenuContent, + MenuRoot, + MenuTrigger, + Spinner, Table, Text, VStack, @@ -14,16 +19,15 @@ import { useState } from 'react'; import { FiSearch, FiMenu, FiMail, FiChevronDown, FiChevronUp } from 'react-icons/fi'; import { TbSelector } from 'react-icons/tb'; import { ProtectedPage } from '@/components/auth/ProtectedPage'; -import { UserRole } from '@/types/authTypes'; -import type { FormStatus } from '@/types/authTypes'; +import { UserRole, FormStatus } from '@/types/authTypes'; import { Checkbox } from '@/components/ui/checkbox'; import { DirectoryProgressSlider } from '@/components/ui/directory-progress-slider'; import { DirectoryDataProvider } from '@/components/admin/DirectoryDataProvider'; -import { MenuContent, MenuRoot, MenuTrigger } from '@chakra-ui/react'; import { LightMode } from '@/components/ui/color-mode'; import { COLORS } from '@/constants/form'; import { AdminHeader } from '@/components/admin/AdminHeader'; import type { UserResponse } from '@/APIClients/authAPIClient'; +import { roleIdToUserRole } from '@/utils/roleUtils'; // Directory-specific colors from Figma design system const DIRECTORY_COLORS = { @@ -169,20 +173,21 @@ export default function Directory() { return ( - {(users) => { + {(users, loading, error) => { const filteredUsers = users.filter((user: UserResponse) => { const fullName = `${user.firstName || ''} ${user.lastName || ''}`.trim().toLowerCase(); const matchesSearch = fullName.includes(searchQuery.toLowerCase()) || user.email?.toLowerCase().includes(searchQuery.toLowerCase()); + const userRole = roleIdToUserRole(user.roleId); // User type filtering const hasUserTypeFilter = appliedUserTypeFilters.participant || appliedUserTypeFilters.volunteer; const matchesUserType = !hasUserTypeFilter || - (appliedUserTypeFilters.participant && user.roleId === 1) || - (appliedUserTypeFilters.volunteer && user.roleId === 2); + (appliedUserTypeFilters.participant && userRole === UserRole.PARTICIPANT) || + (appliedUserTypeFilters.volunteer && userRole === UserRole.VOLUNTEER); // Status filtering const hasStatusFilter = @@ -193,18 +198,20 @@ export default function Directory() { appliedStatusFilters.matching || appliedStatusFilters.matched || appliedStatusFilters.rejected; - const userStatusLabel = - user.formStatus && formStatusMap[user.formStatus as FormStatus]?.label; + const userFormStatus = user.formStatus as FormStatus | undefined; const matchesStatus = !hasStatusFilter || - (appliedStatusFilters.intakeForm && userStatusLabel === 'Intake form') || - (appliedStatusFilters.screenCalling && userStatusLabel === 'Screen calling') || - (appliedStatusFilters.rankingForm && userStatusLabel === 'Ranking form') || + (appliedStatusFilters.intakeForm && userFormStatus === FormStatus.INTAKE_TODO) || + (appliedStatusFilters.screenCalling && + userFormStatus === FormStatus.INTAKE_SUBMITTED) || + (appliedStatusFilters.rankingForm && userFormStatus === FormStatus.RANKING_TODO) || (appliedStatusFilters.secondaryAppForm && - userStatusLabel === 'Secondary app. form') || - (appliedStatusFilters.matching && userStatusLabel === 'Matching') || - (appliedStatusFilters.matched && userStatusLabel === 'Matched') || - (appliedStatusFilters.rejected && userStatusLabel === 'Rejected'); + userFormStatus === FormStatus.SECONDARY_APPLICATION_TODO) || + (appliedStatusFilters.matching && + (userFormStatus === FormStatus.RANKING_SUBMITTED || + userFormStatus === FormStatus.SECONDARY_APPLICATION_SUBMITTED)) || + (appliedStatusFilters.matched && userFormStatus === FormStatus.COMPLETED) || + (appliedStatusFilters.rejected && userFormStatus === FormStatus.REJECTED); return matchesSearch && matchesUserType && matchesStatus; }); @@ -244,6 +251,267 @@ export default function Directory() { setSelectedUsers(newSelected); }; + const tableContent = (() => { + if (loading) { + return ( +
+ + + + Loading users... + + +
+ ); + } + + if (error) { + const errorMessage = + error instanceof Error + ? error.message + : typeof error === 'string' + ? error + : 'Please refresh and try again.'; + return ( +
+ + + + Unable to load the directory + + + {errorMessage} + + + + +
+ ); + } + + if (sortedUsers.length === 0) { + const hasActiveFilters = + searchQuery || + appliedUserTypeFilters.participant || + appliedUserTypeFilters.volunteer || + appliedStatusFilters.intakeForm || + appliedStatusFilters.screenCalling || + appliedStatusFilters.rankingForm || + appliedStatusFilters.secondaryAppForm || + appliedStatusFilters.matching || + appliedStatusFilters.matched || + appliedStatusFilters.rejected; + + return ( +
+ + + {hasActiveFilters ? 'No users match your filters' : 'No users found'} + + + {hasActiveFilters + ? 'Try adjusting your search or filters to see more results.' + : 'There are no users in the directory yet.'} + + +
+ ); + } + + return ( + + + + + 0 && selectedUsers.size === sortedUsers.length + } + onCheckedChange={handleSelectAll} + /> + + { + if (sortBy === 'nameDsc') { + setSortBy('nameAsc'); + } else { + setSortBy('nameDsc'); + } + }} + cursor="pointer" + > + + Name + {sortBy === 'nameAsc' && } + {sortBy === 'nameDsc' && } + {sortBy !== 'nameAsc' && sortBy !== 'nameDsc' && ( + + )} + + + Language + Assigned + { + if (sortBy === 'statusDsc') { + setSortBy('statusAsc'); + } else { + setSortBy('statusDsc'); + } + }} + cursor="pointer" + > + + Status + {sortBy === 'statusAsc' && } + {sortBy === 'statusDsc' && } + {sortBy !== 'statusAsc' && sortBy !== 'statusDsc' && ( + + )} + + + + + + + + {sortedUsers.map((user: UserResponse) => { + const displayName = `${user.firstName || ''} ${user.lastName || ''}`.trim(); + const userRole = roleIdToUserRole(user.roleId); + const roleName = + userRole === UserRole.VOLUNTEER + ? 'Volunteer' + : userRole === UserRole.PARTICIPANT + ? 'Participant' + : 'Unknown'; + + return ( + + + + handleSelectUser(user.id.toString(), !!e.checked) + } + /> + + + {displayName} + + + + English + + + + + {roleName} + + + + + + + {(() => { + const statusConfig = formStatusMap[user.formStatus as FormStatus]; + const statusLabel = statusConfig?.label ?? 'Intake form'; + const statusLevel = statusConfig?.status ?? 'Not started'; + const statusColors = getStatusColor(statusLevel); + return ( + + {statusLabel} + + ); + })()} + + + ); + })} + + + ); + })(); + return ( @@ -515,184 +783,7 @@ export default function Directory() {
- - - - - 0 && selectedUsers.size === sortedUsers.length - } - onCheckedChange={handleSelectAll} - /> - - { - if (sortBy === 'nameDsc') { - setSortBy('nameAsc'); - } else { - setSortBy('nameDsc'); - } - }} - cursor="pointer" - > - - Name - {sortBy === 'nameAsc' && } - {sortBy === 'nameDsc' && } - {sortBy !== 'nameAsc' && sortBy !== 'nameDsc' && ( - - )} - - - Language - Assigned - { - if (sortBy === 'statusDsc') { - setSortBy('statusAsc'); - } else { - setSortBy('statusDsc'); - } - }} - cursor="pointer" - > - - Status - {sortBy === 'statusAsc' && } - {sortBy === 'statusDsc' && } - {sortBy !== 'statusAsc' && sortBy !== 'statusDsc' && ( - - )} - - - - - - - - {sortedUsers.map((user: UserResponse) => { - const displayName = - `${user.firstName || ''} ${user.lastName || ''}`.trim(); - const roleName = user.roleId === 2 ? 'Volunteer' : 'Participant'; - - return ( - - - - handleSelectUser(user.id.toString(), !!e.checked) - } - /> - - - {displayName} - - - - English - - - - - {roleName} - - - - - - - {(() => { - const statusLabel = - formStatusMap[user.formStatus as FormStatus]?.label || - 'intake-submitted'; - const statusLevel = - formStatusMap[user.formStatus as FormStatus].status; - const statusColors = getStatusColor(statusLevel); - return ( - - {statusLabel} - - ); - })()} - - - ); - })} - - + {tableContent}
From 71493ab063450731ab41a2f5ae26c863fc5b73f6 Mon Sep 17 00:00:00 2001 From: YashK2005 Date: Thu, 6 Nov 2025 18:09:51 -0500 Subject: [PATCH 18/18] frontend lint --- frontend/src/components/intake/rejection-screen.tsx | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/frontend/src/components/intake/rejection-screen.tsx b/frontend/src/components/intake/rejection-screen.tsx index 08cc1515..eacd1fea 100644 --- a/frontend/src/components/intake/rejection-screen.tsx +++ b/frontend/src/components/intake/rejection-screen.tsx @@ -110,11 +110,11 @@ export function RejectionScreen() { + fontSize="18px" + color={COLORS.fieldGray} + lineHeight="1.6" + textAlign="center" + > {hasEmail ? ( <> {prefix} @@ -131,7 +131,7 @@ export function RejectionScreen() { ) : ( sentence )} - + ); })}