641 lines
20 KiB
TypeScript
641 lines
20 KiB
TypeScript
import React, { JSX, useEffect, useState } from 'react';
|
|
import {
|
|
Box,
|
|
Paper,
|
|
Table,
|
|
TableBody,
|
|
TableCell,
|
|
TableContainer,
|
|
TableHead,
|
|
TableRow,
|
|
Typography,
|
|
FormControl,
|
|
Select,
|
|
MenuItem,
|
|
InputLabel,
|
|
IconButton,
|
|
Dialog,
|
|
AppBar,
|
|
Toolbar,
|
|
useMediaQuery,
|
|
useTheme,
|
|
Slide,
|
|
TextField,
|
|
InputAdornment,
|
|
} from '@mui/material';
|
|
import {
|
|
KeyboardArrowUp as ArrowUpIcon,
|
|
KeyboardArrowDown as ArrowDownIcon,
|
|
Work as WorkIcon,
|
|
Person as PersonIcon,
|
|
Schedule as ScheduleIcon,
|
|
ArrowBack as ArrowBackIcon,
|
|
Search as SearchIcon,
|
|
Clear as ClearIcon,
|
|
} from '@mui/icons-material';
|
|
import { TransitionProps } from '@mui/material/transitions';
|
|
import { ResumeInfo } from 'components/ui/ResumeInfo';
|
|
import { useAuth } from 'hooks/AuthContext';
|
|
import { useAppState, useSelectedResume } from 'hooks/GlobalContext'; // Assuming similar context exists
|
|
import { useNavigate, useParams } from 'react-router-dom';
|
|
import { Resume } from 'types/types';
|
|
|
|
type SortField = 'updatedAt' | 'createdAt' | 'candidateId' | 'jobId';
|
|
type SortOrder = 'asc' | 'desc';
|
|
|
|
interface ResumeViewerProps {
|
|
onSelect?: (resume: Resume) => void;
|
|
candidateId?: string; // Optional filter by candidate
|
|
jobId?: string; // Optional filter by job
|
|
}
|
|
|
|
const Transition = React.forwardRef(function Transition(
|
|
props: TransitionProps & {
|
|
children: React.ReactElement;
|
|
},
|
|
ref: React.Ref<unknown>
|
|
) {
|
|
return <Slide direction="up" ref={ref} {...props} />;
|
|
});
|
|
|
|
const ResumeViewer: React.FC<ResumeViewerProps> = ({ onSelect, candidateId, jobId }) => {
|
|
const navigate = useNavigate();
|
|
const theme = useTheme();
|
|
const isMobile = useMediaQuery(theme.breakpoints.down('md'));
|
|
const isSmall = useMediaQuery(theme.breakpoints.down('sm'));
|
|
|
|
const { apiClient } = useAuth();
|
|
const { selectedResume, setSelectedResume } = useSelectedResume(); // Assuming similar context
|
|
const { setSnack } = useAppState();
|
|
const [resumes, setResumes] = useState<Resume[]>([]);
|
|
const [sortField, setSortField] = useState<SortField>('updatedAt');
|
|
const [sortOrder, setSortOrder] = useState<SortOrder>('desc');
|
|
const [mobileDialogOpen, setMobileDialogOpen] = useState(false);
|
|
const [searchQuery, setSearchQuery] = useState('');
|
|
const [filteredResumes, setFilteredResumes] = useState<Resume[]>([]);
|
|
const { resumeId } = useParams<{ resumeId?: string }>();
|
|
|
|
useEffect(() => {
|
|
const getResumes = async (): Promise<void> => {
|
|
try {
|
|
let results;
|
|
|
|
if (candidateId) {
|
|
results = await apiClient.getResumesByCandidate(candidateId);
|
|
} else if (jobId) {
|
|
results = await apiClient.getResumesByJob(jobId);
|
|
} else {
|
|
results = await apiClient.getResumes();
|
|
}
|
|
|
|
const resumesData: Resume[] = results.resumes || [];
|
|
setResumes(resumesData);
|
|
setFilteredResumes(resumesData);
|
|
|
|
if (resumeId) {
|
|
const resume = resumesData.find(r => r.id === resumeId);
|
|
if (resume) {
|
|
setSelectedResume(resume);
|
|
onSelect?.(resume);
|
|
return;
|
|
}
|
|
}
|
|
|
|
// Auto-select first resume if none selected
|
|
if (resumesData.length > 0 && !selectedResume) {
|
|
const firstResume = sortResumes(resumesData, sortField, sortOrder)[0];
|
|
setSelectedResume(firstResume);
|
|
onSelect?.(firstResume);
|
|
}
|
|
} catch (err) {
|
|
console.error('Failed to load resumes:', err);
|
|
setSnack('Failed to load resumes: ' + err, 'error');
|
|
}
|
|
};
|
|
|
|
getResumes();
|
|
}, [
|
|
apiClient,
|
|
setSnack,
|
|
candidateId,
|
|
jobId,
|
|
resumeId,
|
|
selectedResume,
|
|
setSelectedResume,
|
|
onSelect,
|
|
sortField,
|
|
sortOrder,
|
|
]);
|
|
|
|
// Filter resumes based on search query
|
|
useEffect(() => {
|
|
if (!searchQuery.trim()) {
|
|
setFilteredResumes(resumes);
|
|
} else {
|
|
const filtered = resumes.filter(
|
|
resume =>
|
|
resume.candidate?.fullName?.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
|
resume.job?.title?.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
|
resume.job?.company?.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
|
resume.resume?.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
|
resume.id?.toLowerCase().includes(searchQuery.toLowerCase())
|
|
);
|
|
setFilteredResumes(filtered);
|
|
}
|
|
}, [searchQuery, resumes]);
|
|
|
|
const sortResumes = (resumesList: Resume[], field: SortField, order: SortOrder): Resume[] => {
|
|
return [...resumesList].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 'candidateId':
|
|
aValue = a.candidate?.fullName?.toLowerCase() || a.candidateId?.toLowerCase() || '';
|
|
bValue = b.candidate?.fullName?.toLowerCase() || b.candidateId?.toLowerCase() || '';
|
|
break;
|
|
case 'jobId':
|
|
aValue = a.job?.title?.toLowerCase() || a.jobId?.toLowerCase() || '';
|
|
bValue = b.job?.title?.toLowerCase() || b.jobId?.toLowerCase() || '';
|
|
break;
|
|
default:
|
|
return 0;
|
|
}
|
|
|
|
if (aValue < bValue) return order === 'asc' ? -1 : 1;
|
|
if (aValue > bValue) return order === 'asc' ? 1 : -1;
|
|
return 0;
|
|
});
|
|
};
|
|
|
|
const handleSort = (field: SortField): void => {
|
|
if (sortField === field) {
|
|
setSortOrder(sortOrder === 'asc' ? 'desc' : 'asc');
|
|
} else {
|
|
setSortField(field);
|
|
setSortOrder('desc');
|
|
}
|
|
};
|
|
|
|
const handleResumeSelect = (resume: Resume): void => {
|
|
setSelectedResume(resume);
|
|
onSelect?.(resume);
|
|
if (isMobile) {
|
|
setMobileDialogOpen(true);
|
|
} else {
|
|
navigate(`/candidate/resumes/${resume.id}`);
|
|
}
|
|
};
|
|
|
|
const handleMobileDialogClose = (): void => {
|
|
setMobileDialogOpen(false);
|
|
};
|
|
|
|
const handleSearchClear = (): void => {
|
|
setSearchQuery('');
|
|
};
|
|
|
|
const sortedResumes = sortResumes(filteredResumes, sortField, sortOrder);
|
|
|
|
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): JSX.Element => {
|
|
if (sortField !== field) return <></>;
|
|
return sortOrder === 'asc' ? (
|
|
<ArrowUpIcon fontSize="small" />
|
|
) : (
|
|
<ArrowDownIcon fontSize="small" />
|
|
);
|
|
};
|
|
|
|
const getDisplayTitle = (): string => {
|
|
if (candidateId) return `Resumes for Candidate`;
|
|
if (jobId) return `Resumes for Job`;
|
|
return `All Resumes`;
|
|
};
|
|
|
|
const ResumeList = (): JSX.Element => (
|
|
<Paper
|
|
elevation={isMobile ? 0 : 1}
|
|
sx={{
|
|
display: 'flex',
|
|
flexDirection: 'column',
|
|
...(isMobile
|
|
? {
|
|
width: '100%',
|
|
boxShadow: 'none',
|
|
backgroundColor: 'transparent',
|
|
}
|
|
: { width: '50%' }),
|
|
}}
|
|
>
|
|
<Box
|
|
sx={{
|
|
p: isMobile ? 0.5 : 1,
|
|
borderBottom: 1,
|
|
borderColor: 'divider',
|
|
backgroundColor: isMobile ? 'background.paper' : 'inherit',
|
|
}}
|
|
>
|
|
<Typography
|
|
variant={isSmall ? 'subtitle2' : isMobile ? 'subtitle1' : 'h6'}
|
|
gutterBottom
|
|
sx={{ mb: isMobile ? 0.5 : 1, fontWeight: 600 }}
|
|
>
|
|
{getDisplayTitle()} ({sortedResumes.length})
|
|
</Typography>
|
|
|
|
<Box
|
|
sx={{
|
|
display: 'flex',
|
|
gap: 1,
|
|
flexDirection: isSmall ? 'column' : 'row',
|
|
alignItems: isSmall ? 'stretch' : 'center',
|
|
}}
|
|
>
|
|
<TextField
|
|
size="small"
|
|
placeholder="Search resumes..."
|
|
value={searchQuery}
|
|
onChange={(e): void => setSearchQuery(e.target.value)}
|
|
InputProps={{
|
|
startAdornment: (
|
|
<InputAdornment position="start">
|
|
<SearchIcon fontSize="small" />
|
|
</InputAdornment>
|
|
),
|
|
endAdornment: searchQuery && (
|
|
<InputAdornment position="end">
|
|
<IconButton size="small" onClick={handleSearchClear}>
|
|
<ClearIcon fontSize="small" />
|
|
</IconButton>
|
|
</InputAdornment>
|
|
),
|
|
}}
|
|
sx={{ flexGrow: 1, minWidth: isSmall ? '100%' : 200 }}
|
|
/>
|
|
|
|
<FormControl size="small" sx={{ minWidth: isSmall ? '100%' : 180 }}>
|
|
<InputLabel>Sort by</InputLabel>
|
|
<Select
|
|
value={`${sortField}-${sortOrder}`}
|
|
label="Sort by"
|
|
onChange={(e): void => {
|
|
const [field, order] = e.target.value.split('-') as [SortField, SortOrder];
|
|
setSortField(field);
|
|
setSortOrder(order);
|
|
}}
|
|
>
|
|
<MenuItem value="updatedAt-desc">Updated (Newest)</MenuItem>
|
|
<MenuItem value="updatedAt-asc">Updated (Oldest)</MenuItem>
|
|
<MenuItem value="createdAt-desc">Created (Newest)</MenuItem>
|
|
<MenuItem value="createdAt-asc">Created (Oldest)</MenuItem>
|
|
<MenuItem value="candidateId-asc">Candidate (A-Z)</MenuItem>
|
|
<MenuItem value="candidateId-desc">Candidate (Z-A)</MenuItem>
|
|
<MenuItem value="jobId-asc">Job (A-Z)</MenuItem>
|
|
<MenuItem value="jobId-desc">Job (Z-A)</MenuItem>
|
|
</Select>
|
|
</FormControl>
|
|
</Box>
|
|
</Box>
|
|
|
|
<TableContainer
|
|
sx={{
|
|
flex: 1,
|
|
overflow: 'auto',
|
|
'& .MuiTable-root': {
|
|
tableLayout: isMobile ? 'fixed' : 'auto',
|
|
},
|
|
}}
|
|
>
|
|
<Table stickyHeader size="small">
|
|
<TableHead>
|
|
<TableRow>
|
|
<TableCell
|
|
sx={{
|
|
cursor: 'pointer',
|
|
userSelect: 'none',
|
|
py: isMobile ? 0.25 : 0.5,
|
|
px: isMobile ? 0.5 : 1,
|
|
width: isMobile ? '35%' : 'auto',
|
|
backgroundColor: 'background.paper',
|
|
}}
|
|
onClick={(): void => handleSort('candidateId')}
|
|
>
|
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
|
|
<PersonIcon fontSize={isMobile ? 'small' : 'medium'} />
|
|
<Typography variant="caption" fontWeight="bold" noWrap>
|
|
{isSmall ? 'Candidate' : 'Candidate'}
|
|
</Typography>
|
|
{getSortIcon('candidateId')}
|
|
</Box>
|
|
</TableCell>
|
|
<TableCell
|
|
sx={{
|
|
cursor: 'pointer',
|
|
userSelect: 'none',
|
|
py: isMobile ? 0.25 : 0.5,
|
|
px: isMobile ? 0.5 : 1,
|
|
width: isMobile ? '35%' : 'auto',
|
|
backgroundColor: 'background.paper',
|
|
}}
|
|
onClick={(): void => handleSort('jobId')}
|
|
>
|
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
|
|
<WorkIcon fontSize={isMobile ? 'small' : 'medium'} />
|
|
<Typography variant="caption" fontWeight="bold" noWrap>
|
|
Job
|
|
</Typography>
|
|
{getSortIcon('jobId')}
|
|
</Box>
|
|
</TableCell>
|
|
{!isMobile && (
|
|
<TableCell
|
|
sx={{
|
|
cursor: 'pointer',
|
|
userSelect: 'none',
|
|
py: 0.5,
|
|
px: 1,
|
|
backgroundColor: 'background.paper',
|
|
}}
|
|
onClick={(): void => handleSort('updatedAt')}
|
|
>
|
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
|
|
<ScheduleIcon fontSize="medium" />
|
|
<Typography variant="caption" fontWeight="bold">
|
|
Updated
|
|
</Typography>
|
|
{getSortIcon('updatedAt')}
|
|
</Box>
|
|
</TableCell>
|
|
)}
|
|
<TableCell
|
|
sx={{
|
|
py: isMobile ? 0.25 : 0.5,
|
|
px: isMobile ? 0.5 : 1,
|
|
width: isMobile ? '30%' : 'auto',
|
|
backgroundColor: 'background.paper',
|
|
}}
|
|
>
|
|
<Typography variant="caption" fontWeight="bold" noWrap>
|
|
ID
|
|
</Typography>
|
|
</TableCell>
|
|
</TableRow>
|
|
</TableHead>
|
|
<TableBody>
|
|
{sortedResumes.map(resume => (
|
|
<TableRow
|
|
key={resume.id}
|
|
hover
|
|
selected={selectedResume?.id === resume.id}
|
|
onClick={(): void => handleResumeSelect(resume)}
|
|
sx={{
|
|
cursor: 'pointer',
|
|
height: isMobile ? 48 : 'auto',
|
|
'&.Mui-selected': {
|
|
backgroundColor: 'action.selected',
|
|
},
|
|
'&:hover': {
|
|
backgroundColor: 'action.hover',
|
|
},
|
|
}}
|
|
>
|
|
<TableCell
|
|
sx={{
|
|
py: isMobile ? 0.25 : 0.5,
|
|
px: isMobile ? 0.5 : 1,
|
|
overflow: 'hidden',
|
|
}}
|
|
>
|
|
<Typography
|
|
variant={isMobile ? 'caption' : 'body2'}
|
|
fontWeight="medium"
|
|
noWrap
|
|
sx={{ fontSize: isMobile ? '0.75rem' : '0.875rem' }}
|
|
>
|
|
{resume.candidate?.fullName || resume.candidateId}
|
|
</Typography>
|
|
{isMobile && (
|
|
<Typography
|
|
variant="caption"
|
|
color="text.secondary"
|
|
noWrap
|
|
sx={{ display: 'block', fontSize: '0.7rem' }}
|
|
>
|
|
{formatDate(resume.updatedAt)}
|
|
</Typography>
|
|
)}
|
|
</TableCell>
|
|
<TableCell
|
|
sx={{
|
|
py: isMobile ? 0.25 : 0.5,
|
|
px: isMobile ? 0.5 : 1,
|
|
overflow: 'hidden',
|
|
}}
|
|
>
|
|
<Typography
|
|
variant={isMobile ? 'caption' : 'body2'}
|
|
fontWeight="medium"
|
|
noWrap
|
|
sx={{ fontSize: isMobile ? '0.75rem' : '0.875rem' }}
|
|
>
|
|
{resume.job?.title || 'Unknown Job'}
|
|
</Typography>
|
|
{!isMobile && resume.job?.company && (
|
|
<Typography
|
|
variant="caption"
|
|
color="text.secondary"
|
|
noWrap
|
|
sx={{ display: 'block', fontSize: '0.7rem' }}
|
|
>
|
|
{resume.job.company}
|
|
</Typography>
|
|
)}
|
|
</TableCell>
|
|
{!isMobile && (
|
|
<TableCell sx={{ py: 0.5, px: 1 }}>
|
|
<Typography variant="body2" sx={{ fontSize: '0.8rem' }}>
|
|
{formatDate(resume.updatedAt)}
|
|
</Typography>
|
|
{resume.createdAt && (
|
|
<Typography
|
|
variant="caption"
|
|
color="text.secondary"
|
|
sx={{ display: 'block', fontSize: '0.7rem' }}
|
|
>
|
|
Created: {formatDate(resume.createdAt)}
|
|
</Typography>
|
|
)}
|
|
</TableCell>
|
|
)}
|
|
<TableCell
|
|
sx={{
|
|
py: isMobile ? 0.25 : 0.5,
|
|
px: isMobile ? 0.5 : 1,
|
|
overflow: 'hidden',
|
|
}}
|
|
>
|
|
<Typography
|
|
variant="caption"
|
|
color="text.secondary"
|
|
noWrap
|
|
sx={{ fontSize: isMobile ? '0.65rem' : '0.7rem' }}
|
|
>
|
|
{resume.id}
|
|
</Typography>
|
|
</TableCell>
|
|
</TableRow>
|
|
))}
|
|
</TableBody>
|
|
</Table>
|
|
</TableContainer>
|
|
</Paper>
|
|
);
|
|
|
|
const ResumeDetails = ({ inDialog = false }: { inDialog?: boolean }): JSX.Element => (
|
|
<Box
|
|
sx={{
|
|
flex: 1,
|
|
overflow: 'auto',
|
|
p: inDialog ? 1.5 : 0.75,
|
|
height: inDialog ? '100%' : 'auto',
|
|
}}
|
|
>
|
|
{selectedResume ? (
|
|
<ResumeInfo
|
|
resume={selectedResume}
|
|
variant="all"
|
|
sx={{
|
|
border: 'none',
|
|
boxShadow: 'none',
|
|
backgroundColor: 'transparent',
|
|
'& .MuiTypography-h6': {
|
|
fontSize: inDialog ? '1.25rem' : '1.1rem',
|
|
},
|
|
}}
|
|
/>
|
|
) : (
|
|
<Box
|
|
sx={{
|
|
display: 'flex',
|
|
alignItems: 'center',
|
|
justifyContent: 'center',
|
|
height: '100%',
|
|
color: 'text.secondary',
|
|
textAlign: 'center',
|
|
p: 2,
|
|
}}
|
|
>
|
|
<Typography variant="body2">Select a resume to view details</Typography>
|
|
</Box>
|
|
)}
|
|
</Box>
|
|
);
|
|
|
|
if (isMobile) {
|
|
return (
|
|
<Box
|
|
sx={{
|
|
height: '100%',
|
|
p: 0.5,
|
|
backgroundColor: 'background.default',
|
|
}}
|
|
>
|
|
<ResumeList />
|
|
|
|
<Dialog
|
|
fullScreen
|
|
open={mobileDialogOpen}
|
|
onClose={handleMobileDialogClose}
|
|
TransitionComponent={Transition}
|
|
TransitionProps={{ timeout: 300 }}
|
|
>
|
|
<AppBar sx={{ position: 'relative', elevation: 1 }}>
|
|
<Toolbar variant="dense" sx={{ minHeight: 48 }}>
|
|
<IconButton
|
|
edge="start"
|
|
color="inherit"
|
|
onClick={handleMobileDialogClose}
|
|
aria-label="back"
|
|
size="small"
|
|
>
|
|
<ArrowBackIcon />
|
|
</IconButton>
|
|
<Box sx={{ ml: 1, flex: 1, minWidth: 0 }}>
|
|
<Typography variant="h6" component="div" noWrap sx={{ fontSize: '1rem' }}>
|
|
Resume Details
|
|
</Typography>
|
|
<Typography
|
|
variant="caption"
|
|
component="div"
|
|
sx={{ color: 'rgba(255, 255, 255, 0.7)' }}
|
|
noWrap
|
|
>
|
|
{selectedResume?.candidate?.fullName || selectedResume?.candidateId}
|
|
</Typography>
|
|
</Box>
|
|
</Toolbar>
|
|
</AppBar>
|
|
<ResumeDetails inDialog />
|
|
</Dialog>
|
|
</Box>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<Box
|
|
sx={{
|
|
display: 'flex',
|
|
height: '100%',
|
|
gap: 0.75,
|
|
p: 0.75,
|
|
backgroundColor: 'background.default',
|
|
}}
|
|
>
|
|
<ResumeList />
|
|
|
|
<Paper
|
|
sx={{
|
|
width: '50%',
|
|
display: 'flex',
|
|
flexDirection: 'column',
|
|
elevation: 1,
|
|
}}
|
|
>
|
|
<Box
|
|
sx={{
|
|
p: 0.75,
|
|
borderBottom: 1,
|
|
borderColor: 'divider',
|
|
backgroundColor: 'background.paper',
|
|
}}
|
|
>
|
|
<Typography variant="h6" sx={{ fontSize: '1.1rem', fontWeight: 600 }}>
|
|
Resume Details
|
|
</Typography>
|
|
</Box>
|
|
<ResumeDetails />
|
|
</Paper>
|
|
</Box>
|
|
);
|
|
};
|
|
|
|
export { ResumeViewer };
|