Added Resume viewing
This commit is contained in:
parent
0bc9f74c7f
commit
8dcc1c0336
@ -99,6 +99,28 @@ const ResumeGenerator: React.FC<ResumeGeneratorProps> = (props: ResumeGeneratorP
|
|||||||
generateResume();
|
generateResume();
|
||||||
}, [job, candidate, apiClient, resume, skills, generated, setSystemPrompt, setPrompt, setResume]);
|
}, [job, candidate, apiClient, resume, skills, generated, setSystemPrompt, setPrompt, setResume]);
|
||||||
|
|
||||||
|
const handleSave = useCallback(async () => {
|
||||||
|
if (!resume) {
|
||||||
|
setSnack('No resume to save!');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (!candidate.id || !job.id) {
|
||||||
|
setSnack('Candidate or job ID is missing.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const controller = apiClient.saveResume(candidate.id, job.id, resume);
|
||||||
|
const result = await controller.promise;
|
||||||
|
if (result.resume.id) {
|
||||||
|
setSnack('Resume saved successfully!');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error saving resume:', error);
|
||||||
|
setSnack('Error saving resume.');
|
||||||
|
}
|
||||||
|
}, [apiClient, candidate.id, job.id, resume, setSnack]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box
|
<Box
|
||||||
className="ResumeGenerator"
|
className="ResumeGenerator"
|
||||||
@ -129,6 +151,10 @@ const ResumeGenerator: React.FC<ResumeGeneratorProps> = (props: ResumeGeneratorP
|
|||||||
{tabValue === 'prompt' && <pre>{prompt}</pre>}
|
{tabValue === 'prompt' && <pre>{prompt}</pre>}
|
||||||
{tabValue === 'resume' && <><CopyBubble onClick={() => { setSnack('Resume copied to clipboard!'); }} sx={{ position: "absolute", top: 0, right: 0 }} content={resume} /><StyledMarkdown content={resume} /></>}
|
{tabValue === 'resume' && <><CopyBubble onClick={() => { setSnack('Resume copied to clipboard!'); }} sx={{ position: "absolute", top: 0, right: 0 }} content={resume} /><StyledMarkdown content={resume} /></>}
|
||||||
</Scrollable></Paper>
|
</Scrollable></Paper>
|
||||||
|
|
||||||
|
{resume && !status && !error && <Button onClick={handleSave} variant="contained" color="primary" sx={{ mt: 2 }}>
|
||||||
|
Save Resume
|
||||||
|
</Button>}
|
||||||
</Box>
|
</Box>
|
||||||
)
|
)
|
||||||
|
|
||||||
|
381
frontend/src/components/ui/ResumeInfo.tsx
Normal file
381
frontend/src/components/ui/ResumeInfo.tsx
Normal file
@ -0,0 +1,381 @@
|
|||||||
|
import React, { useEffect, useRef, useState } from 'react';
|
||||||
|
import {
|
||||||
|
Box,
|
||||||
|
Link,
|
||||||
|
Typography,
|
||||||
|
Avatar,
|
||||||
|
Grid,
|
||||||
|
SxProps,
|
||||||
|
CardActions,
|
||||||
|
Chip,
|
||||||
|
Stack,
|
||||||
|
CardHeader,
|
||||||
|
Button,
|
||||||
|
LinearProgress,
|
||||||
|
IconButton,
|
||||||
|
Tooltip,
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
Divider,
|
||||||
|
useTheme,
|
||||||
|
useMediaQuery,
|
||||||
|
TextField,
|
||||||
|
Dialog,
|
||||||
|
DialogTitle,
|
||||||
|
DialogContent,
|
||||||
|
DialogActions
|
||||||
|
} from '@mui/material';
|
||||||
|
import {
|
||||||
|
Delete as DeleteIcon,
|
||||||
|
Restore as RestoreIcon,
|
||||||
|
Save as SaveIcon,
|
||||||
|
Edit as EditIcon,
|
||||||
|
Description as DescriptionIcon,
|
||||||
|
Work as WorkIcon,
|
||||||
|
Person as PersonIcon,
|
||||||
|
Schedule as ScheduleIcon,
|
||||||
|
Visibility as VisibilityIcon,
|
||||||
|
VisibilityOff as VisibilityOffIcon
|
||||||
|
} from '@mui/icons-material';
|
||||||
|
import { useAuth } from 'hooks/AuthContext';
|
||||||
|
import { useAppState } from 'hooks/GlobalContext';
|
||||||
|
import { StyledMarkdown } from 'components/StyledMarkdown';
|
||||||
|
import { Resume } from 'types/types';
|
||||||
|
|
||||||
|
interface ResumeInfoProps {
|
||||||
|
resume: Resume;
|
||||||
|
sx?: SxProps;
|
||||||
|
action?: string;
|
||||||
|
elevation?: number;
|
||||||
|
variant?: "minimal" | "small" | "normal" | "all" | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ResumeInfo: React.FC<ResumeInfoProps> = (props: ResumeInfoProps) => {
|
||||||
|
const { setSnack } = useAppState();
|
||||||
|
const { resume } = props;
|
||||||
|
const { user, apiClient } = useAuth();
|
||||||
|
const {
|
||||||
|
sx,
|
||||||
|
action = '',
|
||||||
|
elevation = 1,
|
||||||
|
variant = "normal"
|
||||||
|
} = props;
|
||||||
|
const theme = useTheme();
|
||||||
|
const isMobile = useMediaQuery(theme.breakpoints.down('md')) || variant === "minimal";
|
||||||
|
const isAdmin = user?.isAdmin;
|
||||||
|
const [activeResume, setActiveResume] = useState<Resume>({ ...resume });
|
||||||
|
const [isContentExpanded, setIsContentExpanded] = useState(false);
|
||||||
|
const [shouldShowMoreButton, setShouldShowMoreButton] = useState(false);
|
||||||
|
const [deleted, setDeleted] = useState<boolean>(false);
|
||||||
|
const [editDialogOpen, setEditDialogOpen] = useState<boolean>(false);
|
||||||
|
const [editContent, setEditContent] = useState<string>('');
|
||||||
|
const [saving, setSaving] = useState<boolean>(false);
|
||||||
|
const contentRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (resume && resume.id !== activeResume?.id) {
|
||||||
|
setActiveResume(resume);
|
||||||
|
}
|
||||||
|
}, [resume, activeResume]);
|
||||||
|
|
||||||
|
// Check if content needs truncation
|
||||||
|
useEffect(() => {
|
||||||
|
if (contentRef.current && resume.resume) {
|
||||||
|
const element = contentRef.current;
|
||||||
|
setShouldShowMoreButton(element.scrollHeight > element.clientHeight);
|
||||||
|
}
|
||||||
|
}, [resume.resume]);
|
||||||
|
|
||||||
|
const deleteResume = async (resumeId: string | undefined) => {
|
||||||
|
if (resumeId) {
|
||||||
|
try {
|
||||||
|
await apiClient.deleteResume(resumeId);
|
||||||
|
setDeleted(true);
|
||||||
|
setSnack('Resume deleted successfully.');
|
||||||
|
} catch (error) {
|
||||||
|
setSnack('Failed to delete resume.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleReset = async () => {
|
||||||
|
setActiveResume({ ...resume });
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSave = async () => {
|
||||||
|
setSaving(true);
|
||||||
|
try {
|
||||||
|
const result = await apiClient.updateResume(activeResume.id || '', editContent);
|
||||||
|
const updatedResume = { ...activeResume, resume: editContent, updatedAt: new Date() };
|
||||||
|
setActiveResume(updatedResume);
|
||||||
|
setEditDialogOpen(false);
|
||||||
|
setSnack('Resume updated successfully.');
|
||||||
|
} catch (error) {
|
||||||
|
setSnack('Failed to update resume.');
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleEditOpen = () => {
|
||||||
|
setEditContent(activeResume.resume);
|
||||||
|
setEditDialogOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!resume) {
|
||||||
|
return <Box>No resume provided.</Box>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const formatDate = (date: Date | undefined) => {
|
||||||
|
if (!date) return 'N/A';
|
||||||
|
return new Intl.DateTimeFormat('en-US', {
|
||||||
|
month: 'short',
|
||||||
|
day: 'numeric',
|
||||||
|
year: 'numeric',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit'
|
||||||
|
}).format(date);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
display: "flex",
|
||||||
|
borderColor: 'transparent',
|
||||||
|
borderWidth: 2,
|
||||||
|
borderStyle: 'solid',
|
||||||
|
transition: 'all 0.3s ease',
|
||||||
|
flexDirection: "column",
|
||||||
|
minWidth: 0,
|
||||||
|
opacity: deleted ? 0.5 : 1.0,
|
||||||
|
backgroundColor: deleted ? theme.palette.action.disabledBackground : theme.palette.background.paper,
|
||||||
|
pointerEvents: deleted ? "none" : "auto",
|
||||||
|
...sx,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Box sx={{ display: "flex", flexGrow: 1, p: 1, pb: 0, height: '100%', flexDirection: 'column', alignItems: 'stretch', position: "relative" }}>
|
||||||
|
|
||||||
|
{/* Header Information */}
|
||||||
|
<Box sx={{
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: isMobile ? "column" : "row",
|
||||||
|
gap: 2,
|
||||||
|
mb: 2
|
||||||
|
}}>
|
||||||
|
<Grid container spacing={2}>
|
||||||
|
<Grid size={{ xs: 12, md: 6 }}>
|
||||||
|
<Stack spacing={1}>
|
||||||
|
{activeResume.candidate && (
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||||
|
<PersonIcon color="primary" fontSize="small" />
|
||||||
|
<Typography variant="subtitle2" fontWeight="bold">
|
||||||
|
Candidate
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
<Typography variant="body2" color="text.secondary">
|
||||||
|
{activeResume.candidate?.fullName || activeResume.candidateId}
|
||||||
|
</Typography>
|
||||||
|
|
||||||
|
{activeResume.job && (
|
||||||
|
<>
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mt: 1 }}>
|
||||||
|
<WorkIcon color="primary" fontSize="small" />
|
||||||
|
<Typography variant="subtitle2" fontWeight="bold">
|
||||||
|
Job
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
<Typography variant="body2" color="text.secondary">
|
||||||
|
{activeResume.job.title} at {activeResume.job.company}
|
||||||
|
</Typography>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Stack>
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
<Grid size={{ xs: 12, md: 6 }}>
|
||||||
|
<Stack spacing={1}>
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||||
|
<ScheduleIcon color="primary" fontSize="small" />
|
||||||
|
<Typography variant="subtitle2" fontWeight="bold">
|
||||||
|
Timeline
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
<Typography variant="caption" color="text.secondary">
|
||||||
|
Created: {formatDate(activeResume.createdAt)}
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="caption" color="text.secondary">
|
||||||
|
Updated: {formatDate(activeResume.updatedAt)}
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="caption" color="text.secondary">
|
||||||
|
Resume ID: {activeResume.resumeId}
|
||||||
|
</Typography>
|
||||||
|
</Stack>
|
||||||
|
</Grid>
|
||||||
|
</Grid>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Divider sx={{ mb: 2 }} />
|
||||||
|
|
||||||
|
{/* Resume Content */}
|
||||||
|
{activeResume.resume && (
|
||||||
|
<Card elevation={0} sx={{ m: 0, p: 0, background: "transparent !important" }}>
|
||||||
|
<CardHeader
|
||||||
|
title="Resume Content"
|
||||||
|
avatar={<DescriptionIcon color="success" />}
|
||||||
|
sx={{ p: 0, pb: 1 }}
|
||||||
|
action={
|
||||||
|
isAdmin && (
|
||||||
|
<Tooltip title="Edit Resume Content">
|
||||||
|
<IconButton size="small" onClick={handleEditOpen}>
|
||||||
|
<EditIcon />
|
||||||
|
</IconButton>
|
||||||
|
</Tooltip>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<CardContent sx={{ p: 0 }}>
|
||||||
|
<Box sx={{ position: 'relative' }}>
|
||||||
|
<Typography
|
||||||
|
ref={contentRef}
|
||||||
|
variant="body2"
|
||||||
|
component="div"
|
||||||
|
sx={{
|
||||||
|
display: '-webkit-box',
|
||||||
|
WebkitLineClamp: isContentExpanded ? 'unset' : (variant === "small" ? 5 : variant === "minimal" ? 3 : 10),
|
||||||
|
WebkitBoxOrient: 'vertical',
|
||||||
|
overflow: 'hidden',
|
||||||
|
textOverflow: 'ellipsis',
|
||||||
|
lineHeight: 1.6,
|
||||||
|
fontSize: "0.875rem !important",
|
||||||
|
whiteSpace: 'pre-wrap',
|
||||||
|
fontFamily: 'monospace',
|
||||||
|
backgroundColor: theme.palette.action.hover,
|
||||||
|
p: 2,
|
||||||
|
borderRadius: 1,
|
||||||
|
border: `1px solid ${theme.palette.divider}`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{activeResume.resume}
|
||||||
|
</Typography>
|
||||||
|
|
||||||
|
{shouldShowMoreButton && variant !== "all" && (
|
||||||
|
<Box sx={{ display: 'flex', justifyContent: 'center', mt: 1 }}>
|
||||||
|
<Button
|
||||||
|
variant="text"
|
||||||
|
size="small"
|
||||||
|
onClick={() => setIsContentExpanded(!isContentExpanded)}
|
||||||
|
startIcon={isContentExpanded ? <VisibilityOffIcon /> : <VisibilityIcon />}
|
||||||
|
sx={{ fontSize: '0.75rem' }}
|
||||||
|
>
|
||||||
|
{isContentExpanded ? "Show Less" : "Show More"}
|
||||||
|
</Button>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{variant === 'all' && activeResume.resume && (
|
||||||
|
<Box sx={{ mt: 2 }}>
|
||||||
|
<StyledMarkdown content={activeResume.resume} />
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* Admin Controls */}
|
||||||
|
{isAdmin && (
|
||||||
|
<Box sx={{ display: "flex", flexDirection: "column", p: 1 }}>
|
||||||
|
<Box sx={{ display: "flex", flexDirection: "row", pl: 1, pr: 1, gap: 1, alignContent: "center", height: "32px" }}>
|
||||||
|
<Tooltip title="Edit Resume">
|
||||||
|
<IconButton
|
||||||
|
size="small"
|
||||||
|
onClick={(e) => { e.stopPropagation(); handleEditOpen(); }}
|
||||||
|
>
|
||||||
|
<EditIcon />
|
||||||
|
</IconButton>
|
||||||
|
</Tooltip>
|
||||||
|
|
||||||
|
<Tooltip title="Delete Resume">
|
||||||
|
<IconButton
|
||||||
|
size="small"
|
||||||
|
onClick={(e) => { e.stopPropagation(); deleteResume(activeResume.id); }}
|
||||||
|
>
|
||||||
|
<DeleteIcon />
|
||||||
|
</IconButton>
|
||||||
|
</Tooltip>
|
||||||
|
|
||||||
|
<Tooltip title="Reset Resume">
|
||||||
|
<IconButton
|
||||||
|
size="small"
|
||||||
|
onClick={(e) => { e.stopPropagation(); handleReset(); }}
|
||||||
|
>
|
||||||
|
<RestoreIcon />
|
||||||
|
</IconButton>
|
||||||
|
</Tooltip>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{saving && (
|
||||||
|
<Box sx={{ mt: 1 }}>
|
||||||
|
<LinearProgress />
|
||||||
|
<Typography variant="caption" sx={{ mt: 0.5 }}>
|
||||||
|
Saving resume...
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Edit Dialog */}
|
||||||
|
<Dialog
|
||||||
|
open={editDialogOpen}
|
||||||
|
onClose={() => setEditDialogOpen(false)}
|
||||||
|
maxWidth="lg"
|
||||||
|
fullWidth
|
||||||
|
fullScreen={isMobile}
|
||||||
|
>
|
||||||
|
<DialogTitle>
|
||||||
|
Edit Resume Content
|
||||||
|
<Typography variant="caption" display="block" color="text.secondary">
|
||||||
|
Resume for {activeResume.candidate?.fullName || activeResume.candidateId}
|
||||||
|
</Typography>
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogContent>
|
||||||
|
<TextField
|
||||||
|
multiline
|
||||||
|
fullWidth
|
||||||
|
rows={20}
|
||||||
|
value={editContent}
|
||||||
|
onChange={(e) => setEditContent(e.target.value)}
|
||||||
|
variant="outlined"
|
||||||
|
sx={{
|
||||||
|
mt: 1,
|
||||||
|
'& .MuiInputBase-input': {
|
||||||
|
fontFamily: 'monospace',
|
||||||
|
fontSize: '0.875rem'
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
placeholder="Enter resume content..."
|
||||||
|
/>
|
||||||
|
</DialogContent>
|
||||||
|
<DialogActions>
|
||||||
|
<Button onClick={() => setEditDialogOpen(false)}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={handleSave}
|
||||||
|
variant="contained"
|
||||||
|
disabled={saving}
|
||||||
|
startIcon={<SaveIcon />}
|
||||||
|
>
|
||||||
|
{saving ? 'Saving...' : 'Save'}
|
||||||
|
</Button>
|
||||||
|
</DialogActions>
|
||||||
|
</Dialog>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export { ResumeInfo };
|
601
frontend/src/components/ui/ResumeViewer.tsx
Normal file
601
frontend/src/components/ui/ResumeViewer.tsx
Normal file
@ -0,0 +1,601 @@
|
|||||||
|
import React, { useEffect, useState } from 'react';
|
||||||
|
import {
|
||||||
|
Box,
|
||||||
|
Paper,
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableContainer,
|
||||||
|
TableHead,
|
||||||
|
TableRow,
|
||||||
|
Typography,
|
||||||
|
FormControl,
|
||||||
|
Select,
|
||||||
|
MenuItem,
|
||||||
|
InputLabel,
|
||||||
|
Chip,
|
||||||
|
IconButton,
|
||||||
|
Dialog,
|
||||||
|
AppBar,
|
||||||
|
Toolbar,
|
||||||
|
useMediaQuery,
|
||||||
|
useTheme,
|
||||||
|
Slide,
|
||||||
|
TextField,
|
||||||
|
InputAdornment
|
||||||
|
} from '@mui/material';
|
||||||
|
import {
|
||||||
|
KeyboardArrowUp as ArrowUpIcon,
|
||||||
|
KeyboardArrowDown as ArrowDownIcon,
|
||||||
|
Description as DescriptionIcon,
|
||||||
|
Work as WorkIcon,
|
||||||
|
Person as PersonIcon,
|
||||||
|
Schedule as ScheduleIcon,
|
||||||
|
Close as CloseIcon,
|
||||||
|
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 { Navigate, 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 [loading, setLoading] = useState(false);
|
||||||
|
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 () => {
|
||||||
|
setLoading(true);
|
||||||
|
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');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
getResumes();
|
||||||
|
}, [apiClient, setSnack, candidateId, jobId]);
|
||||||
|
|
||||||
|
// 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.resumeId?.toLowerCase().includes(searchQuery.toLowerCase())
|
||||||
|
);
|
||||||
|
setFilteredResumes(filtered);
|
||||||
|
}
|
||||||
|
}, [searchQuery, resumes]);
|
||||||
|
|
||||||
|
const sortResumes = (resumesList: Resume[], field: SortField, order: SortOrder): Resume[] => {
|
||||||
|
return [...resumesList].sort((a, b) => {
|
||||||
|
let aValue: any;
|
||||||
|
let bValue: any;
|
||||||
|
|
||||||
|
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) => {
|
||||||
|
if (sortField === field) {
|
||||||
|
setSortOrder(sortOrder === 'asc' ? 'desc' : 'asc');
|
||||||
|
} else {
|
||||||
|
setSortField(field);
|
||||||
|
setSortOrder('desc');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleResumeSelect = (resume: Resume) => {
|
||||||
|
setSelectedResume(resume);
|
||||||
|
onSelect?.(resume);
|
||||||
|
if (isMobile) {
|
||||||
|
setMobileDialogOpen(true);
|
||||||
|
} else {
|
||||||
|
navigate(`/candidate/resumes/${resume.id}`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleMobileDialogClose = () => {
|
||||||
|
setMobileDialogOpen(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSearchClear = () => {
|
||||||
|
setSearchQuery('');
|
||||||
|
};
|
||||||
|
|
||||||
|
const sortedResumes = sortResumes(filteredResumes, sortField, sortOrder);
|
||||||
|
|
||||||
|
const formatDate = (date: Date | undefined) => {
|
||||||
|
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) => {
|
||||||
|
if (sortField !== field) return null;
|
||||||
|
return sortOrder === 'asc' ? <ArrowUpIcon fontSize="small" /> : <ArrowDownIcon fontSize="small" />;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getDisplayTitle = () => {
|
||||||
|
if (candidateId) return `Resumes for Candidate`;
|
||||||
|
if (jobId) return `Resumes for Job`;
|
||||||
|
return `All Resumes`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const ResumeList = () => (
|
||||||
|
<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) => 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) => {
|
||||||
|
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={() => 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={() => 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={() => 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={() => 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.resumeId}
|
||||||
|
</Typography>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</TableContainer>
|
||||||
|
</Paper>
|
||||||
|
);
|
||||||
|
|
||||||
|
const ResumeDetails = ({ inDialog = false }: { inDialog?: boolean }) => (
|
||||||
|
<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 };
|
@ -45,6 +45,7 @@ import { useAuth } from "hooks/AuthContext";
|
|||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
import { JobViewer } from "components/ui/JobViewer";
|
import { JobViewer } from "components/ui/JobViewer";
|
||||||
import { CandidatePicker } from "components/ui/CandidatePicker";
|
import { CandidatePicker } from "components/ui/CandidatePicker";
|
||||||
|
import { ResumeViewer } from "components/ui/ResumeViewer";
|
||||||
|
|
||||||
// Beta page components for placeholder routes
|
// Beta page components for placeholder routes
|
||||||
const BackstoryPage = () => (
|
const BackstoryPage = () => (
|
||||||
@ -156,6 +157,16 @@ export const navigationConfig: NavigationConfig = {
|
|||||||
),
|
),
|
||||||
userTypes: ["candidate", "guest", "employer"],
|
userTypes: ["candidate", "guest", "employer"],
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: "explore-resumes",
|
||||||
|
label: "Resumes",
|
||||||
|
path: "/candidate/resumes/:resumeId?",
|
||||||
|
icon: <SearchIcon />,
|
||||||
|
component: (
|
||||||
|
<ResumeViewer />
|
||||||
|
),
|
||||||
|
userTypes: ["candidate", "guest", "employer"],
|
||||||
|
},
|
||||||
],
|
],
|
||||||
showInNavigation: true,
|
showInNavigation: true,
|
||||||
},
|
},
|
||||||
|
@ -39,6 +39,8 @@ export interface AppState {
|
|||||||
selectedCandidate: Types.Candidate | null;
|
selectedCandidate: Types.Candidate | null;
|
||||||
selectedJob: Types.Job | null;
|
selectedJob: Types.Job | null;
|
||||||
selectedEmployer: Types.Employer | null;
|
selectedEmployer: Types.Employer | null;
|
||||||
|
selectedResume: Types.Resume | null;
|
||||||
|
setSelectedResume: (resume: Types.Resume | null) => void;
|
||||||
routeState: RouteState;
|
routeState: RouteState;
|
||||||
isInitializing: boolean;
|
isInitializing: boolean;
|
||||||
}
|
}
|
||||||
@ -140,6 +142,7 @@ export function useAppStateLogic(): AppStateContextType {
|
|||||||
const [selectedCandidate, setSelectedCandidateState] = useState<Types.Candidate | null>(null);
|
const [selectedCandidate, setSelectedCandidateState] = useState<Types.Candidate | null>(null);
|
||||||
const [selectedJob, setSelectedJobState] = useState<Types.Job | null>(null);
|
const [selectedJob, setSelectedJobState] = useState<Types.Job | null>(null);
|
||||||
const [selectedEmployer, setSelectedEmployerState] = useState<Types.Employer | null>(null);
|
const [selectedEmployer, setSelectedEmployerState] = useState<Types.Employer | null>(null);
|
||||||
|
const [selectedResume, setSelectedResume] = useState<Types.Resume | null>(null);
|
||||||
const [isInitializing, setIsInitializing] = useState<boolean>(true);
|
const [isInitializing, setIsInitializing] = useState<boolean>(true);
|
||||||
|
|
||||||
// Route state
|
// Route state
|
||||||
@ -373,6 +376,8 @@ export function useAppStateLogic(): AppStateContextType {
|
|||||||
selectedJob,
|
selectedJob,
|
||||||
selectedEmployer,
|
selectedEmployer,
|
||||||
routeState,
|
routeState,
|
||||||
|
selectedResume,
|
||||||
|
setSelectedResume,
|
||||||
isInitializing,
|
isInitializing,
|
||||||
setSelectedCandidate,
|
setSelectedCandidate,
|
||||||
setSelectedJob,
|
setSelectedJob,
|
||||||
@ -437,6 +442,11 @@ export function useSelectedEmployer() {
|
|||||||
return { selectedEmployer, setSelectedEmployer };
|
return { selectedEmployer, setSelectedEmployer };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const useSelectedResume = () => {
|
||||||
|
const { selectedResume, setSelectedResume } = useAppState();
|
||||||
|
return { selectedResume, setSelectedResume };
|
||||||
|
};
|
||||||
|
|
||||||
export function useRouteState() {
|
export function useRouteState() {
|
||||||
const {
|
const {
|
||||||
routeState,
|
routeState,
|
||||||
|
@ -674,6 +674,80 @@ class ApiClient {
|
|||||||
return this.handleApiResponseWithConversion<Types.Job>(response, 'Job');
|
return this.handleApiResponseWithConversion<Types.Job>(response, 'Job');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
saveResume(candidate_id: string, job_id: string, resume: string, streamingOptions?: StreamingOptions<Types.ResumeMessage>): StreamingResponse<Types.ResumeMessage> {
|
||||||
|
const body = JSON.stringify(resume);
|
||||||
|
return this.streamify<Types.ResumeMessage>(`/resumes/${candidate_id}/${job_id}`, body, streamingOptions, "Resume");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Additional API methods for Resume management
|
||||||
|
async getResumes(): Promise<{ success: boolean; resumes: Types.Resume[]; count: number }> {
|
||||||
|
const response = await fetch(`${this.baseUrl}/resumes`, {
|
||||||
|
headers: this.defaultHeaders
|
||||||
|
});
|
||||||
|
|
||||||
|
return handleApiResponse<{ success: boolean; resumes: Types.Resume[]; count: number }>(response);
|
||||||
|
}
|
||||||
|
|
||||||
|
async getResume(resumeId: string): Promise<{ success: boolean; resume: Types.Resume }> {
|
||||||
|
const response = await fetch(`${this.baseUrl}/resumes/${resumeId}`, {
|
||||||
|
headers: this.defaultHeaders
|
||||||
|
});
|
||||||
|
|
||||||
|
return handleApiResponse<{ success: boolean; resume: Types.Resume }>(response);
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteResume(resumeId: string): Promise<{ success: boolean; message: string }> {
|
||||||
|
const response = await fetch(`${this.baseUrl}/resumes/${resumeId}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
headers: this.defaultHeaders
|
||||||
|
});
|
||||||
|
|
||||||
|
return handleApiResponse<{ success: boolean; message: string }>(response);
|
||||||
|
}
|
||||||
|
|
||||||
|
async getResumesByCandidate(candidateId: string): Promise<{ success: boolean; candidateId: string; resumes: Types.Resume[]; count: number }> {
|
||||||
|
const response = await fetch(`${this.baseUrl}/resumes/candidate/${candidateId}`, {
|
||||||
|
headers: this.defaultHeaders
|
||||||
|
});
|
||||||
|
|
||||||
|
return handleApiResponse<{ success: boolean; candidateId: string; resumes: Types.Resume[]; count: number }>(response);
|
||||||
|
}
|
||||||
|
|
||||||
|
async getResumesByJob(jobId: string): Promise<{ success: boolean; jobId: string; resumes: Types.Resume[]; count: number }> {
|
||||||
|
const response = await fetch(`${this.baseUrl}/resumes/job/${jobId}`, {
|
||||||
|
headers: this.defaultHeaders
|
||||||
|
});
|
||||||
|
|
||||||
|
return handleApiResponse<{ success: boolean; jobId: string; resumes: Types.Resume[]; count: number }>(response);
|
||||||
|
}
|
||||||
|
|
||||||
|
async searchResumes(query: string): Promise<{ success: boolean; query: string; resumes: Types.Resume[]; count: number }> {
|
||||||
|
const params = new URLSearchParams({ q: query });
|
||||||
|
const response = await fetch(`${this.baseUrl}/resumes/search?${params}`, {
|
||||||
|
headers: this.defaultHeaders
|
||||||
|
});
|
||||||
|
|
||||||
|
return handleApiResponse<{ success: boolean; query: string; resumes: Types.Resume[]; count: number }>(response);
|
||||||
|
}
|
||||||
|
|
||||||
|
async getResumeStatistics(): Promise<{ success: boolean; statistics: any }> {
|
||||||
|
const response = await fetch(`${this.baseUrl}/resumes/stats`, {
|
||||||
|
headers: this.defaultHeaders
|
||||||
|
});
|
||||||
|
|
||||||
|
return handleApiResponse<{ success: boolean; statistics: any }>(response);
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateResume(resumeId: string, content: string): Promise<{ success: boolean; message: string; resume: Types.Resume }> {
|
||||||
|
const response = await fetch(`${this.baseUrl}/resumes/${resumeId}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: this.defaultHeaders,
|
||||||
|
body: JSON.stringify(content)
|
||||||
|
});
|
||||||
|
|
||||||
|
return handleApiResponse<{ success: boolean; message: string; resume: Types.Resume }>(response);
|
||||||
|
}
|
||||||
|
|
||||||
async getJob(id: string): Promise<Types.Job> {
|
async getJob(id: string): Promise<Types.Job> {
|
||||||
const response = await fetch(`${this.baseUrl}/jobs/${id}`, {
|
const response = await fetch(`${this.baseUrl}/jobs/${id}`, {
|
||||||
headers: this.defaultHeaders
|
headers: this.defaultHeaders
|
||||||
|
@ -1,6 +1,6 @@
|
|||||||
// Generated TypeScript types from Pydantic models
|
// Generated TypeScript types from Pydantic models
|
||||||
// Source: src/backend/models.py
|
// Source: src/backend/models.py
|
||||||
// Generated on: 2025-06-11T22:14:30.373041
|
// Generated on: 2025-06-12T15:58:49.974420
|
||||||
// DO NOT EDIT MANUALLY - This file is auto-generated
|
// DO NOT EDIT MANUALLY - This file is auto-generated
|
||||||
|
|
||||||
// ============================
|
// ============================
|
||||||
@ -965,6 +965,31 @@ export interface ResendVerificationRequest {
|
|||||||
email: string;
|
email: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface Resume {
|
||||||
|
id?: string;
|
||||||
|
resumeId: string;
|
||||||
|
jobId: string;
|
||||||
|
candidateId: string;
|
||||||
|
resume: string;
|
||||||
|
createdAt?: Date;
|
||||||
|
updatedAt?: Date;
|
||||||
|
job?: Job;
|
||||||
|
candidate?: Candidate;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ResumeMessage {
|
||||||
|
id?: string;
|
||||||
|
sessionId: string;
|
||||||
|
senderId?: string;
|
||||||
|
status: "streaming" | "status" | "done" | "error";
|
||||||
|
type: "binary" | "text" | "json";
|
||||||
|
timestamp?: Date;
|
||||||
|
role: "user" | "assistant" | "system" | "information" | "warning" | "error";
|
||||||
|
content: string;
|
||||||
|
tunables?: Tunables;
|
||||||
|
resume: Resume;
|
||||||
|
}
|
||||||
|
|
||||||
export interface RetrievalParameters {
|
export interface RetrievalParameters {
|
||||||
searchType: "similarity" | "mmr" | "hybrid" | "keyword";
|
searchType: "similarity" | "mmr" | "hybrid" | "keyword";
|
||||||
topK: number;
|
topK: number;
|
||||||
@ -1770,6 +1795,42 @@ export function convertRefreshTokenFromApi(data: any): RefreshToken {
|
|||||||
expiresAt: new Date(data.expiresAt),
|
expiresAt: new Date(data.expiresAt),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
/**
|
||||||
|
* Convert Resume from API response
|
||||||
|
* Date fields: createdAt, updatedAt
|
||||||
|
* Nested models: job (Job), candidate (Candidate)
|
||||||
|
*/
|
||||||
|
export function convertResumeFromApi(data: any): Resume {
|
||||||
|
if (!data) return data;
|
||||||
|
|
||||||
|
return {
|
||||||
|
...data,
|
||||||
|
// Convert createdAt from ISO string to Date
|
||||||
|
createdAt: data.createdAt ? new Date(data.createdAt) : undefined,
|
||||||
|
// Convert updatedAt from ISO string to Date
|
||||||
|
updatedAt: data.updatedAt ? new Date(data.updatedAt) : undefined,
|
||||||
|
// Convert nested Job model
|
||||||
|
job: data.job ? convertJobFromApi(data.job) : undefined,
|
||||||
|
// Convert nested Candidate model
|
||||||
|
candidate: data.candidate ? convertCandidateFromApi(data.candidate) : undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* Convert ResumeMessage from API response
|
||||||
|
* Date fields: timestamp
|
||||||
|
* Nested models: resume (Resume)
|
||||||
|
*/
|
||||||
|
export function convertResumeMessageFromApi(data: any): ResumeMessage {
|
||||||
|
if (!data) return data;
|
||||||
|
|
||||||
|
return {
|
||||||
|
...data,
|
||||||
|
// Convert timestamp from ISO string to Date
|
||||||
|
timestamp: data.timestamp ? new Date(data.timestamp) : undefined,
|
||||||
|
// Convert nested Resume model
|
||||||
|
resume: convertResumeFromApi(data.resume),
|
||||||
|
};
|
||||||
|
}
|
||||||
/**
|
/**
|
||||||
* Convert SkillAssessment from API response
|
* Convert SkillAssessment from API response
|
||||||
* Date fields: createdAt, updatedAt
|
* Date fields: createdAt, updatedAt
|
||||||
@ -1912,6 +1973,10 @@ export function convertFromApi<T>(data: any, modelType: string): T {
|
|||||||
return convertRateLimitStatusFromApi(data) as T;
|
return convertRateLimitStatusFromApi(data) as T;
|
||||||
case 'RefreshToken':
|
case 'RefreshToken':
|
||||||
return convertRefreshTokenFromApi(data) as T;
|
return convertRefreshTokenFromApi(data) as T;
|
||||||
|
case 'Resume':
|
||||||
|
return convertResumeFromApi(data) as T;
|
||||||
|
case 'ResumeMessage':
|
||||||
|
return convertResumeMessageFromApi(data) as T;
|
||||||
case 'SkillAssessment':
|
case 'SkillAssessment':
|
||||||
return convertSkillAssessmentFromApi(data) as T;
|
return convertSkillAssessmentFromApi(data) as T;
|
||||||
case 'UserActivity':
|
case 'UserActivity':
|
||||||
|
@ -183,7 +183,9 @@ class RedisDatabase:
|
|||||||
'ai_parameters': 'ai_parameters:',
|
'ai_parameters': 'ai_parameters:',
|
||||||
'users': 'user:',
|
'users': 'user:',
|
||||||
'candidate_documents': 'candidate_documents:',
|
'candidate_documents': 'candidate_documents:',
|
||||||
'job_requirements': 'job_requirements:', # Add this line
|
'job_requirements': 'job_requirements:',
|
||||||
|
'resumes': 'resume:',
|
||||||
|
'user_resumes': 'user_resumes:',
|
||||||
}
|
}
|
||||||
|
|
||||||
def _serialize(self, data: Any) -> str:
|
def _serialize(self, data: Any) -> str:
|
||||||
@ -203,6 +205,286 @@ class RedisDatabase:
|
|||||||
logger.error(f"Failed to deserialize data: {data}")
|
logger.error(f"Failed to deserialize data: {data}")
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
# Resume operations
|
||||||
|
async def set_resume(self, user_id: str, resume_data: Dict) -> bool:
|
||||||
|
"""Save a resume for a user"""
|
||||||
|
try:
|
||||||
|
# Generate resume_id if not present
|
||||||
|
if 'resume_id' not in resume_data:
|
||||||
|
resume_data['resume_id'] = f"resume_{datetime.now(UTC).strftime('%Y%m%d_%H%M%S')}_{user_id[:8]}"
|
||||||
|
|
||||||
|
resume_id = resume_data['resume_id']
|
||||||
|
|
||||||
|
# Store the resume data
|
||||||
|
key = f"{self.KEY_PREFIXES['resumes']}{user_id}:{resume_id}"
|
||||||
|
await self.redis.set(key, self._serialize(resume_data))
|
||||||
|
|
||||||
|
# Add resume_id to user's resume list
|
||||||
|
user_resumes_key = f"{self.KEY_PREFIXES['user_resumes']}{user_id}"
|
||||||
|
await self.redis.rpush(user_resumes_key, resume_id)
|
||||||
|
|
||||||
|
logger.info(f"📄 Saved resume {resume_id} for user {user_id}")
|
||||||
|
return True
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"❌ Error saving resume for user {user_id}: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
async def get_resume(self, user_id: str, resume_id: str) -> Optional[Dict]:
|
||||||
|
"""Get a specific resume for a user"""
|
||||||
|
try:
|
||||||
|
key = f"{self.KEY_PREFIXES['resumes']}{user_id}:{resume_id}"
|
||||||
|
data = await self.redis.get(key)
|
||||||
|
if data:
|
||||||
|
resume_data = self._deserialize(data)
|
||||||
|
logger.debug(f"📄 Retrieved resume {resume_id} for user {user_id}")
|
||||||
|
return resume_data
|
||||||
|
logger.debug(f"📄 Resume {resume_id} not found for user {user_id}")
|
||||||
|
return None
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"❌ Error retrieving resume {resume_id} for user {user_id}: {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
async def get_all_resumes_for_user(self, user_id: str) -> List[Dict]:
|
||||||
|
"""Get all resumes for a specific user"""
|
||||||
|
try:
|
||||||
|
# Get all resume IDs for this user
|
||||||
|
user_resumes_key = f"{self.KEY_PREFIXES['user_resumes']}{user_id}"
|
||||||
|
resume_ids = await self.redis.lrange(user_resumes_key, 0, -1)
|
||||||
|
|
||||||
|
if not resume_ids:
|
||||||
|
logger.debug(f"📄 No resumes found for user {user_id}")
|
||||||
|
return []
|
||||||
|
|
||||||
|
# Get all resume data
|
||||||
|
resumes = []
|
||||||
|
pipe = self.redis.pipeline()
|
||||||
|
for resume_id in resume_ids:
|
||||||
|
pipe.get(f"{self.KEY_PREFIXES['resumes']}{user_id}:{resume_id}")
|
||||||
|
values = await pipe.execute()
|
||||||
|
|
||||||
|
for resume_id, value in zip(resume_ids, values):
|
||||||
|
if value:
|
||||||
|
resume_data = self._deserialize(value)
|
||||||
|
if resume_data:
|
||||||
|
resumes.append(resume_data)
|
||||||
|
else:
|
||||||
|
# Clean up orphaned resume ID
|
||||||
|
await self.redis.lrem(user_resumes_key, 0, resume_id)
|
||||||
|
logger.warning(f"Removed orphaned resume ID {resume_id} for user {user_id}")
|
||||||
|
|
||||||
|
# Sort by created_at timestamp (most recent first)
|
||||||
|
resumes.sort(key=lambda x: x.get("created_at", ""), reverse=True)
|
||||||
|
|
||||||
|
logger.debug(f"📄 Retrieved {len(resumes)} resumes for user {user_id}")
|
||||||
|
return resumes
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"❌ Error retrieving resumes for user {user_id}: {e}")
|
||||||
|
return []
|
||||||
|
|
||||||
|
async def delete_resume(self, user_id: str, resume_id: str) -> bool:
|
||||||
|
"""Delete a specific resume for a user"""
|
||||||
|
try:
|
||||||
|
# Delete the resume data
|
||||||
|
key = f"{self.KEY_PREFIXES['resumes']}{user_id}:{resume_id}"
|
||||||
|
result = await self.redis.delete(key)
|
||||||
|
|
||||||
|
# Remove from user's resume list
|
||||||
|
user_resumes_key = f"{self.KEY_PREFIXES['user_resumes']}{user_id}"
|
||||||
|
await self.redis.lrem(user_resumes_key, 0, resume_id)
|
||||||
|
|
||||||
|
if result > 0:
|
||||||
|
logger.info(f"🗑️ Deleted resume {resume_id} for user {user_id}")
|
||||||
|
return True
|
||||||
|
else:
|
||||||
|
logger.warning(f"⚠️ Resume {resume_id} not found for user {user_id}")
|
||||||
|
return False
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"❌ Error deleting resume {resume_id} for user {user_id}: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
async def delete_all_resumes_for_user(self, user_id: str) -> int:
|
||||||
|
"""Delete all resumes for a specific user and return count of deleted resumes"""
|
||||||
|
try:
|
||||||
|
# Get all resume IDs for this user
|
||||||
|
user_resumes_key = f"{self.KEY_PREFIXES['user_resumes']}{user_id}"
|
||||||
|
resume_ids = await self.redis.lrange(user_resumes_key, 0, -1)
|
||||||
|
|
||||||
|
if not resume_ids:
|
||||||
|
logger.info(f"📄 No resumes found for user {user_id}")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
deleted_count = 0
|
||||||
|
|
||||||
|
# Use pipeline for efficient batch operations
|
||||||
|
pipe = self.redis.pipeline()
|
||||||
|
|
||||||
|
# Delete each resume
|
||||||
|
for resume_id in resume_ids:
|
||||||
|
pipe.delete(f"{self.KEY_PREFIXES['resumes']}{user_id}:{resume_id}")
|
||||||
|
deleted_count += 1
|
||||||
|
|
||||||
|
# Delete the user's resume list
|
||||||
|
pipe.delete(user_resumes_key)
|
||||||
|
|
||||||
|
# Execute all operations
|
||||||
|
await pipe.execute()
|
||||||
|
|
||||||
|
logger.info(f"🗑️ Successfully deleted {deleted_count} resumes for user {user_id}")
|
||||||
|
return deleted_count
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"❌ Error deleting all resumes for user {user_id}: {e}")
|
||||||
|
raise
|
||||||
|
|
||||||
|
async def get_all_resumes(self) -> Dict[str, List[Dict]]:
|
||||||
|
"""Get all resumes grouped by user (admin function)"""
|
||||||
|
try:
|
||||||
|
pattern = f"{self.KEY_PREFIXES['resumes']}*"
|
||||||
|
keys = await self.redis.keys(pattern)
|
||||||
|
|
||||||
|
if not keys:
|
||||||
|
return {}
|
||||||
|
|
||||||
|
# Group by user_id
|
||||||
|
user_resumes = {}
|
||||||
|
pipe = self.redis.pipeline()
|
||||||
|
for key in keys:
|
||||||
|
pipe.get(key)
|
||||||
|
values = await pipe.execute()
|
||||||
|
|
||||||
|
for key, value in zip(keys, values):
|
||||||
|
if value:
|
||||||
|
# Extract user_id from key format: resume:{user_id}:{resume_id}
|
||||||
|
key_parts = key.replace(self.KEY_PREFIXES['resumes'], '').split(':', 1)
|
||||||
|
if len(key_parts) >= 1:
|
||||||
|
user_id = key_parts[0]
|
||||||
|
resume_data = self._deserialize(value)
|
||||||
|
if resume_data:
|
||||||
|
if user_id not in user_resumes:
|
||||||
|
user_resumes[user_id] = []
|
||||||
|
user_resumes[user_id].append(resume_data)
|
||||||
|
|
||||||
|
# Sort each user's resumes by created_at
|
||||||
|
for user_id in user_resumes:
|
||||||
|
user_resumes[user_id].sort(key=lambda x: x.get("created_at", ""), reverse=True)
|
||||||
|
|
||||||
|
return user_resumes
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"❌ Error retrieving all resumes: {e}")
|
||||||
|
return {}
|
||||||
|
|
||||||
|
async def search_resumes_for_user(self, user_id: str, query: str) -> List[Dict]:
|
||||||
|
"""Search resumes for a user by content, job title, or candidate name"""
|
||||||
|
try:
|
||||||
|
all_resumes = await self.get_all_resumes_for_user(user_id)
|
||||||
|
query_lower = query.lower()
|
||||||
|
|
||||||
|
matching_resumes = []
|
||||||
|
for resume in all_resumes:
|
||||||
|
# Search in resume content, job_id, candidate_id, etc.
|
||||||
|
searchable_text = " ".join([
|
||||||
|
resume.get("resume", ""),
|
||||||
|
resume.get("job_id", ""),
|
||||||
|
resume.get("candidate_id", ""),
|
||||||
|
str(resume.get("created_at", ""))
|
||||||
|
]).lower()
|
||||||
|
|
||||||
|
if query_lower in searchable_text:
|
||||||
|
matching_resumes.append(resume)
|
||||||
|
|
||||||
|
logger.debug(f"📄 Found {len(matching_resumes)} matching resumes for user {user_id}")
|
||||||
|
return matching_resumes
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"❌ Error searching resumes for user {user_id}: {e}")
|
||||||
|
return []
|
||||||
|
|
||||||
|
async def get_resumes_by_candidate(self, user_id: str, candidate_id: str) -> List[Dict]:
|
||||||
|
"""Get all resumes for a specific candidate created by a user"""
|
||||||
|
try:
|
||||||
|
all_resumes = await self.get_all_resumes_for_user(user_id)
|
||||||
|
candidate_resumes = [
|
||||||
|
resume for resume in all_resumes
|
||||||
|
if resume.get("candidate_id") == candidate_id
|
||||||
|
]
|
||||||
|
|
||||||
|
logger.debug(f"📄 Found {len(candidate_resumes)} resumes for candidate {candidate_id} by user {user_id}")
|
||||||
|
return candidate_resumes
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"❌ Error retrieving resumes for candidate {candidate_id} by user {user_id}: {e}")
|
||||||
|
return []
|
||||||
|
|
||||||
|
async def get_resumes_by_job(self, user_id: str, job_id: str) -> List[Dict]:
|
||||||
|
"""Get all resumes for a specific job created by a user"""
|
||||||
|
try:
|
||||||
|
all_resumes = await self.get_all_resumes_for_user(user_id)
|
||||||
|
job_resumes = [
|
||||||
|
resume for resume in all_resumes
|
||||||
|
if resume.get("job_id") == job_id
|
||||||
|
]
|
||||||
|
|
||||||
|
logger.debug(f"📄 Found {len(job_resumes)} resumes for job {job_id} by user {user_id}")
|
||||||
|
return job_resumes
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"❌ Error retrieving resumes for job {job_id} by user {user_id}: {e}")
|
||||||
|
return []
|
||||||
|
|
||||||
|
async def get_resume_statistics(self, user_id: str) -> Dict[str, Any]:
|
||||||
|
"""Get resume statistics for a user"""
|
||||||
|
try:
|
||||||
|
all_resumes = await self.get_all_resumes_for_user(user_id)
|
||||||
|
|
||||||
|
stats = {
|
||||||
|
"total_resumes": len(all_resumes),
|
||||||
|
"resumes_by_candidate": {},
|
||||||
|
"resumes_by_job": {},
|
||||||
|
"creation_timeline": {},
|
||||||
|
"recent_resumes": []
|
||||||
|
}
|
||||||
|
|
||||||
|
for resume in all_resumes:
|
||||||
|
# Count by candidate
|
||||||
|
candidate_id = resume.get("candidate_id", "unknown")
|
||||||
|
stats["resumes_by_candidate"][candidate_id] = stats["resumes_by_candidate"].get(candidate_id, 0) + 1
|
||||||
|
|
||||||
|
# Count by job
|
||||||
|
job_id = resume.get("job_id", "unknown")
|
||||||
|
stats["resumes_by_job"][job_id] = stats["resumes_by_job"].get(job_id, 0) + 1
|
||||||
|
|
||||||
|
# Timeline by date
|
||||||
|
created_at = resume.get("created_at")
|
||||||
|
if created_at:
|
||||||
|
try:
|
||||||
|
date_key = created_at[:10] # Extract date part
|
||||||
|
stats["creation_timeline"][date_key] = stats["creation_timeline"].get(date_key, 0) + 1
|
||||||
|
except (IndexError, TypeError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Get recent resumes (last 5)
|
||||||
|
stats["recent_resumes"] = all_resumes[:5]
|
||||||
|
|
||||||
|
return stats
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"❌ Error getting resume statistics for user {user_id}: {e}")
|
||||||
|
return {"total_resumes": 0, "resumes_by_candidate": {}, "resumes_by_job": {}, "creation_timeline": {}, "recent_resumes": []}
|
||||||
|
|
||||||
|
async def update_resume(self, user_id: str, resume_id: str, updates: Dict) -> Optional[Dict]:
|
||||||
|
"""Update specific fields of a resume"""
|
||||||
|
try:
|
||||||
|
resume_data = await self.get_resume(user_id, resume_id)
|
||||||
|
if resume_data:
|
||||||
|
resume_data.update(updates)
|
||||||
|
resume_data["updated_at"] = datetime.now(UTC).isoformat()
|
||||||
|
|
||||||
|
key = f"{self.KEY_PREFIXES['resumes']}{user_id}:{resume_id}"
|
||||||
|
await self.redis.set(key, self._serialize(resume_data))
|
||||||
|
|
||||||
|
logger.info(f"📄 Updated resume {resume_id} for user {user_id}")
|
||||||
|
return resume_data
|
||||||
|
return None
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"❌ Error updating resume {resume_id} for user {user_id}: {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
# Document operations
|
# Document operations
|
||||||
async def get_document(self, document_id: str) -> Optional[Dict]:
|
async def get_document(self, document_id: str) -> Optional[Dict]:
|
||||||
"""Get document metadata by ID"""
|
"""Get document metadata by ID"""
|
||||||
@ -658,7 +940,8 @@ class RedisDatabase:
|
|||||||
"auth_records": 0,
|
"auth_records": 0,
|
||||||
"security_logs": 0,
|
"security_logs": 0,
|
||||||
"ai_parameters": 0,
|
"ai_parameters": 0,
|
||||||
"candidate_record": 0
|
"candidate_record": 0,
|
||||||
|
"resumes": 0
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.info(f"🗑️ Starting cascading delete for candidate {candidate_id}")
|
logger.info(f"🗑️ Starting cascading delete for candidate {candidate_id}")
|
||||||
@ -893,7 +1176,30 @@ class RedisDatabase:
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"❌ Error deleting candidate record: {e}")
|
logger.error(f"❌ Error deleting candidate record: {e}")
|
||||||
|
|
||||||
# 14. Log the deletion as a security event (if we have admin/system user context)
|
# 14. Delete resumes associated with this candidate across all users
|
||||||
|
try:
|
||||||
|
all_resumes = await self.get_all_resumes()
|
||||||
|
candidate_resumes_deleted = 0
|
||||||
|
|
||||||
|
for user_id, user_resumes in all_resumes.items():
|
||||||
|
resumes_to_delete = []
|
||||||
|
for resume in user_resumes:
|
||||||
|
if resume.get("candidate_id") == candidate_id:
|
||||||
|
resumes_to_delete.append(resume.get("resume_id"))
|
||||||
|
|
||||||
|
# Delete each resume for this candidate
|
||||||
|
for resume_id in resumes_to_delete:
|
||||||
|
if resume_id:
|
||||||
|
await self.delete_resume(user_id, resume_id)
|
||||||
|
candidate_resumes_deleted += 1
|
||||||
|
|
||||||
|
deletion_stats["resumes"] = candidate_resumes_deleted
|
||||||
|
if candidate_resumes_deleted > 0:
|
||||||
|
logger.info(f"🗑️ Deleted {candidate_resumes_deleted} resumes for candidate {candidate_id}")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"❌ Error deleting resumes for candidate {candidate_id}: {e}")
|
||||||
|
|
||||||
|
# 15. Log the deletion as a security event (if we have admin/system user context)
|
||||||
try:
|
try:
|
||||||
total_items_deleted = sum(deletion_stats.values())
|
total_items_deleted = sum(deletion_stats.values())
|
||||||
logger.info(f"✅ Completed cascading delete for candidate {candidate_id}. "
|
logger.info(f"✅ Completed cascading delete for candidate {candidate_id}. "
|
||||||
@ -924,7 +1230,8 @@ class RedisDatabase:
|
|||||||
"auth_records": 0,
|
"auth_records": 0,
|
||||||
"security_logs": 0,
|
"security_logs": 0,
|
||||||
"ai_parameters": 0,
|
"ai_parameters": 0,
|
||||||
"candidate_record": 0
|
"candidate_record": 0,
|
||||||
|
"resumes": 0,
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.info(f"🗑️ Starting batch deletion for {len(candidate_ids)} candidates")
|
logger.info(f"🗑️ Starting batch deletion for {len(candidate_ids)} candidates")
|
||||||
|
62
src/backend/get_requirements_list.py
Normal file
62
src/backend/get_requirements_list.py
Normal file
@ -0,0 +1,62 @@
|
|||||||
|
from typing import List, Dict
|
||||||
|
from models import (Job)
|
||||||
|
|
||||||
|
def get_requirements_list(job: Job) -> List[Dict[str, str]]:
|
||||||
|
requirements: List[Dict[str, str]] = []
|
||||||
|
|
||||||
|
if job.requirements:
|
||||||
|
if job.requirements.technical_skills:
|
||||||
|
if job.requirements.technical_skills.required:
|
||||||
|
requirements.extend([
|
||||||
|
{"requirement": req, "domain": "Technical Skills (required)"}
|
||||||
|
for req in job.requirements.technical_skills.required
|
||||||
|
])
|
||||||
|
if job.requirements.technical_skills.preferred:
|
||||||
|
requirements.extend([
|
||||||
|
{"requirement": req, "domain": "Technical Skills (preferred)"}
|
||||||
|
for req in job.requirements.technical_skills.preferred
|
||||||
|
])
|
||||||
|
|
||||||
|
if job.requirements.experience_requirements:
|
||||||
|
if job.requirements.experience_requirements.required:
|
||||||
|
requirements.extend([
|
||||||
|
{"requirement": req, "domain": "Experience (required)"}
|
||||||
|
for req in job.requirements.experience_requirements.required
|
||||||
|
])
|
||||||
|
if job.requirements.experience_requirements.preferred:
|
||||||
|
requirements.extend([
|
||||||
|
{"requirement": req, "domain": "Experience (preferred)"}
|
||||||
|
for req in job.requirements.experience_requirements.preferred
|
||||||
|
])
|
||||||
|
|
||||||
|
if job.requirements.soft_skills:
|
||||||
|
requirements.extend([
|
||||||
|
{"requirement": req, "domain": "Soft Skills"}
|
||||||
|
for req in job.requirements.soft_skills
|
||||||
|
])
|
||||||
|
|
||||||
|
if job.requirements.experience:
|
||||||
|
requirements.extend([
|
||||||
|
{"requirement": req, "domain": "Experience"}
|
||||||
|
for req in job.requirements.experience
|
||||||
|
])
|
||||||
|
|
||||||
|
if job.requirements.education:
|
||||||
|
requirements.extend([
|
||||||
|
{"requirement": req, "domain": "Education"}
|
||||||
|
for req in job.requirements.education
|
||||||
|
])
|
||||||
|
|
||||||
|
if job.requirements.certifications:
|
||||||
|
requirements.extend([
|
||||||
|
{"requirement": req, "domain": "Certifications"}
|
||||||
|
for req in job.requirements.certifications
|
||||||
|
])
|
||||||
|
|
||||||
|
if job.requirements.preferred_attributes:
|
||||||
|
requirements.extend([
|
||||||
|
{"requirement": req, "domain": "Preferred Attributes"}
|
||||||
|
for req in job.requirements.preferred_attributes
|
||||||
|
])
|
||||||
|
|
||||||
|
return requirements
|
@ -94,7 +94,7 @@ from models import (
|
|||||||
Document, DocumentType, DocumentListResponse, DocumentUpdateRequest, DocumentContentResponse,
|
Document, DocumentType, DocumentListResponse, DocumentUpdateRequest, DocumentContentResponse,
|
||||||
|
|
||||||
# Supporting models
|
# Supporting models
|
||||||
Location, MFARequest, MFAData, MFARequestResponse, MFAVerifyRequest, RagContentMetadata, RagContentResponse, ResendVerificationRequest, Skill, SkillAssessment, SystemInfo, WorkExperience, Education,
|
Location, MFARequest, MFAData, MFARequestResponse, MFAVerifyRequest, RagContentMetadata, RagContentResponse, ResendVerificationRequest, Resume, ResumeMessage, Skill, SkillAssessment, SystemInfo, WorkExperience, Education,
|
||||||
|
|
||||||
# Email
|
# Email
|
||||||
EmailVerificationRequest
|
EmailVerificationRequest
|
||||||
@ -3280,7 +3280,270 @@ async def confirm_password_reset(
|
|||||||
status_code=500,
|
status_code=500,
|
||||||
content=create_error_response("RESET_ERROR", "An error occurred resetting the password")
|
content=create_error_response("RESET_ERROR", "An error occurred resetting the password")
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# ============================
|
||||||
|
# Resume Endpoints
|
||||||
|
# ============================
|
||||||
|
|
||||||
|
@api_router.post("/resumes/{candidate_id}/{job_id}")
|
||||||
|
async def create_candidate_resume(
|
||||||
|
candidate_id: str = Path(..., description="ID of the candidate"),
|
||||||
|
job_id: str = Path(..., description="ID of the job"),
|
||||||
|
resume_content: str = Body(...),
|
||||||
|
current_user = Depends(get_current_user),
|
||||||
|
database: RedisDatabase = Depends(get_database)
|
||||||
|
):
|
||||||
|
"""Create a new resume for a candidate/job combination"""
|
||||||
|
async def message_stream_generator():
|
||||||
|
logger.info(f"🔍 Looking up candidate and job details for {candidate_id}/{job_id}")
|
||||||
|
|
||||||
|
candidate_data = await database.get_candidate(candidate_id)
|
||||||
|
if not candidate_data:
|
||||||
|
logger.error(f"❌ Candidate with ID '{candidate_id}' not found")
|
||||||
|
error_message = ChatMessageError(
|
||||||
|
sessionId=MOCK_UUID, # No session ID for document uploads
|
||||||
|
content=f"Candidate with ID '{candidate_id}' not found"
|
||||||
|
)
|
||||||
|
yield error_message
|
||||||
|
return
|
||||||
|
candidate = Candidate.model_validate(candidate_data)
|
||||||
|
|
||||||
|
job_data = await database.get_job(job_id)
|
||||||
|
if not job_data:
|
||||||
|
logger.error(f"❌ Job with ID '{job_id}' not found")
|
||||||
|
error_message = ChatMessageError(
|
||||||
|
sessionId=MOCK_UUID, # No session ID for document uploads
|
||||||
|
content=f"Job with ID '{job_id}' not found"
|
||||||
|
)
|
||||||
|
yield error_message
|
||||||
|
return
|
||||||
|
job = Job.model_validate(job_data)
|
||||||
|
|
||||||
|
logger.info(f"📄 Saving resume for candidate {candidate.first_name} {candidate.last_name} for job '{job.title}'")
|
||||||
|
|
||||||
|
# Job and Candidate are valid. Save the resume
|
||||||
|
resume = Resume(
|
||||||
|
job_id=job_id,
|
||||||
|
candidate_id=candidate_id,
|
||||||
|
resume=resume_content,
|
||||||
|
)
|
||||||
|
resume_message: ResumeMessage = ResumeMessage(
|
||||||
|
sessionId=MOCK_UUID, # No session ID for document uploads
|
||||||
|
resume=resume
|
||||||
|
)
|
||||||
|
|
||||||
|
# Save to database
|
||||||
|
success = await database.set_resume(current_user.id, resume.model_dump())
|
||||||
|
if not success:
|
||||||
|
error_message = ChatMessageError(
|
||||||
|
sessionId=MOCK_UUID,
|
||||||
|
content="Failed to save resume to database"
|
||||||
|
)
|
||||||
|
yield error_message
|
||||||
|
return
|
||||||
|
|
||||||
|
logger.info(f"✅ Successfully saved resume {resume_message.resume.id} for user {current_user.id}")
|
||||||
|
yield resume_message
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
async def to_json(method):
|
||||||
|
try:
|
||||||
|
async for message in method:
|
||||||
|
json_data = message.model_dump(mode='json', by_alias=True)
|
||||||
|
json_str = json.dumps(json_data)
|
||||||
|
yield f"data: {json_str}\n\n".encode("utf-8")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(backstory_traceback.format_exc())
|
||||||
|
logger.error(f"Error in to_json conversion: {e}")
|
||||||
|
return
|
||||||
|
|
||||||
|
return StreamingResponse(
|
||||||
|
to_json(message_stream_generator()),
|
||||||
|
media_type="text/event-stream",
|
||||||
|
headers={
|
||||||
|
"Cache-Control": "no-cache, no-store, must-revalidate",
|
||||||
|
"Connection": "keep-alive",
|
||||||
|
"X-Accel-Buffering": "no", # Nginx
|
||||||
|
"X-Content-Type-Options": "nosniff",
|
||||||
|
"Access-Control-Allow-Origin": "*", # Adjust for your CORS needs
|
||||||
|
"Transfer-Encoding": "chunked",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(backstory_traceback.format_exc())
|
||||||
|
logger.error(f"❌ Resume creation error: {e}")
|
||||||
|
return StreamingResponse(
|
||||||
|
iter([json.dumps(ChatMessageError(
|
||||||
|
sessionId=MOCK_UUID, # No session ID for document uploads
|
||||||
|
content="Failed to create resume"
|
||||||
|
).model_dump(mode='json', by_alias=True))]),
|
||||||
|
media_type="text/event-stream"
|
||||||
|
)
|
||||||
|
|
||||||
|
@api_router.get("/resumes")
|
||||||
|
async def get_user_resumes(
|
||||||
|
current_user = Depends(get_current_user),
|
||||||
|
database: RedisDatabase = Depends(get_database)
|
||||||
|
):
|
||||||
|
"""Get all resumes for the current user"""
|
||||||
|
try:
|
||||||
|
resumes_data = await database.get_all_resumes_for_user(current_user.id)
|
||||||
|
resumes : List[Resume] = [Resume.model_validate(data) for data in resumes_data]
|
||||||
|
return create_success_response({
|
||||||
|
"resumes": resumes,
|
||||||
|
"count": len(resumes)
|
||||||
|
})
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"❌ Error retrieving resumes for user {current_user.id}: {e}")
|
||||||
|
raise HTTPException(status_code=500, detail="Failed to retrieve resumes")
|
||||||
|
|
||||||
|
@api_router.get("/resumes/{resume_id}")
|
||||||
|
async def get_resume(
|
||||||
|
resume_id: str = Path(..., description="ID of the resume"),
|
||||||
|
current_user = Depends(get_current_user),
|
||||||
|
database: RedisDatabase = Depends(get_database)
|
||||||
|
):
|
||||||
|
"""Get a specific resume by ID"""
|
||||||
|
try:
|
||||||
|
resume = await database.get_resume(current_user.id, resume_id)
|
||||||
|
if not resume:
|
||||||
|
raise HTTPException(status_code=404, detail="Resume not found")
|
||||||
|
|
||||||
|
return {
|
||||||
|
"success": True,
|
||||||
|
"resume": resume
|
||||||
|
}
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"❌ Error retrieving resume {resume_id} for user {current_user.id}: {e}")
|
||||||
|
raise HTTPException(status_code=500, detail="Failed to retrieve resume")
|
||||||
|
|
||||||
|
@api_router.delete("/resumes/{resume_id}")
|
||||||
|
async def delete_resume(
|
||||||
|
resume_id: str = Path(..., description="ID of the resume"),
|
||||||
|
current_user = Depends(get_current_user),
|
||||||
|
database: RedisDatabase = Depends(get_database)
|
||||||
|
):
|
||||||
|
"""Delete a specific resume"""
|
||||||
|
try:
|
||||||
|
success = await database.delete_resume(current_user.id, resume_id)
|
||||||
|
if not success:
|
||||||
|
raise HTTPException(status_code=404, detail="Resume not found")
|
||||||
|
|
||||||
|
return {
|
||||||
|
"success": True,
|
||||||
|
"message": f"Resume {resume_id} deleted successfully"
|
||||||
|
}
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"❌ Error deleting resume {resume_id} for user {current_user.id}: {e}")
|
||||||
|
raise HTTPException(status_code=500, detail="Failed to delete resume")
|
||||||
|
|
||||||
|
@api_router.get("/resumes/candidate/{candidate_id}")
|
||||||
|
async def get_resumes_by_candidate(
|
||||||
|
candidate_id: str = Path(..., description="ID of the candidate"),
|
||||||
|
current_user = Depends(get_current_user),
|
||||||
|
database: RedisDatabase = Depends(get_database)
|
||||||
|
):
|
||||||
|
"""Get all resumes for a specific candidate"""
|
||||||
|
try:
|
||||||
|
resumes = await database.get_resumes_by_candidate(current_user.id, candidate_id)
|
||||||
|
return {
|
||||||
|
"success": True,
|
||||||
|
"candidate_id": candidate_id,
|
||||||
|
"resumes": resumes,
|
||||||
|
"count": len(resumes)
|
||||||
|
}
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"❌ Error retrieving resumes for candidate {candidate_id}: {e}")
|
||||||
|
raise HTTPException(status_code=500, detail="Failed to retrieve candidate resumes")
|
||||||
|
|
||||||
|
@api_router.get("/resumes/job/{job_id}")
|
||||||
|
async def get_resumes_by_job(
|
||||||
|
job_id: str = Path(..., description="ID of the job"),
|
||||||
|
current_user = Depends(get_current_user),
|
||||||
|
database: RedisDatabase = Depends(get_database)
|
||||||
|
):
|
||||||
|
"""Get all resumes for a specific job"""
|
||||||
|
try:
|
||||||
|
resumes = await database.get_resumes_by_job(current_user.id, job_id)
|
||||||
|
return {
|
||||||
|
"success": True,
|
||||||
|
"job_id": job_id,
|
||||||
|
"resumes": resumes,
|
||||||
|
"count": len(resumes)
|
||||||
|
}
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"❌ Error retrieving resumes for job {job_id}: {e}")
|
||||||
|
raise HTTPException(status_code=500, detail="Failed to retrieve job resumes")
|
||||||
|
|
||||||
|
@api_router.get("/resumes/search")
|
||||||
|
async def search_resumes(
|
||||||
|
q: str = Query(..., description="Search query"),
|
||||||
|
current_user = Depends(get_current_user),
|
||||||
|
database: RedisDatabase = Depends(get_database)
|
||||||
|
):
|
||||||
|
"""Search resumes by content"""
|
||||||
|
try:
|
||||||
|
resumes = await database.search_resumes_for_user(current_user.id, q)
|
||||||
|
return {
|
||||||
|
"success": True,
|
||||||
|
"query": q,
|
||||||
|
"resumes": resumes,
|
||||||
|
"count": len(resumes)
|
||||||
|
}
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"❌ Error searching resumes for user {current_user.id}: {e}")
|
||||||
|
raise HTTPException(status_code=500, detail="Failed to search resumes")
|
||||||
|
|
||||||
|
@api_router.get("/resumes/stats")
|
||||||
|
async def get_resume_statistics(
|
||||||
|
current_user = Depends(get_current_user),
|
||||||
|
database: RedisDatabase = Depends(get_database)
|
||||||
|
):
|
||||||
|
"""Get resume statistics for the current user"""
|
||||||
|
try:
|
||||||
|
stats = await database.get_resume_statistics(current_user.id)
|
||||||
|
return {
|
||||||
|
"success": True,
|
||||||
|
"statistics": stats
|
||||||
|
}
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"❌ Error retrieving resume statistics for user {current_user.id}: {e}")
|
||||||
|
raise HTTPException(status_code=500, detail="Failed to retrieve resume statistics")
|
||||||
|
|
||||||
|
@api_router.put("/resumes/{resume_id}")
|
||||||
|
async def update_resume(
|
||||||
|
resume_id: str = Path(..., description="ID of the resume"),
|
||||||
|
resume: str = Body(..., description="Updated resume content"),
|
||||||
|
current_user = Depends(get_current_user),
|
||||||
|
database: RedisDatabase = Depends(get_database)
|
||||||
|
):
|
||||||
|
"""Update the content of a specific resume"""
|
||||||
|
try:
|
||||||
|
updates = {
|
||||||
|
"resume": resume,
|
||||||
|
"updated_at": datetime.now(UTC).isoformat()
|
||||||
|
}
|
||||||
|
|
||||||
|
updated_resume = await database.update_resume(current_user.id, resume_id, updates)
|
||||||
|
if not updated_resume:
|
||||||
|
raise HTTPException(status_code=404, detail="Resume not found")
|
||||||
|
|
||||||
|
return {
|
||||||
|
"success": True,
|
||||||
|
"message": f"Resume {resume_id} updated successfully",
|
||||||
|
"resume": updated_resume
|
||||||
|
}
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"❌ Error updating resume {resume_id} for user {current_user.id}: {e}")
|
||||||
|
raise HTTPException(status_code=500, detail="Failed to update resume")
|
||||||
|
|
||||||
# ============================
|
# ============================
|
||||||
# Job Endpoints
|
# Job Endpoints
|
||||||
# ============================
|
# ============================
|
||||||
|
@ -1019,6 +1019,29 @@ class ChatMessageResume(ChatMessageUser):
|
|||||||
resume: str = Field(..., alias="resume")
|
resume: str = Field(..., alias="resume")
|
||||||
system_prompt: Optional[str] = Field(None, alias="systemPrompt")
|
system_prompt: Optional[str] = Field(None, alias="systemPrompt")
|
||||||
prompt: Optional[str] = Field(None, alias="prompt")
|
prompt: Optional[str] = Field(None, alias="prompt")
|
||||||
|
model_config = {
|
||||||
|
"populate_by_name": True, # Allow both field names and aliases
|
||||||
|
}
|
||||||
|
|
||||||
|
class Resume(BaseModel):
|
||||||
|
id: str = Field(default_factory=lambda: str(uuid.uuid4()))
|
||||||
|
job_id: str = Field(..., alias="jobId")
|
||||||
|
candidate_id: str = Field(..., alias="candidateId")
|
||||||
|
resume: str = Field(..., alias="resume")
|
||||||
|
created_at: datetime = Field(default_factory=lambda: datetime.now(UTC), alias="createdAt")
|
||||||
|
updated_at: datetime = Field(default_factory=lambda: datetime.now(UTC), alias="updatedAt")
|
||||||
|
job: Optional[Job] = None
|
||||||
|
candidate: Optional[Candidate] = None
|
||||||
|
model_config = {
|
||||||
|
"populate_by_name": True, # Allow both field names and aliases
|
||||||
|
}
|
||||||
|
|
||||||
|
class ResumeMessage(ChatMessageUser):
|
||||||
|
role: ChatSenderType = ChatSenderType.ASSISTANT
|
||||||
|
resume: Resume = Field(..., alias="resume")
|
||||||
|
model_config = {
|
||||||
|
"populate_by_name": True, # Allow both field names and aliases
|
||||||
|
}
|
||||||
|
|
||||||
class GPUInfo(BaseModel):
|
class GPUInfo(BaseModel):
|
||||||
name: str
|
name: str
|
||||||
|
Loading…
x
Reference in New Issue
Block a user