backstory/frontend/src/components/JobMatchAnalysis.tsx
2025-05-28 09:32:36 -07:00

378 lines
13 KiB
TypeScript

import React, { useState, useEffect } from 'react';
import {
Box,
Typography,
Paper,
Accordion,
AccordionSummary,
AccordionDetails,
CircularProgress,
Grid,
Chip,
Divider,
Card,
CardContent,
useTheme,
LinearProgress
} from '@mui/material';
import ExpandMoreIcon from '@mui/icons-material/ExpandMore';
import CheckCircleIcon from '@mui/icons-material/CheckCircle';
import ErrorIcon from '@mui/icons-material/Error';
import PendingIcon from '@mui/icons-material/Pending';
import WarningIcon from '@mui/icons-material/Warning';
// Define TypeScript interfaces for our data structures
interface Citation {
text: string;
source: string;
relevance: number; // 0-100 scale
}
interface SkillMatch {
requirement: string;
status: 'pending' | 'complete' | 'error';
matchScore: number; // 0-100 scale
assessment: string;
citations: Citation[];
}
interface JobAnalysisProps {
jobTitle: string;
candidateName: string;
// This function would connect to your backend and return updates
fetchRequirements: () => Promise<string[]>;
// This function would fetch match data for a specific requirement
fetchMatchForRequirement: (requirement: string) => Promise<SkillMatch>;
}
const JobMatchAnalysis: React.FC<JobAnalysisProps> = ({
jobTitle,
candidateName,
fetchRequirements,
fetchMatchForRequirement
}) => {
const theme = useTheme();
const [requirements, setRequirements] = useState<string[]>([]);
const [skillMatches, setSkillMatches] = useState<SkillMatch[]>([]);
const [loadingRequirements, setLoadingRequirements] = useState<boolean>(true);
const [expanded, setExpanded] = useState<string | false>(false);
const [overallScore, setOverallScore] = useState<number>(0);
// Handle accordion expansion
const handleAccordionChange = (panel: string) => (event: React.SyntheticEvent, isExpanded: boolean) => {
setExpanded(isExpanded ? panel : false);
};
// Fetch initial requirements
useEffect(() => {
const getRequirements = async () => {
try {
const fetchedRequirements = await fetchRequirements();
setRequirements(fetchedRequirements);
// Initialize skill matches with pending status
const initialSkillMatches = fetchedRequirements.map(req => ({
requirement: req,
status: 'pending' as const,
matchScore: 0,
assessment: '',
citations: []
}));
setSkillMatches(initialSkillMatches);
setLoadingRequirements(false);
} catch (error) {
console.error("Error fetching requirements:", error);
setLoadingRequirements(false);
}
};
getRequirements();
}, [fetchRequirements]);
// Fetch match data for each requirement
useEffect(() => {
const fetchMatchData = async () => {
if (requirements.length === 0) return;
// Process requirements one by one
for (let i = 0; i < requirements.length; i++) {
try {
const match = await fetchMatchForRequirement(requirements[i]);
setSkillMatches(prev => {
const updated = [...prev];
updated[i] = match;
return updated;
});
// Update overall score
setSkillMatches(current => {
const completedMatches = current.filter(match => match.status === 'complete');
if (completedMatches.length > 0) {
const newOverallScore = completedMatches.reduce((sum, match) => sum + match.matchScore, 0) / completedMatches.length;
setOverallScore(newOverallScore);
}
return current;
});
} catch (error) {
console.error(`Error fetching match for requirement ${requirements[i]}:`, error);
setSkillMatches(prev => {
const updated = [...prev];
updated[i] = {
...updated[i],
status: 'error',
assessment: 'Failed to analyze this requirement.'
};
return updated;
});
}
}
};
if (!loadingRequirements) {
fetchMatchData();
}
}, [requirements, loadingRequirements, fetchMatchForRequirement]);
// Get color based on match score
const getMatchColor = (score: number): string => {
if (score >= 80) return theme.palette.success.main;
if (score >= 60) return theme.palette.info.main;
if (score >= 40) return theme.palette.warning.main;
return theme.palette.error.main;
};
// Get icon based on status
const getStatusIcon = (status: string, score: number) => {
if (status === 'pending') return <PendingIcon />;
if (status === 'error') return <ErrorIcon color="error" />;
if (score >= 70) return <CheckCircleIcon color="success" />;
if (score >= 40) return <WarningIcon color="warning" />;
return <ErrorIcon color="error" />;
};
return (
<Box>
<Paper elevation={3} sx={{ p: 3, mb: 4 }}>
<Grid container spacing={2}>
<Grid size={{ xs: 12 }} sx={{ textAlign: 'center', mb: 2 }}>
<Typography variant="h4" component="h1" gutterBottom>
Job Match Analysis
</Typography>
<Divider sx={{ mb: 2 }} />
</Grid>
<Grid size={{ xs: 12, md: 6 }}>
<Typography variant="h6" component="h2">
Job: {jobTitle}
</Typography>
</Grid>
<Grid size={{ xs: 12, md: 6 }}>
<Typography variant="h6" component="h2">
Candidate: {candidateName}
</Typography>
</Grid>
<Grid size={{ xs: 12 }} sx={{ mt: 2 }}>
<Box sx={{ display: 'flex', alignItems: 'center', mb: 2 }}>
<Typography variant="h5" component="h2" sx={{ mr: 2 }}>
Overall Match:
</Typography>
<Box sx={{
position: 'relative',
display: 'inline-flex',
mr: 2
}}>
<CircularProgress
variant="determinate"
value={overallScore}
size={60}
thickness={5}
sx={{
color: getMatchColor(overallScore),
}}
/>
<Box
sx={{
top: 0,
left: 0,
bottom: 0,
right: 0,
position: 'absolute',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}
>
<Typography variant="caption" component="div" sx={{ fontWeight: 'bold' }}>
{`${Math.round(overallScore)}%`}
</Typography>
</Box>
</Box>
<Chip
label={
overallScore >= 80 ? "Excellent Match" :
overallScore >= 60 ? "Good Match" :
overallScore >= 40 ? "Partial Match" : "Low Match"
}
sx={{
bgcolor: getMatchColor(overallScore),
color: 'white',
fontWeight: 'bold'
}}
/>
</Box>
</Grid>
</Grid>
</Paper>
{loadingRequirements ? (
<Box sx={{ display: 'flex', justifyContent: 'center', p: 4 }}>
<CircularProgress />
<Typography variant="h6" sx={{ ml: 2 }}>
Analyzing job requirements...
</Typography>
</Box>
) : (
<Box>
<Typography variant="h5" component="h2" gutterBottom>
Requirements Analysis
</Typography>
{skillMatches.map((match, index) => (
<Accordion
key={index}
expanded={expanded === `panel${index}`}
onChange={handleAccordionChange(`panel${index}`)}
sx={{
mb: 2,
border: '1px solid',
borderColor: match.status === 'complete'
? getMatchColor(match.matchScore)
: theme.palette.divider
}}
>
<AccordionSummary
expandIcon={<ExpandMoreIcon />}
aria-controls={`panel${index}bh-content`}
id={`panel${index}bh-header`}
sx={{
bgcolor: match.status === 'complete'
? `${getMatchColor(match.matchScore)}22` // Add transparency
: 'inherit'
}}
>
<Box sx={{
display: 'flex',
alignItems: 'center',
width: '100%',
justifyContent: 'space-between'
}}>
<Box sx={{ display: 'flex', alignItems: 'center' }}>
{getStatusIcon(match.status, match.matchScore)}
<Typography sx={{ ml: 1, fontWeight: 'medium' }}>
{match.requirement}
</Typography>
</Box>
{match.status === 'complete' ? (
<Chip
label={`${match.matchScore}% Match`}
size="small"
sx={{
bgcolor: getMatchColor(match.matchScore),
color: 'white',
minWidth: 90
}}
/>
) : match.status === 'pending' ? (
<Chip
label="Analyzing..."
size="small"
sx={{ bgcolor: theme.palette.grey[400], color: 'white', minWidth: 90 }}
/>
) : (
<Chip
label="Error"
size="small"
sx={{ bgcolor: theme.palette.error.main, color: 'white', minWidth: 90 }}
/>
)}
</Box>
</AccordionSummary>
<AccordionDetails>
{match.status === 'pending' ? (
<Box sx={{ width: '100%', p: 2 }}>
<LinearProgress />
<Typography sx={{ mt: 2 }}>
Analyzing candidate's match for this requirement...
</Typography>
</Box>
) : match.status === 'error' ? (
<Typography color="error">
{match.assessment || "An error occurred while analyzing this requirement."}
</Typography>
) : (
<Box>
<Typography variant="h6" gutterBottom>
Assessment:
</Typography>
<Typography paragraph sx={{ mb: 3 }}>
{match.assessment}
</Typography>
<Typography variant="h6" gutterBottom>
Supporting Evidence:
</Typography>
{match.citations.length > 0 ? (
match.citations.map((citation, citIndex) => (
<Card
key={citIndex}
variant="outlined"
sx={{
mb: 2,
borderLeft: '4px solid',
borderColor: theme.palette.primary.main,
}}
>
<CardContent>
<Typography variant="body1" component="div" sx={{ mb: 1, fontStyle: 'italic' }}>
"{citation.text}"
</Typography>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<Typography variant="caption" color="text.secondary">
Source: {citation.source}
</Typography>
<Chip
size="small"
label={`Relevance: ${citation.relevance}%`}
sx={{
bgcolor: theme.palette.grey[200],
}}
/>
</Box>
</CardContent>
</Card>
))
) : (
<Typography color="text.secondary">
No specific evidence found in candidate's profile.
</Typography>
)}
</Box>
)}
</AccordionDetails>
</Accordion>
))}
</Box>
)}
</Box>
);
};
export { JobMatchAnalysis };