457 lines
13 KiB
TypeScript
457 lines
13 KiB
TypeScript
import React, { useState, useEffect, useCallback, useRef, JSX } from 'react';
|
|
import {
|
|
Box,
|
|
Stepper,
|
|
Step,
|
|
StepLabel,
|
|
Button,
|
|
Paper,
|
|
useTheme,
|
|
Snackbar,
|
|
Alert,
|
|
Tabs,
|
|
Tab,
|
|
Avatar,
|
|
useMediaQuery,
|
|
} from '@mui/material';
|
|
import { Add, WorkOutline } from '@mui/icons-material';
|
|
import PersonIcon from '@mui/icons-material/Person';
|
|
import WorkIcon from '@mui/icons-material/Work';
|
|
import AssessmentIcon from '@mui/icons-material/Assessment';
|
|
import { JobMatchAnalysis } from 'components/JobMatchAnalysis';
|
|
import { Candidate, Job, SkillAssessment } from 'types/types';
|
|
import { BackstoryPageProps } from 'components/BackstoryTab';
|
|
import { useAuth } from 'hooks/AuthContext';
|
|
import { useSelectedCandidate, useSelectedJob } from 'hooks/GlobalContext';
|
|
import { CandidateInfo } from 'components/ui/CandidateInfo';
|
|
import { Scrollable } from 'components/Scrollable';
|
|
import { CandidatePicker } from 'components/ui/CandidatePicker';
|
|
import { JobCreator } from 'components/JobCreator';
|
|
import { LoginRestricted } from 'components/ui/LoginRestricted';
|
|
import { ResumeGenerator } from 'components/ResumeGenerator';
|
|
import { JobInfo } from 'components/ui/JobInfo';
|
|
import { JobsView } from 'components/ui/JobsView';
|
|
|
|
function WorkAddIcon(): JSX.Element {
|
|
return (
|
|
<Box
|
|
position="relative"
|
|
display="inline-flex"
|
|
sx={{
|
|
lineHeight: '30px',
|
|
mb: '6px',
|
|
}}
|
|
>
|
|
<WorkOutline sx={{ fontSize: 24 }} />
|
|
<Add
|
|
sx={{
|
|
position: 'absolute',
|
|
bottom: -2,
|
|
right: -2,
|
|
fontSize: 14,
|
|
bgcolor: 'background.paper',
|
|
borderRadius: '50%',
|
|
boxShadow: 1,
|
|
}}
|
|
color="primary"
|
|
/>
|
|
</Box>
|
|
);
|
|
}
|
|
|
|
interface AnalysisState {
|
|
job: Job | null;
|
|
candidate: Candidate | null;
|
|
analysis: SkillAssessment[] | null;
|
|
resume: string | null;
|
|
}
|
|
|
|
interface StepData {
|
|
index: number;
|
|
label: string;
|
|
requiredState: string[];
|
|
title: string;
|
|
icon: React.ReactNode;
|
|
}
|
|
|
|
const initialState: AnalysisState = {
|
|
job: null,
|
|
candidate: null,
|
|
analysis: null,
|
|
resume: null,
|
|
};
|
|
|
|
// Steps in our process
|
|
const steps: StepData[] = [
|
|
{ requiredState: [], title: 'Job Selection', icon: <WorkIcon /> },
|
|
{ requiredState: ['job'], title: 'Select Candidate', icon: <PersonIcon /> },
|
|
{
|
|
requiredState: ['job', 'candidate'],
|
|
title: 'Job Analysis',
|
|
icon: <WorkIcon />,
|
|
},
|
|
{
|
|
requiredState: ['job', 'candidate', 'analysis'],
|
|
title: 'Generated Resume',
|
|
icon: <AssessmentIcon />,
|
|
},
|
|
].map((item, index) => {
|
|
return { ...item, index, label: item.title.toLowerCase().replace(/ /g, '-') };
|
|
});
|
|
|
|
const capitalize = (str: string): string => {
|
|
return str.charAt(0).toUpperCase() + str.slice(1);
|
|
};
|
|
|
|
// Main component
|
|
const JobAnalysisPage: React.FC<BackstoryPageProps> = (_props: BackstoryPageProps) => {
|
|
const theme = useTheme();
|
|
const { user, guest } = useAuth();
|
|
const { selectedCandidate, setSelectedCandidate } = useSelectedCandidate();
|
|
const { selectedJob, setSelectedJob } = useSelectedJob();
|
|
|
|
const [activeStep, setActiveStep] = useState<StepData>(steps[0]);
|
|
const [error, setError] = useState<string | null>(null);
|
|
const [jobTab, setJobTab] = useState<string>('select');
|
|
const [analysisState, setAnalysisState] = useState<AnalysisState | null>(null);
|
|
const [canAdvance, setCanAdvance] = useState<boolean>(false);
|
|
const scrollRef = useRef<HTMLDivElement>(null);
|
|
const isMobile = useMediaQuery(theme.breakpoints.down('sm'));
|
|
|
|
const canAccessStep = useCallback(
|
|
(step: StepData) => {
|
|
if (!analysisState) {
|
|
return;
|
|
}
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
const missing = step.requiredState.find(f => !(analysisState as any)[f]);
|
|
return missing;
|
|
},
|
|
[analysisState]
|
|
);
|
|
|
|
useEffect(() => {
|
|
if (analysisState !== null) {
|
|
return;
|
|
}
|
|
|
|
const analysis = {
|
|
...initialState,
|
|
candidate: selectedCandidate,
|
|
job: selectedJob,
|
|
};
|
|
setAnalysisState(analysis);
|
|
for (let i = steps.length - 1; i >= 0; i--) {
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
const missing = steps[i].requiredState.find(f => !(analysis as any)[f]);
|
|
if (!missing) {
|
|
setActiveStep(steps[i]);
|
|
return;
|
|
}
|
|
}
|
|
}, [analysisState, selectedCandidate, selectedJob, setActiveStep, canAccessStep]);
|
|
|
|
useEffect(() => {
|
|
if (activeStep.index === steps.length - 1) {
|
|
setCanAdvance(false);
|
|
return;
|
|
}
|
|
const blocked = canAccessStep(steps[activeStep.index + 1]);
|
|
if (blocked) {
|
|
setCanAdvance(false);
|
|
} else {
|
|
setCanAdvance(true);
|
|
}
|
|
if (scrollRef.current) {
|
|
scrollRef.current.scrollTo({
|
|
top: 0,
|
|
behavior: 'smooth',
|
|
});
|
|
}
|
|
}, [setCanAdvance, analysisState, activeStep, canAccessStep]);
|
|
|
|
const handleNext = (): void => {
|
|
if (activeStep.index === steps.length - 1) {
|
|
return;
|
|
}
|
|
const missing = canAccessStep(steps[activeStep.index + 1]);
|
|
if (missing) {
|
|
setError(`${capitalize(missing)} is necessary before continuing.`);
|
|
return;
|
|
}
|
|
|
|
if (activeStep.index < steps.length - 1) {
|
|
setActiveStep(prevActiveStep => steps[prevActiveStep.index + 1]);
|
|
}
|
|
};
|
|
|
|
const handleBack = (): void => {
|
|
if (activeStep.index === 0) {
|
|
return;
|
|
}
|
|
|
|
setActiveStep(prevActiveStep => steps[prevActiveStep.index - 1]);
|
|
};
|
|
|
|
const moveToStep = (step: number): void => {
|
|
const missing = canAccessStep(steps[step]);
|
|
if (missing) {
|
|
setError(`${capitalize(missing)} is needed to access this step.`);
|
|
return;
|
|
}
|
|
setActiveStep(steps[step]);
|
|
};
|
|
|
|
const onCandidateSelect = (candidate: Candidate): void => {
|
|
if (!analysisState) {
|
|
return;
|
|
}
|
|
analysisState.candidate = candidate;
|
|
setAnalysisState({ ...analysisState });
|
|
setSelectedCandidate(candidate);
|
|
handleNext();
|
|
};
|
|
|
|
const onJobsSelected = (job: Job): void => {
|
|
if (!analysisState) {
|
|
return;
|
|
}
|
|
analysisState.job = job;
|
|
setAnalysisState({ ...analysisState });
|
|
setSelectedJob(job);
|
|
handleNext();
|
|
};
|
|
|
|
// Render function for the candidate selection step
|
|
const renderCandidateSelection = (): JSX.Element => (
|
|
<CandidatePicker sx={{ pt: 1 }} onSelect={onCandidateSelect} />
|
|
);
|
|
|
|
const handleTabChange = (event: React.SyntheticEvent, value: string): void => {
|
|
setJobTab(value);
|
|
};
|
|
|
|
// Render function for the job description step
|
|
const renderJobDescription = (): JSX.Element => {
|
|
return (
|
|
<Box sx={{ mt: 3, width: '100%' }}>
|
|
<Box sx={{ borderBottom: 1, borderColor: 'divider', mb: 3 }}>
|
|
<Tabs value={jobTab} onChange={handleTabChange} centered>
|
|
<Tab value="select" icon={<WorkOutline />} label="Select Job" />
|
|
<Tab value="create" icon={<WorkAddIcon />} label="Create Job" />
|
|
</Tabs>
|
|
</Box>
|
|
|
|
{jobTab === 'select' && (
|
|
<JobsView selectable={false} onJobView={onJobsSelected} showDetailsPanel={false} />
|
|
)}
|
|
{jobTab === 'create' && user && (
|
|
<JobCreator
|
|
onSave={(job): void => {
|
|
onJobsSelected(job);
|
|
}}
|
|
/>
|
|
)}
|
|
{jobTab === 'create' && guest && (
|
|
<LoginRestricted>
|
|
<JobCreator
|
|
onSave={(job): void => {
|
|
onJobsSelected(job);
|
|
}}
|
|
/>
|
|
</LoginRestricted>
|
|
)}
|
|
</Box>
|
|
);
|
|
};
|
|
|
|
const onAnalysisComplete = (skills: SkillAssessment[]): void => {
|
|
if (!analysisState) {
|
|
return;
|
|
}
|
|
analysisState.analysis = skills;
|
|
setAnalysisState({ ...analysisState });
|
|
};
|
|
|
|
// Render function for the analysis step
|
|
const renderAnalysis = (): JSX.Element => {
|
|
if (!analysisState) {
|
|
return <></>;
|
|
}
|
|
if (!analysisState.job || !analysisState.candidate) {
|
|
return (
|
|
<Box>
|
|
{JSON.stringify({
|
|
job: analysisState.job,
|
|
candidate: analysisState.candidate,
|
|
})}
|
|
</Box>
|
|
);
|
|
}
|
|
return (
|
|
<Box sx={{ mt: 3 }}>
|
|
<JobMatchAnalysis
|
|
variant="small"
|
|
job={analysisState.job}
|
|
candidate={analysisState.candidate}
|
|
onAnalysisComplete={onAnalysisComplete}
|
|
/>
|
|
</Box>
|
|
);
|
|
};
|
|
|
|
const renderResume = (): JSX.Element => {
|
|
if (!analysisState) {
|
|
return <></>;
|
|
}
|
|
if (!analysisState.job || !analysisState.candidate || !analysisState.analysis) {
|
|
return <></>;
|
|
}
|
|
|
|
return (
|
|
<Box sx={{ mt: 3 }}>
|
|
<ResumeGenerator
|
|
job={analysisState.job}
|
|
candidate={analysisState.candidate}
|
|
skills={analysisState.analysis}
|
|
/>
|
|
</Box>
|
|
);
|
|
};
|
|
|
|
return (
|
|
<Box
|
|
sx={{
|
|
display: 'flex',
|
|
flexDirection: 'column',
|
|
height: '100%' /* Restrict to main-container's height */,
|
|
width: '100%',
|
|
minHeight: 0 /* Prevent flex overflow */,
|
|
maxHeight: 'min-content',
|
|
'& > *:not(.Scrollable)': {
|
|
flexShrink: 0 /* Prevent shrinking */,
|
|
},
|
|
position: 'relative',
|
|
}}
|
|
>
|
|
<Paper elevation={4} sx={{ m: 0, borderRadius: 0, mb: 1, p: 0, gap: 1 }}>
|
|
<Stepper activeStep={activeStep.index} alternativeLabel sx={{ mt: 2, mb: 2 }}>
|
|
{steps.map((step, index) => (
|
|
<Step key={step.index}>
|
|
<StepLabel
|
|
sx={{ cursor: 'pointer' }}
|
|
onClick={(): void => {
|
|
moveToStep(index);
|
|
}}
|
|
slots={{
|
|
stepIcon: (): JSX.Element => (
|
|
<Avatar
|
|
key={step.index}
|
|
sx={{
|
|
bgcolor:
|
|
activeStep.index >= step.index
|
|
? theme.palette.primary.main
|
|
: theme.palette.grey[300],
|
|
color: 'white',
|
|
}}
|
|
>
|
|
{step.icon}
|
|
</Avatar>
|
|
),
|
|
}}
|
|
>
|
|
{step.title}
|
|
</StepLabel>
|
|
</Step>
|
|
))}
|
|
</Stepper>
|
|
<Box sx={{ display: 'flex', flexDirection: isMobile ? 'column' : 'row' }}>
|
|
{analysisState && analysisState.job && (
|
|
<Box sx={{ display: 'flex', flexDirection: 'row', width: '100%' }}>
|
|
{!isMobile && (
|
|
<Avatar
|
|
sx={{
|
|
ml: 1,
|
|
mt: 1,
|
|
bgcolor: theme.palette.primary.main,
|
|
color: 'white',
|
|
}}
|
|
>
|
|
<WorkIcon />
|
|
</Avatar>
|
|
)}
|
|
<JobInfo variant="minimal" job={analysisState.job} />
|
|
</Box>
|
|
)}
|
|
{isMobile && <Box sx={{ display: 'flex', borderBottom: '1px solid lightgrey' }} />}
|
|
{!isMobile && <Box sx={{ display: 'flex', borderLeft: '1px solid lightgrey' }} />}
|
|
{analysisState && analysisState.candidate && (
|
|
<Box sx={{ display: 'flex', flexDirection: 'row', width: '100%' }}>
|
|
<CandidateInfo variant="minimal" candidate={analysisState.candidate} sx={{}} />
|
|
</Box>
|
|
)}
|
|
</Box>
|
|
</Paper>
|
|
<Scrollable
|
|
ref={scrollRef}
|
|
sx={{
|
|
position: 'relative',
|
|
maxHeight: '100%',
|
|
width: '100%',
|
|
display: 'flex',
|
|
flexGrow: 1,
|
|
flex: 1 /* Take remaining space in some-container */,
|
|
overflowY: 'auto' /* Scroll if content overflows */,
|
|
}}
|
|
>
|
|
{activeStep.label === 'job-selection' && renderJobDescription()}
|
|
{activeStep.label === 'select-candidate' && renderCandidateSelection()}
|
|
{activeStep.label === 'job-analysis' && renderAnalysis()}
|
|
{activeStep.label === 'generated-resume' && renderResume()}
|
|
</Scrollable>
|
|
|
|
<Box sx={{ display: 'flex', flexDirection: 'row', pt: 2 }}>
|
|
<Button
|
|
color="inherit"
|
|
disabled={activeStep.index === steps[0].index}
|
|
onClick={handleBack}
|
|
sx={{ mr: 1 }}
|
|
>
|
|
Back
|
|
</Button>
|
|
<Box sx={{ flex: '1 1 auto' }} />
|
|
|
|
{activeStep.index === steps[steps.length - 1].index ? (
|
|
<Button
|
|
disabled={!canAdvance}
|
|
onClick={(): void => {
|
|
moveToStep(0);
|
|
}}
|
|
variant="outlined"
|
|
>
|
|
Start New Analysis
|
|
</Button>
|
|
) : (
|
|
<Button disabled={!canAdvance} onClick={handleNext} variant="contained">
|
|
{activeStep.index === steps.length - 1 ? 'Done' : 'Next'}
|
|
</Button>
|
|
)}
|
|
</Box>
|
|
|
|
{/* Error Snackbar */}
|
|
<Snackbar
|
|
open={!!error}
|
|
autoHideDuration={6000}
|
|
onClose={(): void => setError(null)}
|
|
anchorOrigin={{ vertical: 'bottom', horizontal: 'center' }}
|
|
>
|
|
<Alert onClose={(): void => setError(null)} severity="error" sx={{ width: '100%' }}>
|
|
{error}
|
|
</Alert>
|
|
</Snackbar>
|
|
</Box>
|
|
);
|
|
};
|
|
|
|
export { JobAnalysisPage };
|