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..8740cd23 --- /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: 9f1a6d727929 +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] = "9f1a6d727929" +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/components/admin/DirectoryDataProvider.tsx b/frontend/src/components/admin/DirectoryDataProvider.tsx new file mode 100644 index 00000000..7ff54185 --- /dev/null +++ b/frontend/src/components/admin/DirectoryDataProvider.tsx @@ -0,0 +1,43 @@ +import baseAPIClient from '@/APIClients/baseAPIClient'; +import { useEffect, useState } from 'react'; +import type { ReactNode } from 'react'; +import type { UserResponse } from '@/APIClients/authAPIClient'; + +interface DirectoryDataProviderProps { + 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); + + useEffect(() => { + const fetchUsers = async () => { + try { + setLoading(true); + setError(null); + const response = await baseAPIClient.get('/users'); + const usersData = response.data?.users || response.data || []; + setUsers(Array.isArray(usersData) ? usersData : []); + } catch (err) { + 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); + } + }; + + fetchUsers(); + }, []); + + return <>{children(users, loading, error)}; +} diff --git a/frontend/src/components/intake/rejection-screen.tsx b/frontend/src/components/intake/rejection-screen.tsx index 69635f88..eacd1fea 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/components/ui/directory-progress-slider.tsx b/frontend/src/components/ui/directory-progress-slider.tsx new file mode 100644 index 00000000..5f6691f1 --- /dev/null +++ b/frontend/src/components/ui/directory-progress-slider.tsx @@ -0,0 +1,85 @@ +import type 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; + + return ( + + {isCompleted && } + + ); + })} + + + + {/* Percentage Display */} + + {value}% + + + ); +}; 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 new file mode 100644 index 00000000..62a506e0 --- /dev/null +++ b/frontend/src/pages/admin/directory.tsx @@ -0,0 +1,796 @@ +import { + Badge, + Box, + Button, + Center, + Flex, + Heading, + IconButton, + Input, + MenuContent, + MenuRoot, + MenuTrigger, + Spinner, + Table, + Text, + VStack, +} from '@chakra-ui/react'; +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, 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 { 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 = { + // 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; + +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 form', + progress: 50, + }, + 'ranking-submitted': { + // PARTICIPANT ONLY + // after all onboarding has been completed OR participant rematches + status: 'In-progress', + label: 'Matching', + progress: 75, + }, + 'secondary-application-todo': { + // VOLUNTEER ONLY + // when volunteer is in progress of doing secondary app form + status: 'In-progress', + label: 'Secondary app. form', + progress: 50, + }, + 'secondary-application-submitted': { + // VOLUNTEER ONLY + // when the volunteer does not have any scheduled calls + status: 'In-progress', + label: 'Matching', + progress: 75, + }, + completed: { + // when participant/volunteer has a match + status: 'Completed', + label: 'Matched', + progress: 100, + }, + rejected: { + status: 'Rejected', + label: 'Rejected', + progress: 100, + }, +}; + +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 }; +}; + +export default function Directory() { + 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({ + intakeForm: false, + screenCalling: false, + rankingForm: false, + secondaryAppForm: false, + matching: false, + matched: 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 = { + intakeForm: false, + screenCalling: false, + rankingForm: false, + secondaryAppForm: false, + matching: false, + matched: false, + rejected: false, + }; + setUserTypeFilters(clearedUserTypes); + setStatusFilters(clearedStatuses); + setAppliedUserTypeFilters(clearedUserTypes); + setAppliedStatusFilters(clearedStatuses); + }; + + return ( + + + {(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 && userRole === UserRole.PARTICIPANT) || + (appliedUserTypeFilters.volunteer && userRole === UserRole.VOLUNTEER); + + // Status filtering + const hasStatusFilter = + appliedStatusFilters.intakeForm || + appliedStatusFilters.screenCalling || + appliedStatusFilters.rankingForm || + appliedStatusFilters.secondaryAppForm || + appliedStatusFilters.matching || + appliedStatusFilters.matched || + appliedStatusFilters.rejected; + const userFormStatus = user.formStatus as FormStatus | undefined; + const matchesStatus = + !hasStatusFilter || + (appliedStatusFilters.intakeForm && userFormStatus === FormStatus.INTAKE_TODO) || + (appliedStatusFilters.screenCalling && + userFormStatus === FormStatus.INTAKE_SUBMITTED) || + (appliedStatusFilters.rankingForm && userFormStatus === FormStatus.RANKING_TODO) || + (appliedStatusFilters.secondaryAppForm && + 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; + }); + + // Sort the filtered users + const sortedUsers = [...filteredUsers].sort((a: UserResponse, b: UserResponse) => { + 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 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: { checked: boolean | 'indeterminate' }) => { + if (e.checked) { + setSelectedUsers(new Set(sortedUsers.map((u) => 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 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 ( + + + + {/* Main Content */} + + + Directory + + + {/* 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 + + + + + {/* Status Section */} + + + Status + + + + setStatusFilters({ ...statusFilters, intakeForm: !!e.checked }) + } + > + Intake form + + + setStatusFilters({ ...statusFilters, screenCalling: !!e.checked }) + } + > + Screen calling + + + setStatusFilters({ ...statusFilters, rankingForm: !!e.checked }) + } + > + Ranking form + + + setStatusFilters({ + ...statusFilters, + secondaryAppForm: !!e.checked, + }) + } + > + Secondary app. form + + + setStatusFilters({ ...statusFilters, matching: !!e.checked }) + } + > + Matching + + + setStatusFilters({ ...statusFilters, matched: !!e.checked }) + } + > + Matched + + + setStatusFilters({ ...statusFilters, rejected: !!e.checked }) + } + > + Rejected + + + + + {/* Action Buttons */} + + + + + + + + + {/* Search Menu */} + + + + + + + + + + Search users + + 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 && ( + + )} + + + + + + + {tableContent} + + + + + ); + }} +
+
+ ); +} 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 {