import React, { useState, useEffect, useCallback, JSX, useMemo } from 'react';
import {
Box,
Typography,
Accordion,
AccordionSummary,
AccordionDetails,
CircularProgress,
Chip,
Card,
CardContent,
useTheme,
LinearProgress,
useMediaQuery,
Button,
Paper,
SxProps,
Tooltip,
IconButton,
} 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 ModelTrainingIcon from '@mui/icons-material/ModelTraining';
import PendingIcon from '@mui/icons-material/Pending';
import WarningIcon from '@mui/icons-material/Warning';
import { Candidate, SkillAssessment, SkillStatus } from 'types/types';
import { useAuth } from 'hooks/AuthContext';
import { BackstoryPageProps } from './BackstoryTab';
import { Job } from 'types/types';
import * as Types from 'types/types';
import { JobInfo } from './ui/JobInfo';
import { Scrollable } from 'components/Scrollable';
interface JobAnalysisScore {
score: number;
skills: SkillAssessment[];
}
interface JobAnalysisProps extends BackstoryPageProps {
job: Job;
candidate: Candidate;
variant?: 'small' | 'normal';
onAnalysisComplete: (analysis: JobAnalysisScore) => void;
}
interface SkillMatch extends SkillAssessment {
domain: string;
status: SkillStatus;
matchScore: number;
}
const JobMatchScore: React.FC<{ score: number; variant?: 'small' | 'normal'; sx?: SxProps }> = ({
variant = 'normal',
score,
sx = {},
}) => {
const theme = useTheme();
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;
};
const suffix = variant === 'small' ? '' : ' Match';
return (
= 80
? `Excellent${suffix}`
: score >= 60
? `Good${suffix}`
: score >= 40
? `Partial${suffix}`
: `Low${suffix}`
}
sx={{
bgcolor: getMatchColor(score),
color: 'white',
fontWeight: 'bold',
fontSize: variant === 'small' ? '0.7rem' : '1rem',
}}
/>
{variant !== 'small' && (
{`${Math.round(score)}%`}
)}
);
};
const calculateScore = (skillMatch: SkillAssessment): number => {
let score = 0;
switch (skillMatch.evidenceStrength.toUpperCase()) {
case 'STRONG':
score = 100;
break;
case 'MODERATE':
score = 75;
break;
case 'WEAK':
score = 50;
break;
case 'NONE':
score = 0;
break;
}
if (
skillMatch.evidenceStrength === 'none' &&
skillMatch.evidenceDetails &&
skillMatch.evidenceDetails.length > 3
) {
score = Math.min(skillMatch.evidenceDetails.length * 8, 40);
}
return score;
};
const JobMatchAnalysis: React.FC = (props: JobAnalysisProps) => {
const { job, candidate, onAnalysisComplete, variant = 'normal' } = props;
const { apiClient } = useAuth();
const theme = useTheme();
const [requirements, setRequirements] = useState<{ requirement: string; domain: string }[]>([]);
const [skillMatches, setSkillMatches] = useState([]);
const [loadingRequirements, setLoadingRequirements] = useState(false);
const [expanded, setExpanded] = useState(false);
const [overallScore, setOverallScore] = useState(0);
const [analyzing, setAnalyzing] = useState(false);
const [matchStatus, setMatchStatus] = useState('');
const [percentage, setPercentage] = useState(0);
const [analysis, setAnalysis] = useState(null);
const [startAnalysis, setStartAnalysis] = useState(true);
const [firstRun, setFirstRun] = useState(true);
const isMobile = useMediaQuery(theme.breakpoints.down('sm'));
// Handle accordion expansion
const handleAccordionChange =
(panel: string) => (event: React.SyntheticEvent, isExpanded: boolean) => {
setExpanded(isExpanded ? panel : false);
};
const initializeRequirements = useCallback(
(job: Job): void => {
if (!job || !job.requirements) {
return;
}
const requirements: { requirement: string; domain: string }[] = [];
if (job.requirements?.technicalSkills) {
job.requirements.technicalSkills.required?.forEach(req =>
requirements.push({
requirement: req,
domain: 'Technical Skills (required)',
})
);
job.requirements.technicalSkills.preferred?.forEach(req =>
requirements.push({
requirement: req,
domain: 'Technical Skills (preferred)',
})
);
}
if (job.requirements?.experienceRequirements) {
job.requirements.experienceRequirements.required?.forEach(req =>
requirements.push({ requirement: req, domain: 'Experience (required)' })
);
job.requirements.experienceRequirements.preferred?.forEach(req =>
requirements.push({
requirement: req,
domain: 'Experience (preferred)',
})
);
}
if (job.requirements?.softSkills) {
job.requirements.softSkills.forEach(req =>
requirements.push({ requirement: req, domain: 'Soft Skills' })
);
}
if (job.requirements?.experience) {
job.requirements.experience.forEach(req =>
requirements.push({ requirement: req, domain: 'Experience' })
);
}
if (job.requirements?.education) {
job.requirements.education.forEach(req =>
requirements.push({ requirement: req, domain: 'Education' })
);
}
if (job.requirements?.certifications) {
job.requirements.certifications.forEach(req =>
requirements.push({ requirement: req, domain: 'Certifications' })
);
}
if (job.requirements?.preferredAttributes) {
job.requirements.preferredAttributes.forEach(req =>
requirements.push({ requirement: req, domain: 'Preferred Attributes' })
);
}
const initialSkillMatches: SkillMatch[] = requirements.map(req => ({
skill: req.requirement,
skillModified: req.requirement,
candidateId: candidate.id || '',
domain: req.domain,
status: 'waiting' as const,
assessment: '',
description: '',
evidenceFound: false,
evidenceStrength: 'none',
evidenceDetails: [],
matchScore: 0,
}));
setRequirements(requirements);
setSkillMatches(initialSkillMatches);
setLoadingRequirements(false);
setOverallScore(0);
},
[candidate.id]
);
useEffect(() => {
initializeRequirements(job);
}, [job, initializeRequirements]);
const skillMatchHandlers = useMemo(() => {
return {
onStatus: (status: Types.ChatMessageStatus): void => {
console.log('Skill Match Status:', status.content);
setMatchStatus(status.content);
},
};
}, [setMatchStatus]);
// Fetch match data for each requirement
useEffect(() => {
if (
(!startAnalysis && !firstRun) ||
analyzing ||
!job.requirements ||
requirements.length === 0
) {
return;
}
const fetchMatchData = async (firstRun: boolean): Promise => {
const currentAnalysis = await apiClient.getJobAnalysis(job, candidate);
for (let i = 0; i < requirements.length; i++) {
try {
let match: SkillMatch;
const existingMatch = currentAnalysis?.skills.find(
(match: SkillAssessment) => match.skill === requirements[i].requirement
);
if (existingMatch) {
match = {
...existingMatch,
status: 'complete',
matchScore: calculateScore(existingMatch),
domain: requirements[i].domain,
};
} else {
if (firstRun) {
continue;
}
setSkillMatches(prev => {
const updated = [...prev];
updated[i] = { ...updated[i], status: 'pending' };
return updated;
});
const request = await apiClient.candidateMatchForRequirement(
candidate.id || '',
requirements[i].requirement,
false,
skillMatchHandlers
);
const result = await request.promise; /* Wait for the streaming result to complete */
const skillMatch = result.skillAssessment;
setMatchStatus('');
match = {
...skillMatch,
status: 'complete',
matchScore: calculateScore(skillMatch),
domain: requirements[i].domain,
};
}
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(Math.round(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;
});
}
setPercentage(Math.round(((i + 1) / requirements.length) * 100));
}
};
setAnalyzing(true);
setPercentage(0);
fetchMatchData(firstRun).then(() => {
setAnalyzing(false);
setStartAnalysis(false);
});
setFirstRun(false);
}, [
job,
startAnalysis,
analyzing,
requirements,
loadingRequirements,
apiClient,
candidate.id,
skillMatchHandlers,
firstRun,
]);
useEffect(() => {
if (skillMatches.length === 0) {
return;
}
const finishedAnalysis = skillMatches.every(
match => match.status === 'complete' || match.status === 'error'
);
if (!finishedAnalysis) {
return;
}
if (analysis && analysis.score === overallScore) {
return; // No change in score, skip setting analysis
}
const newAnalysis: JobAnalysisScore = {
score: overallScore,
skills: skillMatches,
};
setAnalysis(newAnalysis);
onAnalysisComplete && onAnalysisComplete(newAnalysis);
}, [onAnalysisComplete, skillMatches, overallScore, analysis]);
// 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): JSX.Element => {
if (status === 'pending' || status === 'waiting') return ;
if (status === 'error') return ;
if (score >= 70) return ;
if (score >= 40) return ;
return ;
};
const handleAssesmentRegenerate = async (index: number, skill: string): Promise => {
setSkillMatches(prev => {
const updated = [...prev];
updated[index] = {
...updated[index],
status: 'pending',
assessment: '',
evidenceDetails: [],
evidenceStrength: 'none',
matchScore: 0,
};
return updated;
});
setMatchStatus('Regenerating assessment...');
const request = apiClient.candidateMatchForRequirement(
candidate.id || '',
skill,
true,
skillMatchHandlers
);
const result = await request.promise; /* Wait for the streaming result to complete */
const skillMatch = result.skillAssessment;
setMatchStatus('');
setSkillMatches(prev => {
const updated = [...prev];
updated[index] = {
...skillMatch,
status: 'complete',
matchScore: calculateScore(skillMatch),
domain: updated[index].domain,
};
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(Math.round(newOverallScore));
}
return current;
});
setAnalysis({
score: overallScore,
skills: skillMatches,
});
onAnalysisComplete &&
onAnalysisComplete({
score: overallScore,
skills: skillMatches,
});
setStartAnalysis(false);
setAnalyzing(false);
setFirstRun(false);
};
const beginAnalysis = (): void => {
initializeRequirements(job);
setStartAnalysis(true);
};
return (
{variant !== 'small' && }
{analyzing && overallScore !== 0 && (
)}
{analyzing && (
Analyzing
{`${Math.round(percentage)}%`}
)}
{loadingRequirements ? (
Analyzing job requirements...
) : (
Requirements Analysis
{skillMatches.map((match, index) => (
}
aria-controls={`panel${index}bh-content`}
id={`panel${index}bh-header`}
sx={{
bgcolor:
match.status === 'complete'
? `${getMatchColor(match.matchScore)}22` // Add transparency
: 'inherit',
}}
>
{getStatusIcon(match.status, match.matchScore)}
{match.skill}
{match.domain}
{match.status === 'complete' ? (
) : match.status === 'waiting' ? (
) : match.status === 'pending' ? (
) : (
)}
{match.status === 'pending' ? (
Analyzing candidate's match for this requirement... {matchStatus}
) : match.status === 'error' ? (
{match.assessment || 'An error occurred while analyzing this requirement.'}
) : (
Assessment
{match.assessment}
Supporting Evidence
{match.evidenceDetails && match.evidenceDetails.length > 0 ? (
match.evidenceDetails.map((evidence, evndex) => (
"{evidence.quote}"
Relevance: {evidence.context}
Source: {evidence.source}
{/* */}
))
) : (
No specific evidence found in candidate's profile.
)}
Skill description
{match.description}
{match.status === 'complete' && (
{
e.stopPropagation();
handleAssesmentRegenerate(index, match.skill);
}}
>
)}
{/* { match.ragResults && match.ragResults.length !== 0 && <>
RAG Information
>
} */}
)}
))}
)}
);
};
export type { JobAnalysisScore };
export { JobMatchAnalysis, JobMatchScore };