From 71d27f5aceba2d034cf490b5ad86bf80c6b8e48a Mon Sep 17 00:00:00 2001 From: James Ketrenos Date: Fri, 27 Jun 2025 14:52:48 -0700 Subject: [PATCH] Job search works (had to reorder route) --- frontend/src/components/ui/CandidateInfo.tsx | 137 +++---- frontend/src/components/ui/JobsTable.tsx | 364 ------------------- frontend/src/components/ui/JobsView.tsx | 2 +- frontend/src/config/navigationConfig.tsx | 16 +- frontend/src/pages/CandidateChatPage.tsx | 23 +- frontend/src/pages/candidate/Profile.tsx | 35 +- src/backend/routes/candidates.py | 2 +- src/backend/routes/jobs.py | 43 ++- 8 files changed, 144 insertions(+), 478 deletions(-) delete mode 100644 frontend/src/components/ui/JobsTable.tsx diff --git a/frontend/src/components/ui/CandidateInfo.tsx b/frontend/src/components/ui/CandidateInfo.tsx index f6a6cbf..8c61560 100644 --- a/frontend/src/components/ui/CandidateInfo.tsx +++ b/frontend/src/components/ui/CandidateInfo.tsx @@ -1,4 +1,4 @@ -import React, { useState, useRef, useEffect } from 'react'; +import React, { useState, useRef, useEffect, JSX } from 'react'; import { Box, Link, Typography, Avatar, SxProps, Tooltip, IconButton } from '@mui/material'; import { Divider, useTheme } from '@mui/material'; import DeleteIcon from '@mui/icons-material/Delete'; @@ -17,6 +17,73 @@ interface CandidateInfoProps { variant?: 'minimal' | 'small' | 'normal' | undefined; } +interface ExpandingTextProps { + content: string; + lines?: number; + sx?: SxProps; +} +const ExpandingText = (props: ExpandingTextProps): JSX.Element => { + const { content, sx, lines = 3 } = props; + const theme = useTheme(); + const [isTextExpanded, setIsTextExpanded] = useState(false); + const [shouldShowMoreButton, setShouldShowMoreButton] = useState(false); + const textRef = useRef(null); + + useEffect(() => { + if (textRef.current && content) { + const element = textRef.current; + // Check if the scrollHeight is greater than clientHeight (meaning content is truncated) + setShouldShowMoreButton(element.scrollHeight > element.clientHeight); + } + }, [content]); + + return ( + + + {content} + + {shouldShowMoreButton && ( + { + e.preventDefault(); + e.stopPropagation(); + setIsTextExpanded(!isTextExpanded); + }} + sx={{ + color: theme.palette.primary.main, + textDecoration: 'none', + cursor: 'pointer', + fontSize: '0.725rem', + fontWeight: 500, + mt: 0.5, + display: 'block', + '&:hover': { + textDecoration: 'underline', + }, + }} + > + [{isTextExpanded ? 'less' : 'more'}] + + )} + + ); +}; + const CandidateInfo: React.FC = (props: CandidateInfoProps) => { const { candidate } = props; const { user, apiClient } = useAuth(); @@ -26,20 +93,6 @@ const CandidateInfo: React.FC = (props: CandidateInfoProps) const ai: CandidateAI | null = 'isAI' in candidate ? (candidate as CandidateAI) : null; const isAdmin = user?.isAdmin; - // State for description expansion - const [isDescriptionExpanded, setIsDescriptionExpanded] = useState(false); - const [shouldShowMoreButton, setShouldShowMoreButton] = useState(false); - const descriptionRef = useRef(null); - - // Check if description needs truncation - useEffect(() => { - if (descriptionRef.current && candidate.description) { - const element = descriptionRef.current; - // Check if the scrollHeight is greater than clientHeight (meaning content is truncated) - setShouldShowMoreButton(element.scrollHeight > element.clientHeight); - } - }, [candidate.description]); - const deleteCandidate = async (candidateId: string | undefined): Promise => { if (candidateId) { await apiClient.deleteCandidate(candidateId); @@ -68,7 +121,7 @@ const CandidateInfo: React.FC = (props: CandidateInfoProps) > {ai && } - + = (props: CandidateInfoProps) + {variant === 'normal' && ( + + )} - {!isMobile && variant === 'normal' && ( - - - {candidate.description} - - {shouldShowMoreButton && ( - { - e.preventDefault(); - e.stopPropagation(); - setIsDescriptionExpanded(!isDescriptionExpanded); - }} - sx={{ - color: theme.palette.primary.main, - textDecoration: 'none', - cursor: 'pointer', - fontSize: '0.725rem', - fontWeight: 500, - mt: 0.5, - display: 'block', - '&:hover': { - textDecoration: 'underline', - }, - }} - > - [{isDescriptionExpanded ? 'less' : 'more'}] - - )} - - )} - {variant !== 'small' && variant !== 'minimal' && ( <> diff --git a/frontend/src/components/ui/JobsTable.tsx b/frontend/src/components/ui/JobsTable.tsx deleted file mode 100644 index c86b47b..0000000 --- a/frontend/src/components/ui/JobsTable.tsx +++ /dev/null @@ -1,364 +0,0 @@ -import * as React from 'react'; -import { - Table, - TableBody, - TableCell, - TableContainer, - TableHead, - TableRow, - Paper, - Checkbox, - TablePagination, - TextField, - InputAdornment, - Box, - Typography, - Chip, - CircularProgress, - Alert, - IconButton, - Tooltip, -} from '@mui/material'; -import { - Search as SearchIcon, - Visibility as VisibilityIcon, - Edit as EditIcon, - Delete as DeleteIcon, -} from '@mui/icons-material'; - -import * as Types from 'types/types'; -import { useAuth } from 'hooks/AuthContext'; - -interface JobsTableProps { - onJobSelect?: (selectedJobs: Types.Job[]) => void; - onJobView?: (job: Types.Job) => void; - onJobEdit?: (job: Types.Job) => void; - onJobDelete?: (job: Types.Job) => void; - selectable?: boolean; - showActions?: boolean; -} - -const JobsTable: React.FC = ({ - onJobSelect, - onJobView, - onJobEdit, - onJobDelete, - selectable = true, - showActions = true, -}) => { - const { apiClient } = useAuth(); - const [jobs, setJobs] = React.useState([]); - const [loading, setLoading] = React.useState(true); - const [error, setError] = React.useState(null); - const [page, setPage] = React.useState(0); - const [limit, setLimit] = React.useState(25); - const [total, setTotal] = React.useState(0); - const [searchQuery, setSearchQuery] = React.useState(''); - const [searchTimeout, setSearchTimeout] = React.useState(null); - const [selectedJobs, setSelectedJobs] = React.useState>(new Set()); - - // Fetch jobs from API - const fetchJobs = React.useCallback( - async (pageNum = 0, searchTerm = '') => { - try { - setLoading(true); - setError(null); - const paginationRequest: Partial = { - page: pageNum + 1, - limit: limit, - sortBy: 'createdAt', - sortOrder: 'desc', - }; - - let paginationResponse: Types.PaginatedResponse; - if (searchTerm.trim()) { - paginationResponse = await apiClient.searchJobs(searchTerm); - } else { - paginationResponse = await apiClient.getJobs(paginationRequest); - } - - setJobs(paginationResponse.data); - setTotal(paginationResponse.totalPages); - } catch (err) { - setError(err instanceof Error ? err.message : 'An error occurred while fetching jobs'); - setJobs([]); - setTotal(0); - } finally { - setLoading(false); - } - }, - [limit, apiClient] - ); - - // Initial load - React.useEffect(() => { - fetchJobs(0, searchQuery); - }, [fetchJobs, searchQuery]); - - // Handle search with debouncing - const handleSearchChange = (event: React.ChangeEvent): void => { - const value = event.target.value; - setSearchQuery(value); - - if (searchTimeout) { - clearTimeout(searchTimeout); - } - - const timeout = setTimeout(() => { - setPage(0); - fetchJobs(0, value); - }, 500); - - setSearchTimeout(timeout); - }; - - // Handle page change - const handlePageChange = (event: unknown, newPage: number): void => { - setPage(newPage); - fetchJobs(newPage, searchQuery); - }; - - // Handle rows per page change - const handleRowsPerPageChange = (event: React.ChangeEvent): void => { - const newLimit = parseInt(event.target.value, 10); - setLimit(newLimit); - setPage(0); - fetchJobs(0, searchQuery); - }; - - // Handle selection - const handleSelectAll = (event: React.ChangeEvent): void => { - if (event.target.checked) { - const newSelected = new Set(jobs.map(job => job.id || '')); - setSelectedJobs(newSelected); - onJobSelect?.(jobs); - } else { - setSelectedJobs(new Set()); - onJobSelect?.([]); - } - }; - - const handleSelectJob = (jobId: string): void => { - const newSelected = new Set(selectedJobs); - if (newSelected.has(jobId)) { - newSelected.delete(jobId); - } else { - newSelected.add(jobId); - } - setSelectedJobs(newSelected); - - const selectedJobsList = jobs.filter(job => newSelected.has(job.id || '')); - onJobSelect?.(selectedJobsList); - }; - - const getOwnerName = (owner?: Types.Job['owner']): string => { - if (!owner) return 'Unknown'; - return `${owner.firstName || ''} ${owner.lastName || ''}`.trim() || owner.email || 'Unknown'; - }; - - const truncateDescription = (description: string, maxLength = 100): string => { - if (description.length <= maxLength) return description; - return description.substring(0, maxLength) + '...'; - }; - - const isSelected = (jobId: string): boolean => selectedJobs.has(jobId); - const numSelected = selectedJobs.size; - const rowCount = jobs.length; - - if (error) { - return ( - - {error} - - ); - } - - return ( - - - - Jobs ({total}) - - - - - - ), - }} - sx={{ mb: 2 }} - /> - - - - - - - {selectable && ( - - 0 && numSelected < rowCount} - checked={rowCount > 0 && numSelected === rowCount} - onChange={handleSelectAll} - inputProps={{ - 'aria-label': 'select all jobs', - }} - /> - - )} - Title - Description - {/* Skills */} - Owner - {/* Views */} - Status - Created - {showActions && Actions} - - - - {loading ? ( - - - - - - ) : jobs.length === 0 ? ( - - - - No jobs found - - - - ) : ( - jobs.map(job => { - const isItemSelected = isSelected(job.id || ''); - return ( - { - !selectable && handleSelectJob(job.id || ''); - }} - sx={{ - '&:last-child td, &:last-child th': { border: 0 }, - cursor: selectable ? 'default' : 'pointer', - }} - > - {selectable && ( - - handleSelectJob(job.id || '')} - inputProps={{ - 'aria-labelledby': `job-${job.id}`, - }} - /> - - )} - - - {job.title} - - - - - {truncateDescription(job.description)} - - - {/* - - {job.skills?.slice(0, 3).map((skill) => ( - - ))} - {job.skills && job.skills.length > 3 && ( - - )} - - */} - - {getOwnerName(job.owner)} - - {/* - - {job.views} - - */} - - - - - {job.createdAt?.toLocaleDateString()} - - {showActions && ( - - - {onJobView && ( - - onJobView(job)}> - - - - )} - {onJobEdit && ( - - onJobEdit(job)}> - - - - )} - {onJobDelete && ( - - onJobDelete(job)}> - - - - )} - - - )} - - ); - }) - )} - -
-
- - -
- ); -}; - -export { JobsTable }; diff --git a/frontend/src/components/ui/JobsView.tsx b/frontend/src/components/ui/JobsView.tsx index 799604d..c814a17 100644 --- a/frontend/src/components/ui/JobsView.tsx +++ b/frontend/src/components/ui/JobsView.tsx @@ -534,7 +534,7 @@ const JobsView: React.FC = ({ - {truncateDescription(job.description)} + {truncateDescription(job.summary || job.description || '', 100)} diff --git a/frontend/src/config/navigationConfig.tsx b/frontend/src/config/navigationConfig.tsx index f0f7465..f9abae0 100644 --- a/frontend/src/config/navigationConfig.tsx +++ b/frontend/src/config/navigationConfig.tsx @@ -30,7 +30,6 @@ import { useNavigate } from 'react-router-dom'; import { JobsView } from 'components/ui/JobsView'; import { ResumeViewer } from 'components/ui/ResumeViewer'; -import { JobsTable } from 'components/ui/JobsTable'; import * as Types from 'types/types'; const LogoutPage = (): JSX.Element => { @@ -128,20 +127,8 @@ export const navigationConfig: NavigationConfig = { label: 'Jobs', path: '/candidate/jobs/:jobId?', icon: , - component: , - variant: 'fullWidth', - userTypes: ['candidate', 'guest', 'employer'], - showInNavigation: false, - showInUserMenu: true, - userMenuGroup: 'profile', - }, - { - id: 'jobs-table', - label: 'Jobs Table', - path: '/candidate/jobs-table/:jobId?', - icon: , component: ( - console.log('Selected:', selectedJobs)} onJobView={(job: Types.Job): void => console.log('View job:', job)} onJobEdit={(job: Types.Job): void => console.log('Edit job:', job)} @@ -150,6 +137,7 @@ export const navigationConfig: NavigationConfig = { showActions={true} /> ), + variant: 'fullWidth', userTypes: ['candidate', 'guest', 'employer'], showInNavigation: false, showInUserMenu: true, diff --git a/frontend/src/pages/CandidateChatPage.tsx b/frontend/src/pages/CandidateChatPage.tsx index a70057f..5316724 100644 --- a/frontend/src/pages/CandidateChatPage.tsx +++ b/frontend/src/pages/CandidateChatPage.tsx @@ -10,6 +10,7 @@ import { ChatMessageStreaming, ChatMessageStatus, ChatMessageMetaData, + CandidateQuestion, } from 'types/types'; import { ConversationHandle } from 'components/Conversation'; import { BackstoryPageProps } from 'components/BackstoryTab'; @@ -50,7 +51,7 @@ const defaultMessage: ChatMessage = { const CandidateChatPage = forwardRef( (_props: BackstoryPageProps, ref): JSX.Element => { const { apiClient } = useAuth(); - const { selectedCandidate, setSelectedCandidate } = useSelectedCandidate(); + const { selectedCandidate } = useSelectedCandidate(); const [processingMessage, setProcessingMessage] = useState< ChatMessageStatus | ChatMessageError | null >(null); @@ -219,6 +220,10 @@ const CandidateChatPage = forwardRef( metadata: emptyMetadata, }; + const handleSubmitQuestion = (question: CandidateQuestion): void => { + sendMessage(question.question); + }; + return ( ( action={`Chat with Backstory about ${selectedCandidate.firstName}`} elevation={4} candidate={selectedCandidate} - variant="small" + variant="normal" sx={{ flexShrink: 1, width: '100%', @@ -249,7 +254,7 @@ const CandidateChatPage = forwardRef( minHeight: 'min-content', }} // Prevent header from shrinking /> - + */} {/* Chat Interface */} {/* Scrollable Messages Area */} @@ -308,8 +313,14 @@ const CandidateChatPage = forwardRef(
)} - {selectedCandidate.questions?.length !== 0 && - selectedCandidate.questions?.map((q, i) => )} + {selectedCandidate.questions?.length !== 0 && ( + + {' '} + {selectedCandidate.questions?.map((q, i) => ( + + ))} + + )} {/* Fixed Message Input */} { const isMobile = useMediaQuery(theme.breakpoints.down('sm')); const { user, updateUserData, apiClient } = useAuth(); const chatRef = React.useRef(null); + const [dirty, setDirty] = useState(false); // Check if user is a candidate const candidate = user?.userType === 'candidate' ? (user as Types.Candidate) : null; @@ -119,7 +120,6 @@ const CandidateProfile: React.FC = () => { // Dialog states const [skillDialog, setSkillDialog] = useState(false); - const [questionDialog, setQuestionDialog] = useState(false); const [experienceDialog, setExperienceDialog] = useState(false); // New item states @@ -144,6 +144,13 @@ const CandidateProfile: React.FC = () => { location: { city: '', country: '' }, }); + useEffect(() => { + if (dirty) { + handleSave(); + setDirty(false); + } + }, [formData, dirty, setDirty]); + useEffect(() => { if (candidate) { setFormData(candidate); @@ -208,14 +215,13 @@ const CandidateProfile: React.FC = () => { }; // Save changes - const handleSave = async (section: string): Promise => { + const handleSave = async (): Promise => { setLoading(true); try { if (candidate.id) { const updatedCandidate = await apiClient.updateCandidate(candidate.id, formData); updateUserData(updatedCandidate); setSnack('Profile updated successfully!'); - toggleEditMode(section); } } catch (error) { setSnack('Failed to update profile. Please try again.', 'error'); @@ -243,6 +249,7 @@ const CandidateProfile: React.FC = () => { }); setSkillDialog(false); setSnack('Skill added successfully!'); + setDirty(true); } }; @@ -251,6 +258,7 @@ const CandidateProfile: React.FC = () => { const updatedSkills = (formData.skills || []).filter((_, i) => i !== index); setFormData({ ...formData, skills: updatedSkills }); setSnack('Skill removed successfully!'); + setDirty(true); }; // Add new question @@ -264,8 +272,9 @@ const CandidateProfile: React.FC = () => { setNewQuestion({ question: '', }); - setQuestionDialog(false); + setEditMode({ ...editMode, questions: false }); setSnack('Question added successfully!'); + setDirty(true); } }; @@ -274,6 +283,7 @@ const CandidateProfile: React.FC = () => { const updatedQuestions = (formData.questions || []).filter((_, i) => i !== index); setFormData({ ...formData, questions: updatedQuestions }); setSnack('Question removed successfully!'); + setDirty(true); }; // Add new work experience @@ -303,6 +313,7 @@ const CandidateProfile: React.FC = () => { const updatedExperience = (formData.experience || []).filter((_, i) => i !== index); setFormData({ ...formData, experience: updatedExperience }); setSnack('Experience removed successfully!'); + setDirty(true); }; // Basic Information Tab @@ -515,7 +526,7 @@ const CandidateProfile: React.FC = () => {