diff --git a/frontend/src/components/ui/JobsTable.tsx b/frontend/src/components/ui/JobsTable.tsx new file mode 100644 index 0000000..b4391a7 --- /dev/null +++ b/frontend/src/components/ui/JobsTable.tsx @@ -0,0 +1,359 @@ +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: number = 0, searchTerm: string = '') => { + try { + setLoading(true); + setError(null); + const paginationRequest : Partial = { + page: pageNum + 1, + limit: limit, + sortBy: 'createdAt', + sortOrder: 'desc', + }; + + let paginationResponse : Types.PaginatedResponse; + let url = `/api/1.0/jobs`; + 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]); + + // Initial load + React.useEffect(() => { + fetchJobs(0, searchQuery); + }, [fetchJobs]); + + // Handle search with debouncing + const handleSearchChange = (event: React.ChangeEvent) => { + 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) => { + setPage(newPage); + fetchJobs(newPage, searchQuery); + }; + + // Handle rows per page change + const handleRowsPerPageChange = (event: React.ChangeEvent) => { + const newLimit = parseInt(event.target.value, 10); + setLimit(newLimit); + setPage(0); + fetchJobs(0, searchQuery); + }; + + // Handle selection + const handleSelectAll = (event: React.ChangeEvent) => { + 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) => { + 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); + }; + + // Utility functions + const formatDate = (dateString: string) => { + return new Date(dateString).toLocaleDateString(); + }; + + const getOwnerName = (owner?: Types.Job['owner']) => { + if (!owner) return 'Unknown'; + return `${owner.firstName || ''} ${owner.lastName || ''}`.trim() || owner.email || 'Unknown'; + }; + + const truncateDescription = (description: string, maxLength: number = 100) => { + if (description.length <= maxLength) return description; + return description.substring(0, maxLength) + '...'; + }; + + const isSelected = (jobId: string) => 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 || '')} + 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 }; \ No newline at end of file diff --git a/frontend/src/config/navigationConfig.tsx b/frontend/src/config/navigationConfig.tsx index edda268..8053770 100644 --- a/frontend/src/config/navigationConfig.tsx +++ b/frontend/src/config/navigationConfig.tsx @@ -48,6 +48,9 @@ import { JobViewer } from 'components/ui/JobViewer'; import { CandidatePicker } from 'components/ui/CandidatePicker'; import { ResumeViewer } from 'components/ui/ResumeViewer'; +import { JobsTable } from 'components/ui/JobsTable'; +import * as Types from 'types/types'; + // Beta page components for placeholder routes const BackstoryPage = () => ( @@ -195,6 +198,24 @@ export const navigationConfig: NavigationConfig = { 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) => console.log('View job:', job)} + onJobEdit={(job: Types.Job) => console.log('Edit job:', job)} + onJobDelete={(job: Types.Job) => console.log('Delete job:', job)} + selectable={true} + showActions={true} + />, + userTypes: ['candidate', 'guest', 'employer'], + showInNavigation: false, + showInUserMenu: true, + userMenuGroup: 'profile', + }, { id: 'explore-resumes', label: 'Resumes', diff --git a/frontend/src/hooks/useAutoScrollToBottom.tsx b/frontend/src/hooks/useAutoScrollToBottom.tsx index 185f9db..cf8c688 100644 --- a/frontend/src/hooks/useAutoScrollToBottom.tsx +++ b/frontend/src/hooks/useAutoScrollToBottom.tsx @@ -107,7 +107,7 @@ const useResizeObserverAndMutationObserver = ( const useAutoScrollToBottom = ( scrollToRef: RefObject, smooth = true, - fallbackThreshold = 0.33, + fallbackThreshold = 0.33 ): RefObject => { const containerRef = useRef(null); const lastScrollTop = useRef(0);