719 lines
24 KiB
TypeScript

import React, { useEffect } from 'react';
import {
Box,
Paper,
Table,
TableBody,
TableCell,
TableContainer,
TableHead,
TableRow,
Typography,
FormControl,
Select,
MenuItem,
InputLabel,
Chip,
IconButton,
Dialog,
AppBar,
Toolbar,
useMediaQuery,
useTheme,
Slide,
Checkbox,
TablePagination,
TextField,
InputAdornment,
CircularProgress,
Alert,
Tooltip,
Grid,
SxProps,
} from '@mui/material';
import {
KeyboardArrowUp as ArrowUpIcon,
KeyboardArrowDown as ArrowDownIcon,
Business as BusinessIcon,
Work as WorkIcon,
Schedule as ScheduleIcon,
ArrowBack as ArrowBackIcon,
Search as SearchIcon,
Visibility as VisibilityIcon,
Edit as EditIcon,
Delete as DeleteIcon,
Close as CloseIcon,
} from '@mui/icons-material';
import { TransitionProps } from '@mui/material/transitions';
import * as Types from 'types/types'; // Adjust the import path as necessary
import { useAuth } from 'hooks/AuthContext';
import { StyledMarkdown } from 'components/StyledMarkdown';
import { Scrollable } from 'components/Scrollable';
import { useLocation } from 'react-router-dom';
// async searchJobs(query: string): Promise<Types.PaginatedResponse> {
// const results = await this.getJobs();
// const filtered = results.data.filter(job =>
// job.title.toLowerCase().includes(query.toLowerCase()) ||
// job.description.toLowerCase().includes(query.toLowerCase()) ||
// job.company?.toLowerCase().includes(query.toLowerCase())
// );
// return {
// data: filtered,
// totalPages: 1,
// totalItems: filtered.length,
// };
// }
type SortField = 'updatedAt' | 'createdAt' | 'company' | 'title';
type SortOrder = 'asc' | 'desc';
interface JobsViewProps {
filter?: Record<string, string | number | boolean>;
onJobSelect?: (selectedJobs: Types.Job[]) => void;
onJobView?: (job: Types.Job) => void;
onJobEdit?: (job: Types.Job) => void;
onJobDelete?: (job: Types.Job) => Promise<void>;
selectable?: boolean;
showActions?: boolean;
showDetailsPanel?: boolean;
variant?: 'table' | 'list' | 'responsive';
sx?: SxProps;
}
const Transition = React.forwardRef(function Transition(
props: TransitionProps & {
children: React.ReactElement;
},
ref: React.Ref<unknown>
) {
return <Slide direction="up" ref={ref} {...props} />;
});
const JobInfoPanel: React.FC<{ job: Types.Job; onClose?: () => void; inDialog?: boolean }> = ({
job,
onClose,
inDialog = false,
}) => (
<Box
sx={{
p: inDialog ? 2 : 1.5,
height: '100%',
}}
>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', mb: 2 }}>
<Typography variant="h5" component="h1" gutterBottom>
{job.title}
</Typography>
{onClose && !inDialog && (
<IconButton onClick={onClose} size="small">
<CloseIcon />
</IconButton>
)}
</Box>
<Typography variant="h6" color="primary" gutterBottom>
{job.company}
</Typography>
<Box sx={{ mb: 2 }}>
<Chip
label={job.details?.isActive ? 'Active' : 'Inactive'}
color={job.details?.isActive ? 'success' : 'default'}
size="small"
sx={{ mr: 1 }}
/>
{job.details?.employmentType && (
<Chip label={job.details.employmentType} variant="outlined" size="small" sx={{ mr: 1 }} />
)}
</Box>
{job.details?.location && (
<Typography variant="body2" color="text.secondary" gutterBottom>
📍 {job.details.location.city}, {job.details.location.state || job.details.location.country}
</Typography>
)}
<StyledMarkdown content={job.description} />
{job.requirements &&
job.requirements.technicalSkills &&
job.requirements.technicalSkills.required &&
job.requirements.technicalSkills.required.length > 0 && (
<Box sx={{ mt: 2 }}>
<Typography variant="subtitle2" gutterBottom>
Required Skills
</Typography>
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 0.5 }}>
{job.requirements.technicalSkills.required.map(skill => (
<Chip key={skill} label={skill} size="small" variant="outlined" />
))}
</Box>
</Box>
)}
<Box sx={{ mt: 3, pt: 2, borderTop: 1, borderColor: 'divider' }}>
<Typography variant="body2" color="text.secondary">
Posted: {job.createdAt?.toLocaleDateString()}
</Typography>
<Typography variant="body2" color="text.secondary">
Updated: {job.updatedAt?.toLocaleDateString()}
</Typography>
{/* {job.views && (
<Typography variant="body2" color="text.secondary">
Views: {job.views}
</Typography>
)} */}
</Box>
</Box>
);
const JobsView: React.FC<JobsViewProps> = ({
onJobSelect,
onJobView,
onJobEdit,
onJobDelete,
selectable = true,
showActions = true,
showDetailsPanel = true,
filter = {},
sx = {},
}) => {
const theme = useTheme();
const { apiClient, user } = useAuth();
const isMobile = useMediaQuery(theme.breakpoints.down('md'));
const isSmall = useMediaQuery(theme.breakpoints.down('sm'));
const location = useLocation();
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());
const [selectedJob, setSelectedJob] = React.useState<Types.Job | null>(null);
const [sortField, setSortField] = React.useState<SortField>('updatedAt');
const [sortOrder, setSortOrder] = React.useState<SortOrder>('desc');
const [mobileDialogOpen, setMobileDialogOpen] = React.useState(false);
const [detailsPanelOpen, setDetailsPanelOpen] = React.useState(showDetailsPanel);
if (location.pathname.indexOf('/candidate/jobs') === 0) {
filter = { ...filter, owner_id: user?.id || '' };
}
const fetchJobs = React.useCallback(
async (pageNum = 0, searchTerm = '') => {
try {
setLoading(true);
setError(null);
const paginationRequest: Partial<Types.PaginatedRequest> = {
page: pageNum + 1,
limit: limit,
sortBy: sortField,
sortOrder: sortOrder,
filters: filter,
};
let paginationResponse: Types.PaginatedResponse;
if (searchTerm.trim()) {
paginationResponse = await apiClient.searchJobs(searchTerm);
} else {
paginationResponse = await apiClient.getJobs(paginationRequest);
}
const sortedJobs = sortJobs(paginationResponse.data, sortField, sortOrder);
let updated = false;
if (jobs.length) {
if (sortedJobs.length !== jobs.length) {
updated = true;
} else {
for (let i = 0; i < sortedJobs.length; i++) {
if (sortedJobs[i].id !== jobs[i].id) {
updated = true;
break;
}
}
}
} else {
updated = true;
}
if (updated) {
setJobs(sortedJobs);
setTotal(paginationResponse.total);
}
} catch (err) {
setError(err instanceof Error ? err.message : 'An error occurred while fetching jobs');
setJobs([]);
setTotal(0);
} finally {
setLoading(false);
}
},
[limit, sortField, sortOrder, apiClient]
);
useEffect(() => {
if (jobs.length > 0 && !selectedJob && detailsPanelOpen) {
console.log('Setting selected job from fetchJobs');
setSelectedJob(jobs[0]);
}
}, [jobs, selectedJob, detailsPanelOpen]);
React.useEffect(() => {
console.log('Fetching jobs with filter:', filter, 'searchQuery:', searchQuery);
fetchJobs(0, searchQuery);
}, [fetchJobs, searchQuery]);
const sortJobs = (jobsList: Types.Job[], field: SortField, order: SortOrder): Types.Job[] => {
return [...jobsList].sort((a, b) => {
let aValue: number | string;
let bValue: number | string;
switch (field) {
case 'updatedAt':
aValue = a.updatedAt?.getTime() || 0;
bValue = b.updatedAt?.getTime() || 0;
break;
case 'createdAt':
aValue = a.createdAt?.getTime() || 0;
bValue = b.createdAt?.getTime() || 0;
break;
case 'company':
aValue = a.company?.toLowerCase() || '';
bValue = b.company?.toLowerCase() || '';
break;
case 'title':
aValue = a.title?.toLowerCase() || '';
bValue = b.title?.toLowerCase() || '';
break;
default:
return 0;
}
if (aValue < bValue) return order === 'asc' ? -1 : 1;
if (aValue > bValue) return order === 'asc' ? 1 : -1;
return 0;
});
};
const handleSearchChange = (event: React.ChangeEvent<HTMLInputElement>): void => {
console.log('Handling search change:', event.target.value);
const value = event.target.value;
setSearchQuery(value);
if (searchTimeout) {
clearTimeout(searchTimeout);
}
const timeout = setTimeout(() => {
setPage(0);
fetchJobs(0, value);
}, 500);
setSearchTimeout(timeout);
};
const handlePageChange = (event: unknown, newPage: number): void => {
console.log('Handling page change:', newPage);
setPage(newPage);
fetchJobs(newPage, searchQuery);
};
const handleRowsPerPageChange = (event: React.ChangeEvent<HTMLInputElement>): void => {
console.log('Handling rows per page change:', event.target.value);
const newLimit = parseInt(event.target.value, 10);
setLimit(newLimit);
setPage(0);
fetchJobs(0, searchQuery);
};
const handleSelectAll = (event: React.ChangeEvent<HTMLInputElement>): void => {
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): 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 handleSort = (field: SortField): void => {
if (sortField === field) {
setSortOrder(sortOrder === 'asc' ? 'desc' : 'asc');
} else {
setSortField(field);
setSortOrder('desc');
}
};
const handleJobRowClick = (job: Types.Job): void => {
setSelectedJob(job);
if (isMobile) {
setMobileDialogOpen(true);
} else if (detailsPanelOpen || !isMobile) {
setDetailsPanelOpen(true);
}
onJobView?.(job);
};
const getOwnerName = (owner?: Types.BaseUser): 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 formatDate = (date: Date | undefined): string => {
if (!date) return 'N/A';
return new Intl.DateTimeFormat('en-US', {
month: 'short',
day: 'numeric',
...(isMobile ? {} : { year: 'numeric' }),
...(isSmall ? {} : { hour: '2-digit', minute: '2-digit' }),
}).format(date);
};
const getSortIcon = (field: SortField): React.ReactElement => {
if (sortField !== field) return <></>;
return sortOrder === 'asc' ? (
<ArrowUpIcon fontSize="small" />
) : (
<ArrowDownIcon fontSize="small" />
);
};
const isSelected = (jobId: string): boolean => selectedJobs.has(jobId);
const numSelected = selectedJobs.size;
const rowCount = jobs.length;
if (error) {
return (
<Paper sx={{ p: 2 }}>
<Alert severity="error">{error}</Alert>
</Paper>
);
}
const tableContent = (
<>
<Box sx={{ p: 2 }}>
<Grid container spacing={2} alignItems="center">
<Grid size={{ xs: 12, md: 6 }}>
<Typography variant="h6" component="h2">
Jobs ({total})
</Typography>
</Grid>
<Grid size={{ xs: 12, md: 6 }}>
<Box sx={{ display: 'flex', gap: 1, alignItems: 'center' }}>
<TextField
fullWidth
size="small"
placeholder="Search jobs..."
value={searchQuery}
onChange={handleSearchChange}
InputProps={{
startAdornment: (
<InputAdornment position="start">
<SearchIcon />
</InputAdornment>
),
}}
/>
<FormControl size="small" sx={{ minWidth: 150 }}>
<InputLabel>Sort</InputLabel>
<Select
value={`${sortField}-${sortOrder}`}
label="Sort"
onChange={(e): void => {
const [field, order] = e.target.value.split('-') as [SortField, SortOrder];
setSortField(field);
setSortOrder(order);
}}
>
<MenuItem value="updatedAt-desc">Updated </MenuItem>
<MenuItem value="updatedAt-asc">Updated </MenuItem>
<MenuItem value="createdAt-desc">Created </MenuItem>
<MenuItem value="createdAt-asc">Created </MenuItem>
<MenuItem value="company-asc">Company A-Z</MenuItem>
<MenuItem value="company-desc">Company Z-A</MenuItem>
<MenuItem value="title-asc">Title A-Z</MenuItem>
<MenuItem value="title-desc">Title Z-A</MenuItem>
</Select>
</FormControl>
</Box>
</Grid>
</Grid>
</Box>
<TableContainer>
<Table size="small" aria-label="jobs table">
<TableHead>
<TableRow sx={{ '& th': { whiteSpace: 'nowrap' } }}>
{selectable && (
<TableCell padding="checkbox">
<Checkbox
color="primary"
indeterminate={numSelected > 0 && numSelected < rowCount}
checked={rowCount > 0 && numSelected === rowCount}
onChange={handleSelectAll}
/>
</TableCell>
)}
<TableCell sx={{ cursor: 'pointer' }} onClick={(): void => handleSort('company')}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
<BusinessIcon fontSize="small" />
Company {getSortIcon('company')}
</Box>
</TableCell>
<TableCell sx={{ cursor: 'pointer' }} onClick={(): void => handleSort('title')}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
<WorkIcon fontSize="small" />
Title {getSortIcon('title')}
</Box>
</TableCell>
<TableCell>Description</TableCell>
{!isMobile && (
<>
<TableCell>Owner</TableCell>
<TableCell
sx={{ cursor: 'pointer' }}
onClick={(): void => handleSort('updatedAt')}
>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
<ScheduleIcon fontSize="small" />
Updated {getSortIcon('updatedAt')}
</Box>
</TableCell>
</>
)}
{/* <TableCell>Status</TableCell> */}
{!isMobile && showActions && <TableCell align="center">Actions</TableCell>}
</TableRow>
</TableHead>
<TableBody>
{loading ? (
<TableRow>
<TableCell colSpan={8} align="center">
<CircularProgress size={24} />
</TableCell>
</TableRow>
) : jobs.length === 0 ? (
<TableRow>
<TableCell colSpan={8} 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
selected={isItemSelected || selectedJob?.id === job.id}
onClick={(): void => handleJobRowClick(job)}
sx={{ cursor: 'pointer' }}
>
{selectable && (
<TableCell padding="checkbox" onClick={(e): void => e.stopPropagation()}>
<Checkbox
color="primary"
checked={isItemSelected}
onChange={(): void => handleSelectJob(job.id || '')}
/>
</TableCell>
)}
<TableCell>
<Typography variant="body2" fontWeight="medium">
{job.company}
</Typography>
{job.details?.location && (
<Typography variant="caption" color="text.secondary">
{job.details.location.city}, {job.details.location.state}
</Typography>
)}
</TableCell>
<TableCell>
<Typography variant="body2" fontWeight="medium">
{job.title}
</Typography>
{job.details?.employmentType && (
<Chip
label={job.details.employmentType}
size="small"
variant="outlined"
sx={{ mt: 0.5 }}
/>
)}
</TableCell>
<TableCell>
<Typography variant="body2" color="textSecondary">
{truncateDescription(job.summary || job.description || '', 100)}
</Typography>
</TableCell>
{!isMobile && (
<>
<TableCell>
<Typography variant="body2">{getOwnerName(job.owner)}</Typography>
</TableCell>
<TableCell>
<Typography variant="body2">{formatDate(job.updatedAt)}</Typography>
</TableCell>
</>
)}
{/* <TableCell>
<Chip
label={job.details?.isActive ? 'Active' : 'Inactive'}
color={job.details?.isActive ? 'success' : 'default'}
size="small"
/>
</TableCell> */}
{!isMobile && showActions && (
<TableCell align="center" onClick={(e): void => e.stopPropagation()}>
<Box sx={{ display: 'flex', gap: 0.5 }}>
{onJobView && (
<Tooltip title="View Job">
<IconButton size="small" onClick={(): void => onJobView(job)}>
<VisibilityIcon fontSize="small" />
</IconButton>
</Tooltip>
)}
{onJobEdit && (
<Tooltip title="Edit Job">
<IconButton size="small" onClick={(): void => onJobEdit(job)}>
<EditIcon fontSize="small" />
</IconButton>
</Tooltip>
)}
{onJobDelete && (
<Tooltip title="Delete Job">
<IconButton
size="small"
onClick={async (): Promise<void> => {
await onJobDelete(job);
fetchJobs(0, searchQuery);
}}
>
<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}
/>
</>
);
return (
<Box sx={{ flexGrow: 1, display: 'flex', flexDirection: 'row', position: 'relative', ...sx }}>
<Scrollable
sx={{ display: 'flex', flex: 1, flexDirection: 'column', height: '100%', width: '100%' }}
>
<Paper sx={{ flex: 1, display: 'flex', flexDirection: 'column' }}>{tableContent}</Paper>
</Scrollable>
{detailsPanelOpen && !isMobile && (
<Scrollable
sx={{ display: 'flex', flex: 1, flexDirection: 'row', height: '100%', width: '100%' }}
>
<Paper sx={{ flex: 1, ml: 1 }}>
{selectedJob ? (
<JobInfoPanel
job={selectedJob}
onClose={(): void => {
console.log('Closing JobInfoPanel');
setDetailsPanelOpen(false);
setSelectedJob(null);
}}
/>
) : (
<Box
sx={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
height: '100%',
color: 'text.secondary',
textAlign: 'center',
p: 2,
}}
>
<Typography variant="body2">Select a job to view details</Typography>
</Box>
)}
</Paper>
</Scrollable>
)}
<Dialog
fullScreen
open={mobileDialogOpen}
onClose={(): void => setMobileDialogOpen(false)}
TransitionComponent={Transition}
>
<AppBar sx={{ position: 'relative' }}>
<Toolbar>
<IconButton
edge="start"
color="inherit"
onClick={(): void => setMobileDialogOpen(false)}
>
<ArrowBackIcon />
</IconButton>
<Box sx={{ ml: 2, flex: 1 }}>
<Typography variant="h6" noWrap>
{selectedJob?.title}
</Typography>
<Typography variant="body2" sx={{ opacity: 0.8 }} noWrap>
{selectedJob?.company}
</Typography>
</Box>
</Toolbar>
</AppBar>
{selectedJob && <JobInfoPanel job={selectedJob} inDialog />}
</Dialog>
</Box>
);
};
export { JobsView };