Adding dense JobsTable
This commit is contained in:
parent
17381dded1
commit
5c867af814
359
frontend/src/components/ui/JobsTable.tsx
Normal file
359
frontend/src/components/ui/JobsTable.tsx
Normal 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 };
|
@ -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',
|
||||||
|
@ -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);
|
||||||
|
Loading…
x
Reference in New Issue
Block a user