378 lines
13 KiB
TypeScript
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 }; |