Adding dense JobsTable

This commit is contained in:
James Ketr 2025-06-19 08:32:22 -07:00
parent 17381dded1
commit 5c867af814
3 changed files with 381 additions and 1 deletions

View File

@ -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<JobsTableProps> = ({
onJobSelect,
onJobView,
onJobEdit,
onJobDelete,
selectable = true,
showActions = true,
}) => {
const { apiClient } = useAuth();
const [jobs, setJobs] = React.useState<Types.Job[]>([]);
const [loading, setLoading] = React.useState<boolean>(true);
const [error, setError] = React.useState<string | null>(null);
const [page, setPage] = React.useState<number>(0);
const [limit, setLimit] = React.useState<number>(25);
const [total, setTotal] = React.useState<number>(0);
const [searchQuery, setSearchQuery] = React.useState<string>('');
const [searchTimeout, setSearchTimeout] = React.useState<NodeJS.Timeout | null>(null);
const [selectedJobs, setSelectedJobs] = React.useState<Set<string>>(new Set());
// Fetch jobs from API
const fetchJobs = React.useCallback(async (pageNum: number = 0, searchTerm: string = '') => {
try {
setLoading(true);
setError(null);
const paginationRequest : Partial<Types.PaginatedRequest> = {
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<HTMLInputElement>) => {
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<HTMLInputElement>) => {
const newLimit = parseInt(event.target.value, 10);
setLimit(newLimit);
setPage(0);
fetchJobs(0, searchQuery);
};
// Handle selection
const handleSelectAll = (event: React.ChangeEvent<HTMLInputElement>) => {
if (event.target.checked) {
const newSelected = new Set<string>(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 (
<Paper sx={{ p: 2 }}>
<Alert severity="error">{error}</Alert>
</Paper>
);
}
return (
<Paper>
<Box sx={{ p: 2 }}>
<Typography variant="h6" component="h2" sx={{ mb: 2 }}>
Jobs ({total})
</Typography>
<TextField
fullWidth
size="small"
placeholder="Search jobs by title, description, or skills..."
value={searchQuery}
onChange={handleSearchChange}
InputProps={{
startAdornment: (
<InputAdornment position="start">
<SearchIcon />
</InputAdornment>
),
}}
sx={{ mb: 2 }}
/>
</Box>
<TableContainer>
<Table size="small" aria-label="jobs table">
<TableHead sx={{"& th": { whiteSpace: "nowrap" }}}>
<TableRow>
{selectable && (
<TableCell padding="checkbox">
<Checkbox
color="primary"
indeterminate={numSelected > 0 && numSelected < rowCount}
checked={rowCount > 0 && numSelected === rowCount}
onChange={handleSelectAll}
inputProps={{
'aria-label': 'select all jobs',
}}
/>
</TableCell>
)}
<TableCell>Title</TableCell>
<TableCell>Description</TableCell>
{/* <TableCell>Skills</TableCell> */}
<TableCell>Owner</TableCell>
{/* <TableCell align="right">Views</TableCell> */}
<TableCell>Status</TableCell>
<TableCell>Created</TableCell>
{showActions && <TableCell align="center">Actions</TableCell>}
</TableRow>
</TableHead>
<TableBody sx={{ "& p, & td" : { m: 0, p: 0.5, fontSize: '0.75rem !important'}}}>
{loading ? (
<TableRow>
<TableCell colSpan={selectable ? (showActions ? 9 : 8) : (showActions ? 8 : 7)} align="center">
<CircularProgress size={24} />
</TableCell>
</TableRow>
) : jobs.length === 0 ? (
<TableRow>
<TableCell colSpan={selectable ? (showActions ? 9 : 8) : (showActions ? 8 : 7)} align="center">
<Typography variant="body2" color="textSecondary">
No jobs found
</Typography>
</TableCell>
</TableRow>
) : (
jobs.map((job) => {
const isItemSelected = isSelected(job.id || '');
return (
<TableRow
key={job.id}
hover
role="checkbox"
aria-checked={isItemSelected}
selected={isItemSelected}
sx={{ '&:last-child td, &:last-child th': { border: 0 } }}
>
{selectable && (
<TableCell padding="checkbox">
<Checkbox
color="primary"
checked={isItemSelected}
onChange={() => handleSelectJob(job.id || '')}
inputProps={{
'aria-labelledby': `job-${job.id}`,
}}
/>
</TableCell>
)}
<TableCell scope="row" id={`job-${job.id}`}>
<Typography variant="body2" fontWeight="medium">
{job.title}
</Typography>
</TableCell>
<TableCell>
<Typography variant="body2" color="textSecondary">
{truncateDescription(job.description)}
</Typography>
</TableCell>
{/* <TableCell>
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 0.5 }}>
{job.skills?.slice(0, 3).map((skill) => (
<Chip key={skill} label={skill} size="small" variant="outlined" />
))}
{job.skills && job.skills.length > 3 && (
<Chip label={`+${job.skills.length - 3}`} size="small" />
)}
</Box>
</TableCell> */}
<TableCell>
<Typography variant="body2">
{getOwnerName(job.owner)}
</Typography>
</TableCell>
{/* <TableCell align="right">
<Typography variant="body2">
{job.views}
</Typography>
</TableCell> */}
<TableCell>
<Chip
label={job.details?.isActive ? 'Active' : 'Inactive'}
color={job.details?.isActive ? 'success' : 'default'}
size="small"
/>
</TableCell>
<TableCell sx={{whiteSpace: "nowrap" }}>
<Typography variant="body2">
{job.createdAt?.toLocaleDateString()}
</Typography>
</TableCell>
{showActions && (
<TableCell align="center">
<Box sx={{ display: 'flex', gap: 0.5 }}>
{onJobView && (
<Tooltip title="View Job">
<IconButton size="small" onClick={() => onJobView(job)}>
<VisibilityIcon fontSize="small" />
</IconButton>
</Tooltip>
)}
{onJobEdit && (
<Tooltip title="Edit Job">
<IconButton size="small" onClick={() => onJobEdit(job)}>
<EditIcon fontSize="small" />
</IconButton>
</Tooltip>
)}
{onJobDelete && (
<Tooltip title="Delete Job">
<IconButton size="small" onClick={() => onJobDelete(job)}>
<DeleteIcon fontSize="small" />
</IconButton>
</Tooltip>
)}
</Box>
</TableCell>
)}
</TableRow>
);
})
)}
</TableBody>
</Table>
</TableContainer>
<TablePagination
rowsPerPageOptions={[10, 25, 50, 100]}
component="div"
count={total}
rowsPerPage={limit}
page={page}
onPageChange={handlePageChange}
onRowsPerPageChange={handleRowsPerPageChange}
/>
</Paper>
);
};
export { JobsTable };

View File

@ -48,6 +48,9 @@ import { JobViewer } from 'components/ui/JobViewer';
import { CandidatePicker } from 'components/ui/CandidatePicker'; import { CandidatePicker } from 'components/ui/CandidatePicker';
import { ResumeViewer } from 'components/ui/ResumeViewer'; import { ResumeViewer } from 'components/ui/ResumeViewer';
import { JobsTable } from 'components/ui/JobsTable';
import * as Types from 'types/types';
// Beta page components for placeholder routes // Beta page components for placeholder routes
const BackstoryPage = () => ( const BackstoryPage = () => (
<BetaPage> <BetaPage>
@ -195,6 +198,24 @@ export const navigationConfig: NavigationConfig = {
showInUserMenu: true, showInUserMenu: true,
userMenuGroup: 'profile', userMenuGroup: 'profile',
}, },
{
id: 'jobs-table',
label: 'Jobs Table',
path: '/candidate/jobs-table/:jobId?',
icon: <WorkIcon />,
component: <JobsTable
onJobSelect={(selectedJobs: Types.Job[]) => 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', id: 'explore-resumes',
label: 'Resumes', label: 'Resumes',

View File

@ -107,7 +107,7 @@ const useResizeObserverAndMutationObserver = (
const useAutoScrollToBottom = ( const useAutoScrollToBottom = (
scrollToRef: RefObject<HTMLElement | null>, scrollToRef: RefObject<HTMLElement | null>,
smooth = true, smooth = true,
fallbackThreshold = 0.33, fallbackThreshold = 0.33
): RefObject<HTMLDivElement | null> => { ): RefObject<HTMLDivElement | null> => {
const containerRef = useRef<HTMLDivElement | null>(null); const containerRef = useRef<HTMLDivElement | null>(null);
const lastScrollTop = useRef(0); const lastScrollTop = useRef(0);