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