backstory/frontend/src/components/ui/ResumeViewer.tsx

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 };