Full initial flow for guest working

This commit is contained in:
James Ketr 2025-06-10 15:10:26 -07:00
parent 3a21f2e510
commit bb4017b835
17 changed files with 456 additions and 388 deletions

BIN
frontend/public/final-resume.png Executable file

Binary file not shown.

After

Width:  |  Height:  |  Size: 81 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 76 KiB

BIN
frontend/public/select-a-job.png Executable file

Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 76 KiB

BIN
frontend/public/wait.png Executable file

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

View File

@ -95,13 +95,6 @@ const JobCreator = (props: JobCreatorProps) => {
const fileInputRef = useRef<HTMLInputElement>(null); const fileInputRef = useRef<HTMLInputElement>(null);
if (!user?.id) {
return (
<LoginRequired asset="job creation" />
);
}
const jobStatusHandlers = { const jobStatusHandlers = {
onStatus: (status: Types.ChatMessageStatus) => { onStatus: (status: Types.ChatMessageStatus) => {
console.log('status:', status.content); console.log('status:', status.content);

View File

@ -32,10 +32,12 @@ import { useAppState } from 'hooks/GlobalContext';
import * as Types from 'types/types'; import * as Types from 'types/types';
import JsonView from '@uiw/react-json-view'; import JsonView from '@uiw/react-json-view';
import { VectorVisualizer } from './VectorVisualizer'; import { VectorVisualizer } from './VectorVisualizer';
import { JobInfo } from './ui/JobInfo';
interface JobAnalysisProps extends BackstoryPageProps { interface JobAnalysisProps extends BackstoryPageProps {
job: Job; job: Job;
candidate: Candidate; candidate: Candidate;
variant?: "small" | "normal";
onAnalysisComplete: (skills: SkillAssessment[]) => void; onAnalysisComplete: (skills: SkillAssessment[]) => void;
} }
@ -54,6 +56,7 @@ const JobMatchAnalysis: React.FC<JobAnalysisProps> = (props: JobAnalysisProps) =
job, job,
candidate, candidate,
onAnalysisComplete, onAnalysisComplete,
variant = "normal",
} = props } = props
const { apiClient } = useAuth(); const { apiClient } = useAuth();
const { setSnack } = useAppState(); const { setSnack } = useAppState();
@ -69,6 +72,7 @@ const JobMatchAnalysis: React.FC<JobAnalysisProps> = (props: JobAnalysisProps) =
const [startAnalysis, setStartAnalysis] = useState<boolean>(false); const [startAnalysis, setStartAnalysis] = useState<boolean>(false);
const [analyzing, setAnalyzing] = useState<boolean>(false); const [analyzing, setAnalyzing] = useState<boolean>(false);
const [matchStatus, setMatchStatus] = useState<string>(''); const [matchStatus, setMatchStatus] = useState<string>('');
const [matchStatusType, setMatchStatusType] = useState<Types.ApiActivityType | null>(null);
const isMobile = useMediaQuery(theme.breakpoints.down('sm')); const isMobile = useMediaQuery(theme.breakpoints.down('sm'));
@ -133,6 +137,7 @@ const JobMatchAnalysis: React.FC<JobAnalysisProps> = (props: JobAnalysisProps) =
const skillMatchHandlers = { const skillMatchHandlers = {
onStatus: (status: Types.ChatMessageStatus) => { onStatus: (status: Types.ChatMessageStatus) => {
setMatchStatusType(status.activity);
setMatchStatus(status.content.toLowerCase()); setMatchStatus(status.content.toLowerCase());
}, },
}; };
@ -238,67 +243,30 @@ const JobMatchAnalysis: React.FC<JobAnalysisProps> = (props: JobAnalysisProps) =
}; };
return ( return (
<Box> <Box sx={{ display: "flex", flexDirection: "column" }}>
<Paper elevation={3} sx={{ p: 3, mb: 4 }}> {variant !== "small" &&
<Grid container spacing={2}> <JobInfo job={job} variant="normal" />
<Box sx={{ display: "flex", flexDirection: isMobile ? "column" : "row" }}> }
<Grid size={{ xs: 12, md: 6 }} sx={{ display: "flex", flexGrow: 1, flexDirection: "column" }}>
<Typography variant="h6" component="h2">
Company:
</Typography>
<Typography variant="body1" component="h2">
{job.company || "N/A"}
</Typography>
<Typography variant="h6" component="h2"> <Box sx={{ display: 'flex', flexDirection: "row", alignItems: 'center', mb: 2, gap: 1, justifyContent: "space-between" }}>
Job Title: <Box sx={{ display: "flex", flexDirection: "row", flexGrow: 1 }}>
</Typography> {overallScore !== 0 && <>
<Typography variant="body1" component="h2">
{job.title || "N/A"}
</Typography>
<Typography variant="h6" component="h2">
Backstory Generated Job Summary:
</Typography>
<Typography variant="body1" component="h2">
{job.summary || "N/A"}
</Typography>
<Typography variant="caption">Job ID: {job.id}</Typography>
</Grid>
<Grid size={{ xs: 12, md: 6 }} sx={{ display: "flex", flexShrink: 1, flexDirection: "column" }}>
<Typography variant="h6" component="h2">
Original Job Description:
</Typography>
<Paper sx={{ p: 2, maxHeight: "22rem" }}>
<Scrollable sx={{ display: "flex", maxHeight: "100%" }}>
<StyledMarkdown content={job.description} />
</Scrollable>
</Paper>
</Grid>
</Box>
<Grid size={{ xs: 12 }} sx={{ mt: 2 }}>
<Box sx={{ display: 'flex', alignItems: 'center', mb: 2, gap: 1 }}>
{<Button disabled={analyzing || startAnalysis} onClick={beginAnalysis} variant="contained">Start Skill Assessment</Button>}
{overallScore !== 0 && <>
<Typography variant="h5" component="h2" sx={{ mr: 2 }}> <Typography variant="h5" component="h2" sx={{ mr: 2 }}>
Overall Match: Overall Match:
</Typography> </Typography>
<Box sx={{ <Box sx={{
position: 'relative', position: 'relative',
display: 'inline-flex', display: 'inline-flex',
mr: 2 mr: 2
}}> }}>
<CircularProgress <CircularProgress
variant="determinate" variant="determinate"
value={overallScore} value={overallScore}
size={60} size={60}
thickness={5} thickness={5}
sx={{ sx={{
color: getMatchColor(overallScore), color: getMatchColor(overallScore),
}} }}
/> />
<Box <Box
sx={{ sx={{
@ -317,23 +285,24 @@ const JobMatchAnalysis: React.FC<JobAnalysisProps> = (props: JobAnalysisProps) =
</Typography> </Typography>
</Box> </Box>
</Box> </Box>
<Chip <Chip
label={ label={
overallScore >= 80 ? "Excellent Match" : overallScore >= 80 ? "Excellent Match" :
overallScore >= 60 ? "Good Match" : overallScore >= 60 ? "Good Match" :
overallScore >= 40 ? "Partial Match" : "Low Match" overallScore >= 40 ? "Partial Match" : "Low Match"
} }
sx={{ sx={{
bgcolor: getMatchColor(overallScore), bgcolor: getMatchColor(overallScore),
color: 'white', color: 'white',
fontWeight: 'bold' fontWeight: 'bold'
}} }}
/> />
</>} </>}
</Box> </Box>
</Grid> <Button sx={{ marginLeft: "auto" }} disabled={analyzing || startAnalysis} onClick={beginAnalysis} variant="contained">
</Grid> {analyzing ? "Assessment in Progress" : "Start Skill Assessment"}
</Paper> </Button>
</Box>
{loadingRequirements ? ( {loadingRequirements ? (
<Box sx={{ display: 'flex', justifyContent: 'center', p: 4 }}> <Box sx={{ display: 'flex', justifyContent: 'center', p: 4 }}>

View File

@ -373,7 +373,7 @@ const Message = (props: MessageProps) => {
if (typeof (message.content) === "string") { if (typeof (message.content) === "string") {
content = message.content.trim(); content = message.content.trim();
} else { } else {
console.error(`message content is not a string`); console.error(`message content is not a string, it is a ${typeof message.content}`);
return (<></>) return (<></>)
} }

View File

@ -30,16 +30,14 @@ const defaultMessage: Types.ChatMessageStatus = {
const ResumeGenerator: React.FC<ResumeGeneratorProps> = (props: ResumeGeneratorProps) => { const ResumeGenerator: React.FC<ResumeGeneratorProps> = (props: ResumeGeneratorProps) => {
const { job, candidate, skills, onComplete } = props; const { job, candidate, skills, onComplete } = props;
const { apiClient } = useAuth(); const { apiClient, user } = useAuth();
const [resume, setResume] = useState<string>(''); const [resume, setResume] = useState<string>('');
const [prompt, setPrompt] = useState<string>(''); const [prompt, setPrompt] = useState<string>('');
const [systemPrompt, setSystemPrompt] = useState<string>(''); const [systemPrompt, setSystemPrompt] = useState<string>('');
const [generating, setGenerating] = useState<boolean>(false); const [generating, setGenerating] = useState<boolean>(false);
const [statusMessage, setStatusMessage] = useState<Types.ChatMessageStatus | null>(null); const [statusMessage, setStatusMessage] = useState<Types.ChatMessageStatus | null>(null);
const [tabValue, setTabValue] = useState<string>('resume'); const [tabValue, setTabValue] = useState<string>('resume');
const handleTabChange = (event: React.SyntheticEvent, newValue: string) => { const handleTabChange = (event: React.SyntheticEvent, newValue: string) => {
setTabValue(newValue); setTabValue(newValue);
} }
@ -47,7 +45,7 @@ const ResumeGenerator: React.FC<ResumeGeneratorProps> = (props: ResumeGeneratorP
const generateResumeHandlers = { const generateResumeHandlers = {
onStatus: (status: Types.ChatMessageStatus) => { onStatus: (status: Types.ChatMessageStatus) => {
setStatusMessage({...defaultMessage, content: status.content.toLowerCase}); setStatusMessage({ ...defaultMessage, content: status.content.toLowerCase() });
}, },
onStreaming: (chunk: Types.ChatMessageStreaming) =>{ onStreaming: (chunk: Types.ChatMessageStreaming) =>{
setResume(chunk.content); setResume(chunk.content);
@ -82,20 +80,20 @@ const ResumeGenerator: React.FC<ResumeGeneratorProps> = (props: ResumeGeneratorP
display: "flex", display: "flex",
flexDirection: "column", flexDirection: "column",
}}> }}>
<Box sx={{ borderBottom: 1, borderColor: 'divider', mb: 3 }}> {user?.isAdmin && <Box sx={{ borderBottom: 1, borderColor: 'divider', mb: 3 }}>
<Tabs value={tabValue} onChange={handleTabChange} centered> <Tabs value={tabValue} onChange={handleTabChange} centered>
<Tab sx={{display: systemPrompt ? "flex" : "none"}} value="system" icon={<TuneIcon />} label="System" /> <Tab sx={{ display: systemPrompt ? "flex" : "none" }} value="system" icon={<TuneIcon />} label="System" />
<Tab sx={{display: prompt ? "flex" : "none"}}value="prompt" icon={<InputIcon />} label="Prompt" /> <Tab sx={{ display: prompt ? "flex" : "none" }} value="prompt" icon={<InputIcon />} label="Prompt" />
<Tab sx={{display: resume ? "flex" : "none"}}value="resume" icon={<ArticleIcon />} label="Resume" /> <Tab sx={{ display: resume ? "flex" : "none" }} value="resume" icon={<ArticleIcon />} label="Resume" />
</Tabs> </Tabs>
</Box> </Box>}
{ statusMessage && <Message message={statusMessage} />} {statusMessage && <Message message={statusMessage} />}
<Paper elevation={3} sx={{ p: 3, m: 4, mt: 0 }}><Scrollable autoscroll sx={{display: "flex", flexGrow: 1}}> <Paper elevation={3} sx={{ p: 3, m: 4, mt: 0 }}><Scrollable autoscroll sx={{ display: "flex", flexGrow: 1 }}>
{ tabValue === 'system' && <pre>{systemPrompt}</pre> } {tabValue === 'system' && <pre>{systemPrompt}</pre>}
{ tabValue === 'prompt' && <pre>{prompt}</pre> } {tabValue === 'prompt' && <pre>{prompt}</pre>}
{ tabValue === 'resume' && <StyledMarkdown content={resume} />} {tabValue === 'resume' && <StyledMarkdown content={resume} />}
</Scrollable></Paper> </Scrollable></Paper>
</Box> </Box>
) )
}; };

View File

@ -1,6 +1,6 @@
import Box from '@mui/material/Box'; import Box from '@mui/material/Box';
import { SxProps, Theme } from '@mui/material'; import { SxProps, Theme } from '@mui/material';
import { RefObject, useRef } from 'react'; import { RefObject, useRef, forwardRef, useImperativeHandle } from 'react';
import { useAutoScrollToBottom } from '../hooks/useAutoScrollToBottom'; import { useAutoScrollToBottom } from '../hooks/useAutoScrollToBottom';
interface ScrollableProps { interface ScrollableProps {
@ -13,7 +13,7 @@ interface ScrollableProps {
className?: string; className?: string;
} }
const Scrollable = (props: ScrollableProps) => { const Scrollable = forwardRef((props: ScrollableProps, ref) => {
const { sx, className, children, autoscroll, textFieldRef, fallbackThreshold = 0.33, contentUpdateTrigger } = props; const { sx, className, children, autoscroll, textFieldRef, fallbackThreshold = 0.33, contentUpdateTrigger } = props;
// Create a default ref if textFieldRef is not provided // Create a default ref if textFieldRef is not provided
const defaultTextFieldRef = useRef<HTMLElement | null>(null); const defaultTextFieldRef = useRef<HTMLElement | null>(null);
@ -32,11 +32,11 @@ const Scrollable = (props: ScrollableProps) => {
// backgroundColor: '#F5F5F5', // backgroundColor: '#F5F5F5',
...sx, ...sx,
}} }}
ref={autoscroll !== undefined && autoscroll !== false ? scrollRef : undefined} ref={autoscroll !== undefined && autoscroll !== false ? scrollRef : ref}
> >
{children} {children}
</Box> </Box>
); );
}; });
export { useAutoScrollToBottom, Scrollable }; export { useAutoScrollToBottom, Scrollable };

View File

@ -48,114 +48,111 @@ const CandidateInfo: React.FC<CandidateInfoProps> = (props: CandidateInfoProps)
} }
return ( return (
<Card <Box
elevation={elevation}
sx={{ sx={{
display: "flex", display: "flex",
borderColor: 'transparent', borderColor: 'transparent',
borderWidth: 2, borderWidth: 2,
borderStyle: 'solid', borderStyle: 'solid',
transition: 'all 0.3s ease', transition: 'all 0.3s ease',
flexGrow: 1,
p: 3,
height: '100%',
flexDirection: 'column',
alignItems: 'stretch',
position: "relative",
overflow: "hidden",
...sx ...sx
}} }}
{...rest} {...rest}
> >
<CardContent sx={{ display: "flex", flexGrow: 1, p: 3, height: '100%', flexDirection: 'column', alignItems: 'stretch', position: "relative" }}> {ai && <AIBanner />}
{ai && <AIBanner />}
<Box sx={{ display: "flex", flexDirection: "row" }}>
<Grid container spacing={2}> <Avatar
<Grid src={candidate.profileImage ? `/api/1.0/candidates/profile/${candidate.username}` : ''}
size={{ xs: 12, sm: 2 }} alt={`${candidate.fullName}'s profile`}
sx={{
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
minWidth: "80px",
maxWidth: "80px"
}}>
<Avatar
src={candidate.profileImage ? `/api/1.0/candidates/profile/${candidate.username}` : ''}
alt={`${candidate.fullName}'s profile`}
sx={{ sx={{
alignSelf: "flex-start", alignSelf: "flex-start",
width: 80, width: 80,
height: 80, height: 80,
border: '2px solid #e0e0e0', border: '2px solid #e0e0e0',
}} }}
/> />
</Grid>
{isAdmin && ai &&
<DeleteConfirmation
onDelete={() => { deleteCandidate(candidate.id); }}
sx={{ minWidth: 'auto', px: 2, maxHeight: "min-content", color: "red" }}
action="delete"
label="user"
title="Delete AI user"
icon=<DeleteIcon />
message={`Are you sure you want to delete ${candidate.username}? This action cannot be undone.`}
/>}
<Grid size={{ xs: 12, sm: 10 }}> <Box sx={{ ml: 1 }}>
<Box <Box
sx={{ sx={{
display: 'flex', display: 'flex',
justifyContent: 'space-between', justifyContent: 'space-between',
alignItems: 'flex-start', alignItems: 'flex-start',
mb: 1 }}> mb: 1
}}>
<Box> <Box>
<Box sx={{ <Box sx={{
display: "flex", display: "flex",
flexDirection: isMobile ? "column" : "row", flexDirection: isMobile ? "column" : "row",
alignItems: "left", alignItems: "left",
gap: 1, "& > .MuiTypography-root": { m: 0 } gap: 1, "& > .MuiTypography-root": { m: 0 }
}}> }}>
{ {
action !== '' && action !== '' &&
<Typography variant="body1">{action}</Typography> <Typography variant="body1">{action}</Typography>
} }
<Typography variant="h5" component="h1" {action === '' &&
sx={{ <Typography variant="h5" component="h1"
fontWeight: 'bold', sx={{
whiteSpace: 'nowrap' fontWeight: 'bold',
whiteSpace: 'nowrap'
}}> }}>
{candidate.fullName} {candidate.fullName}
</Typography> </Typography>
}
</Box> </Box>
<Box sx={{ fontSize: "0.75rem", alignItems: "center" }} > <Box sx={{ fontSize: "0.75rem", alignItems: "center" }} >
<Link href={`/u/${candidate.username}`}>{`/u/${candidate.username}`}</Link> <Link href={`/u/${candidate.username}`}>{`/u/${candidate.username}`}</Link>
<CopyBubble <CopyBubble
onClick={(event: any) => { event.stopPropagation() }} onClick={(event: any) => { event.stopPropagation() }}
tooltip="Copy link" content={`${window.location.origin}/u/{candidate.username}`} /> tooltip="Copy link" content={`${window.location.origin}/u/{candidate.username}`} />
{isAdmin && ai &&
<DeleteConfirmation
onDelete={() => { deleteCandidate(candidate.id); }}
sx={{ minWidth: 'auto', px: 2, maxHeight: "min-content", color: "red" }}
action="delete"
label="user"
title="Delete AI user"
icon=<DeleteIcon />
message={`Are you sure you want to delete ${candidate.username}? This action cannot be undone.`}
/>}
</Box> </Box>
</Box> </Box>
</Box> </Box>
<Typography variant="body1" color="text.secondary"> <Typography variant="body1" color="text.secondary">
{candidate.description} {candidate.description}
</Typography> </Typography>
{variant !== "small" && <> {variant !== "small" && <>
<Divider sx={{ my: 2 }} /> <Divider sx={{ my: 2 }} />
{candidate.location && {candidate.location &&
<Typography variant="body2" sx={{ mb: 1 }}> <Typography variant="body2" sx={{ mb: 1 }}>
<strong>Location:</strong> {candidate.location.city}, {candidate.location.state || candidate.location.country} <strong>Location:</strong> {candidate.location.city}, {candidate.location.state || candidate.location.country}
</Typography>
}
{candidate.email &&
<Typography variant="body2" sx={{ mb: 1 }}>
<strong>Email:</strong> {candidate.email}
</Typography>
}
{candidate.phone && <Typography variant="body2">
<strong>Phone:</strong> {candidate.phone}
</Typography> </Typography>
} }
</>} {candidate.email &&
</Grid> <Typography variant="body2" sx={{ mb: 1 }}>
</Grid> <strong>Email:</strong> {candidate.email}
</CardContent> </Typography>
</Card> }
{candidate.phone && <Typography variant="body2">
<strong>Phone:</strong> {candidate.phone}
</Typography>
}
</>}
</Box>
</Box>
</Box>
); );
}; };

View File

@ -5,16 +5,16 @@ import Box from '@mui/material/Box';
import { BackstoryElementProps } from 'components/BackstoryTab'; import { BackstoryElementProps } from 'components/BackstoryTab';
import { CandidateInfo } from 'components/ui/CandidateInfo'; import { CandidateInfo } from 'components/ui/CandidateInfo';
import { Candidate } from "types/types"; import { Candidate, CandidateAI } from "types/types";
import { useAuth } from 'hooks/AuthContext'; import { useAuth } from 'hooks/AuthContext';
import { useAppState, useSelectedCandidate } from 'hooks/GlobalContext'; import { useAppState, useSelectedCandidate } from 'hooks/GlobalContext';
interface CandidatePickerProps extends BackstoryElementProps { interface CandidatePickerProps extends BackstoryElementProps {
onSelect?: (candidate: Candidate) => void onSelect?: (candidate: Candidate) => void;
}; };
const CandidatePicker = (props: CandidatePickerProps) => { const CandidatePicker = (props: CandidatePickerProps) => {
const { onSelect } = props; const { onSelect, sx } = props;
const { apiClient, user } = useAuth(); const { apiClient, user } = useAuth();
const { selectedCandidate, setSelectedCandidate } = useSelectedCandidate(); const { selectedCandidate, setSelectedCandidate } = useSelectedCandidate();
const navigate = useNavigate(); const navigate = useNavigate();
@ -30,7 +30,12 @@ const CandidatePicker = (props: CandidatePickerProps) => {
const results = await apiClient.getCandidates(); const results = await apiClient.getCandidates();
const candidates: Candidate[] = results.data; const candidates: Candidate[] = results.data;
candidates.sort((a, b) => { candidates.sort((a, b) => {
let result = a.lastName.localeCompare(b.lastName); const aIsAi = 'isAI' in a ? 1 : 0;
const bIsAi = 'isAI' in b ? 1 : 0;
let result = aIsAi - bIsAi;
if (result === 0) {
result = a.lastName.localeCompare(b.lastName);
}
if (result === 0) { if (result === 0) {
result = a.firstName.localeCompare(b.firstName); result = a.firstName.localeCompare(b.firstName);
} }
@ -49,7 +54,7 @@ const CandidatePicker = (props: CandidatePickerProps) => {
}, [candidates, setSnack]); }, [candidates, setSnack]);
return ( return (
<Box sx={{display: "flex", flexDirection: "column"}}> <Box sx={{ display: "flex", flexDirection: "column", ...sx }}>
<Box sx={{ display: "flex", gap: 1, flexWrap: "wrap", justifyContent: "center" }}> <Box sx={{ display: "flex", gap: 1, flexWrap: "wrap", justifyContent: "center" }}>
{candidates?.map((u, i) => {candidates?.map((u, i) =>
<Box key={`${u.username}`} <Box key={`${u.username}`}

View File

@ -59,7 +59,7 @@ const JobInfo: React.FC<JobInfoProps> = (props: JobInfoProps) => {
} }
if (!job) { if (!job) {
return <Box>No user loaded.</Box>; return <Box>No job provided.</Box>;
} }
const handleSave = async () => { const handleSave = async () => {
@ -191,8 +191,7 @@ const JobInfo: React.FC<JobInfoProps> = (props: JobInfoProps) => {
}; };
return ( return (
<Card <Box
elevation={elevation}
sx={{ sx={{
display: "flex", display: "flex",
borderColor: 'transparent', borderColor: 'transparent',
@ -204,26 +203,37 @@ const JobInfo: React.FC<JobInfoProps> = (props: JobInfoProps) => {
}} }}
{...rest} {...rest}
> >
<CardContent sx={{ display: "flex", flexGrow: 1, p: 3, height: '100%', flexDirection: 'column', alignItems: 'stretch', position: "relative" }}> <Box sx={{ display: "flex", flexGrow: 1, p: 3, height: '100%', flexDirection: 'column', alignItems: 'stretch', position: "relative" }}>
<Box sx={{
display: "flex", flexDirection: (isMobile || variant !== "small") ? "column" : "row",
"& > div > div > :first-of-type": { fontWeight: "bold" },
"& > div > div > :last-of-type": { mb: 0.75, mr: 1 }
}}>
<Box sx={{ display: "flex", flexDirection: "column", flexGrow: 1 }}>
{
activeJob.title && <Box sx={{ fontSize: "0.8rem" }}>
<Box>Title</Box>
<Box>{activeJob.title}</Box>
</Box>
}
{activeJob.company && <Box sx={{ fontSize: "0.8rem" }}>
<Box>Company</Box>
<Box>{activeJob.company}</Box>
</Box>}
</Box>
<Box sx={{ display: "flex", flexDirection: "column", width: "75%" }}>
{activeJob.summary && <Box sx={{ fontSize: "0.8rem" }}>
<Box>Summary</Box>
<Box>{activeJob.summary}</Box>
</Box>}
</Box>
</Box>
{variant !== "small" && <> {variant !== "small" && <>
{activeJob.details && {activeJob.details &&
<Typography variant="body2" sx={{ mb: 1 }}> <Typography variant="body2" sx={{ mb: 1 }}>
<strong>Location:</strong> {activeJob.details.location.city}, {activeJob.details.location.state || activeJob.details.location.country} <strong>Location:</strong> {activeJob.details.location.city}, {activeJob.details.location.state || activeJob.details.location.country}
</Typography> </Typography>
}
{activeJob.title &&
<Typography variant="body2" sx={{ mb: 1 }}>
<strong>Title:</strong> {activeJob.title}
</Typography>
}
{activeJob.company &&
<Typography variant="body2" sx={{ mb: 1 }}>
<strong>Company:</strong> {activeJob.company}
</Typography>
}
{activeJob.summary && <Typography variant="body2">
<strong>Summary:</strong> {activeJob.summary}
</Typography>
} }
{activeJob.owner && <Typography variant="body2"> {activeJob.owner && <Typography variant="body2">
<strong>Created by:</strong> {activeJob.owner.fullName} <strong>Created by:</strong> {activeJob.owner.fullName}
@ -237,12 +247,11 @@ const JobInfo: React.FC<JobInfoProps> = (props: JobInfoProps) => {
<Typography variant="caption">Job ID: {job.id}</Typography> <Typography variant="caption">Job ID: {job.id}</Typography>
</>} </>}
<Divider /> {variant !== 'small' && <><Divider />{renderJobRequirements()}</>}
{renderJobRequirements()}
</CardContent> </Box >
{isAdmin && {isAdmin &&
<CardActions sx={{ display: "flex", flexDirection: "column", p: 1 }}> <Box sx={{ display: "flex", flexDirection: "column", p: 1 }}>
<Box sx={{ display: "flex", flexDirection: "row", pl: 1, pr: 1, gap: 1, alignContent: "center", height: "32px" }}> <Box sx={{ display: "flex", flexDirection: "row", pl: 1, pr: 1, gap: 1, alignContent: "center", height: "32px" }}>
{(job.updatedAt && job.updatedAt.toISOString()) !== (activeJob.updatedAt && activeJob.updatedAt.toISOString()) && {(job.updatedAt && job.updatedAt.toISOString()) !== (activeJob.updatedAt && activeJob.updatedAt.toISOString()) &&
<Tooltip title="Save Job"> <Tooltip title="Save Job">
@ -290,9 +299,9 @@ const JobInfo: React.FC<JobInfoProps> = (props: JobInfoProps) => {
{adminStatus && <LinearProgress sx={{ mt: 1 }} />} {adminStatus && <LinearProgress sx={{ mt: 1 }} />}
</Box> </Box>
} }
</CardActions> </Box>
} }
</Card> </Box >
); );
}; };

View File

@ -24,6 +24,7 @@ import PropagateLoader from 'react-spinners/PropagateLoader';
import { BackstoryTextField, BackstoryTextFieldRef } from 'components/BackstoryTextField'; import { BackstoryTextField, BackstoryTextFieldRef } from 'components/BackstoryTextField';
import { BackstoryQuery } from 'components/BackstoryQuery'; import { BackstoryQuery } from 'components/BackstoryQuery';
import { CandidatePicker } from 'components/ui/CandidatePicker'; import { CandidatePicker } from 'components/ui/CandidatePicker';
import { Scrollable } from 'components/Scrollable';
const defaultMessage: ChatMessage = { const defaultMessage: ChatMessage = {
status: "done", type: "text", sessionId: "", timestamp: new Date(), content: "", role: "user", metadata: null as any status: "done", type: "text", sessionId: "", timestamp: new Date(), content: "", role: "user", metadata: null as any
@ -186,55 +187,44 @@ const CandidateChatPage = forwardRef<ConversationHandle, BackstoryPageProps>((pr
}; };
return ( return (
<Box ref={ref} sx={{ <Box ref={ref}
width: "100%", sx={{
display: "flex", display: "flex", flexDirection: "column",
flexDirection: "column", height: "100%", /* Restrict to main-container's height */
gap: 1, width: "100%",
}}> minHeight: 0,/* Prevent flex overflow */
maxHeight: "min-content",
"& > *:not(.Scrollable)": {
flexShrink: 0, /* Prevent shrinking */
},
position: "relative",
}}>
<Paper elevation={2} sx={{ m: 1, p: 1 }}>
<CandidateInfo <CandidateInfo
key={selectedCandidate.username} key={selectedCandidate.username}
action={`Chat with Backstory about ${selectedCandidate.firstName}`} action={`Chat with Backstory about ${selectedCandidate.firstName}`}
elevation={4} elevation={4}
candidate={selectedCandidate} candidate={selectedCandidate}
variant="small" variant="small"
sx={{ flexShrink: 0 }} // Prevent header from shrinking sx={{ flexShrink: 1, width: "100%", maxHeight: 0, minHeight: "min-content" }} // Prevent header from shrinking
/> />
<Button onClick={() => { setSelectedCandidate(null); }} variant="contained">Change Candidates</Button> <Button sx={{ maxWidth: "max-content" }} onClick={() => { setSelectedCandidate(null); }} variant="contained">Change Candidates</Button>
</Paper>
{/* Chat Interface */} {/* Chat Interface */}
<Paper {/* Scrollable Messages Area */}
sx={{ {chatSession &&
display: 'flex', <Scrollable
flexDirection: 'column', sx={{
flexGrow: 1, position: "relative",
width: '100%', maxHeight: "100%",
minHeight: 'max-content' width: "100%",
}} display: "flex", flexGrow: 1,
> flex: 1, /* Take remaining space in some-container */
{/* Scrollable Messages Area */} overflowY: "auto", /* Scroll if content overflows */
{chatSession && <> pt: 2,
<Box sx={{ pl: 1,
flexGrow: 1, pr: 1,
p: 2, pb: 2,
display: 'flex',
flexDirection: 'column',
minHeight: 'max-content', // Important for flex child
// Custom scrollbar styling
'&::-webkit-scrollbar': {
width: '8px',
},
'&::-webkit-scrollbar-track': {
backgroundColor: theme.palette.grey[100],
borderRadius: '4px',
},
'&::-webkit-scrollbar-thumb': {
backgroundColor: theme.palette.grey[400],
borderRadius: '4px',
'&:hover': {
backgroundColor: theme.palette.grey[600],
},
},
}}> }}>
{messages.length === 0 && <Message {...{ chatSession, message: welcomeMessage, }} />} {messages.length === 0 && <Message {...{ chatSession, message: welcomeMessage, }} />}
{messages.map((message: ChatMessage) => ( {messages.map((message: ChatMessage) => (
@ -263,12 +253,11 @@ const CandidateChatPage = forwardRef<ConversationHandle, BackstoryPageProps>((pr
</Box> </Box>
)} )}
<div ref={messagesEndRef} /> <div ref={messagesEndRef} />
</Box> </Scrollable>
</>} }
</Paper>
{selectedCandidate.questions?.length !== 0 && selectedCandidate.questions?.map(q => <BackstoryQuery question={q} />)} {selectedCandidate.questions?.length !== 0 && selectedCandidate.questions?.map(q => <BackstoryQuery question={q} />)}
{/* Fixed Message Input */} {/* Fixed Message Input */}
<Box sx={{ display: 'flex', flexShrink: 0, gap: 1 }}> <Box sx={{ display: 'flex', flexShrink: 1, gap: 1 }}>
<DeleteConfirmation <DeleteConfirmation
onDelete={() => { chatSession && onDelete(chatSession); }} onDelete={() => { chatSession && onDelete(chatSession); }}
disabled={!chatSession} disabled={!chatSession}

View File

@ -1,22 +1,61 @@
import React from 'react'; import React from 'react';
import { Box, Paper, Typography } from '@mui/material'; import { Box, Paper, Typography } from '@mui/material';
import { BackstoryLogo } from 'components/ui/BackstoryLogo'; import { BackstoryLogo } from 'components/ui/BackstoryLogo';
import { StyledMarkdown } from 'components/StyledMarkdown';
const HowItWorks = () => { const HowItWorks = () => {
const content = `\
Welcome to the Backstory Beta!
Here are your steps from zero-to-hero to see Backstory in action.
![select-job-analysis](/select-job-analysis.png)
Select 'Job Analysis' from the menu. This takes you to the interactive Job Analysis page,
where you will get to evaluate a candidate for a selected job.
Once on the Job Analysis Page, explore a little bit and then select one of the jobs. The
requirements and information provided on Backstory are extracted from job postings that users have pasted as a
job description or uploaded from a PDF. You can create your own job postings once you create an account. Until then,
you need to select one that already exists.
![select-a-job](/select-a-job.png)
Now that you have a Job selected, you need to select a candidate. In addition to myself (James), there are several
candidates which AI has generated. Each has a unique skillset and can be used to test out the system. If you create an account,
you can opt-in to have your account show up for others to view as well, or keep it private for just your own resume
generation and job research.
![select-a-candidate](/select-a-candidate.png)
After selecting a candidate, you are ready to have Backstory perform the Job Analysis. During this phase, Backstory will
take each of requirements that were extracted from the Job and match it against any information available about the selected candidate.
This could be as little as a simple resume, or as complete as a full work history. Backstory performs similarity searches to
identify key elements from the candidate that pertain to a given skill and provides a graded response.
![select-start-analysis](/select-start-analysis.png)
To see that in action, click the "Start Skill Assessment". Once you begin that action, the Start Skill Assessment button will grey out and
the page will begin updating as it discovers information about the candidate. As it does its thing, you can monitor the progress and explore the different
identified skills to see how or why a candidate does or does not have that skill.
![Wait](/wait.png)
Once it is done, you can see the final Overall Match. This is a weighted score based on amount of evidence a skill had, whether the skill was required or preferred, and other metrics.
The final step is creating the custom resume for the Candidate tailored to the particular Job. On the bottom right you can click "Next" to have Backstory
generate the custom resume.
![final-resume](/final-resume.png)
Note that the resume focuses on identifying key areas from the Candidate's work history that align with skills which were extracted from the original job posting.
You can then click the Copy button to copy the resume into your editor, adjust, and apply for your dream job!
`;
return (<Paper sx={{ m: 1, p: 1 }}> return (<Paper sx={{ m: 1, p: 1 }}>
<Box sx={{ display: "flex", flexDirection: "column" }}> <Box sx={{ display: "flex", flexDirection: "column" }}>
<Box sx={{ display: "flex", alignContent: "center", verticalAlign: "center", flexDirection: "row" }}> <StyledMarkdown content={content} />
<Typography>Job Description </Typography><BackstoryLogo /><Typography> (Company Info, Job Summary, Job Requirements) <strong>Job</strong></Typography>
</Box>
<Box sx={{ display: "flex", alignContent: "center", verticalAlign: "center", flexDirection: "row" }}>
<Typography>User Content </Typography><BackstoryLogo /><Typography> RAG Vector Database <strong>Candidate</strong></Typography>
</Box>
<Box sx={{ display: "flex", alignContent: "center", verticalAlign: "center", flexDirection: "row" }}>
<Typography><strong>Job</strong> + <strong>Candidate</strong> </Typography><BackstoryLogo /><Typography> <strong>Skill Match</strong></Typography>
</Box>
<Box sx={{ display: "flex", alignContent: "center", verticalAlign: "center", flexDirection: "row" }}>
<Typography><strong>Skill Match</strong> + <strong>Candidate</strong> </Typography><BackstoryLogo /><Typography> <strong>Resume</strong></Typography>
</Box>
</Box> </Box>
</Paper>); </Paper>);
} }

View File

@ -1,4 +1,4 @@
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect, useCallback, useRef } from 'react';
import { import {
Box, Box,
Stepper, Stepper,
@ -13,6 +13,8 @@ import {
Tabs, Tabs,
Tab, Tab,
Avatar, Avatar,
useMediaQuery,
Divider,
} from '@mui/material'; } from '@mui/material';
import { import {
Add, Add,
@ -37,6 +39,7 @@ import { JobCreator } from 'components/JobCreator';
import { LoginRestricted } from 'components/ui/LoginRestricted'; import { LoginRestricted } from 'components/ui/LoginRestricted';
import JsonView from '@uiw/react-json-view'; import JsonView from '@uiw/react-json-view';
import { ResumeGenerator } from 'components/ResumeGenerator'; import { ResumeGenerator } from 'components/ResumeGenerator';
import { JobInfo } from 'components/ui/JobInfo';
function WorkAddIcon() { function WorkAddIcon() {
return ( return (
@ -63,108 +66,153 @@ function WorkAddIcon() {
); );
} }
interface AnalysisState {
job: Job | null;
candidate: Candidate | null;
analysis: SkillAssessment[] | null;
resume: string | null;
};
interface Step {
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: Step[] = [
{ 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) => {
return str.charAt(0).toUpperCase() + str.slice(1);
}
// Main component // Main component
const JobAnalysisPage: React.FC<BackstoryPageProps> = (props: BackstoryPageProps) => { const JobAnalysisPage: React.FC<BackstoryPageProps> = (props: BackstoryPageProps) => {
const theme = useTheme(); const theme = useTheme();
const { user, guest } = useAuth(); const { user, guest } = useAuth();
const { selectedCandidate, setSelectedCandidate } = useSelectedCandidate() const { selectedCandidate, setSelectedCandidate } = useSelectedCandidate();
const { selectedJob, setSelectedJob } = useSelectedJob(); const { selectedJob, setSelectedJob } = useSelectedJob();
// State management
const [activeStep, setActiveStep] = useState(0); const [activeStep, setActiveStep] = useState<Step>(steps[0]);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [jobTab, setJobTab] = useState<string>('load'); const [jobTab, setJobTab] = useState<string>('select');
const [skills, setSkills] = useState<SkillAssessment[] | null>(null) 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: Step) => {
if (!analysisState) {
return;
}
const missing = step.requiredState.find(f => !(analysisState as any)[f])
return missing;
}, [analysisState]);
useEffect(() => { useEffect(() => {
if (!selectedCandidate) { if (analysisState !== null) {
if (activeStep !== 0) { return;
setActiveStep(0); }
}
} else if (!selectedJob) { const analysis = { ...initialState, candidate: selectedCandidate, job: selectedJob }
if (activeStep !== 1) { setAnalysisState(analysis);
setActiveStep(1); for (let i = steps.length - 1; i >= 0; i--) {
const missing = steps[i].requiredState.find(f => !(analysis as any)[f])
if (!missing) {
setActiveStep(steps[i]);
return;
} }
} }
}, [selectedCandidate, selectedJob, activeStep]) }, [analysisState, selectedCandidate, selectedJob, setActiveStep, canAccessStep]);
// Steps in our process useEffect(() => {
const steps = [ if (activeStep.index === steps.length - 1) {
{ index: 0, label: 'Select Candidate', icon: <PersonIcon /> }, setCanAdvance(false);
{ index: 1, label: 'Job Selection', icon: <WorkIcon /> }, return;
{ index: 2, label: 'Job Analysis', icon: <WorkIcon /> }, }
{ index: 3, label: 'Generated Resume', icon: <AssessmentIcon /> } 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]);
// Navigation handlers
const handleNext = () => { const handleNext = () => {
if (activeStep === 0 && !selectedCandidate) { if (activeStep.index === steps.length - 1) {
setError('Please select a candidate before continuing.');
return; return;
} }
const missing = canAccessStep(steps[activeStep.index + 1]);
if (activeStep === 1 && !selectedJob) { if (missing) {
setError('Please select a job before continuing.'); setError(`${capitalize(missing)} is necessary before continuing.`);
return; return missing;
} }
if (activeStep === 2 && !skills) { if (activeStep.index < steps.length - 1) {
setError('Skill assessment must be complete before continuing.'); setActiveStep((prevActiveStep) => steps[prevActiveStep.index + 1]);
return;
} }
setActiveStep((prevActiveStep) => prevActiveStep + 1);
}; };
const handleBack = () => { const handleBack = () => {
console.log(activeStep); if (activeStep.index === 0) {
if (activeStep === 1) { return;
setSelectedCandidate(null);
} }
if (activeStep === 2) {
setSelectedJob(null); setActiveStep((prevActiveStep) => steps[prevActiveStep.index - 1]);
}
setActiveStep((prevActiveStep) => prevActiveStep - 1);
}; };
const moveToStep = (step: number) => { const moveToStep = (step: number) => {
console.log(`Move to ${step}`) const missing = canAccessStep(steps[step]);
switch (step) { if (missing) {
case 0: /* Select candidate */ setError(`${capitalize(missing)} is needed to access this step.`);
setSelectedCandidate(null); return;
setSelectedJob(null);
setSkills(null);
break;
case 1: /* Select Job */
setSelectedJob(null);
setSkills(null);
break;
case 2: /* Job Analysis */
setSkills(null);
break;
case 3: /* Generate Resume */
break;
} }
setActiveStep(step); setActiveStep(steps[step]);
} }
const onCandidateSelect = (candidate: Candidate) => { const onCandidateSelect = (candidate: Candidate) => {
if (!analysisState) {
return;
}
analysisState.candidate = candidate;
setAnalysisState({ ...analysisState });
setSelectedCandidate(candidate); setSelectedCandidate(candidate);
setActiveStep(1); handleNext();
} }
const onJobSelect = (job: Job) => { const onJobSelect = (job: Job) => {
setSelectedJob(job) if (!analysisState) {
setActiveStep(2); return;
}
analysisState.job = job;
setAnalysisState({ ...analysisState });
setSelectedJob(job);
handleNext();
} }
// Render function for the candidate selection step // Render function for the candidate selection step
const renderCandidateSelection = () => ( const renderCandidateSelection = () => (
<Paper elevation={3} sx={{ p: 3, mt: 3, mb: 4, borderRadius: 2 }}> <CandidatePicker sx={{ pt: 1 }} onSelect={onCandidateSelect} />
<Typography variant="h5" gutterBottom>
Select a Candidate
</Typography>
<CandidatePicker onSelect={onCandidateSelect} />
</Paper>
); );
const handleTabChange = (event: React.SyntheticEvent, value: string) => { const handleTabChange = (event: React.SyntheticEvent, value: string) => {
@ -173,19 +221,15 @@ const JobAnalysisPage: React.FC<BackstoryPageProps> = (props: BackstoryPageProps
// Render function for the job description step // Render function for the job description step
const renderJobDescription = () => { const renderJobDescription = () => {
if (!selectedCandidate) {
return;
}
return (<Box sx={{ mt: 3 }}> return (<Box sx={{ mt: 3 }}>
<Box sx={{ borderBottom: 1, borderColor: 'divider', mb: 3 }}> <Box sx={{ borderBottom: 1, borderColor: 'divider', mb: 3 }}>
<Tabs value={jobTab} onChange={handleTabChange} centered> <Tabs value={jobTab} onChange={handleTabChange} centered>
<Tab value='load' icon={<WorkOutline />} label="Load" /> <Tab value='select' icon={<WorkOutline />} label="Select Job" />
<Tab value='create' icon={<WorkAddIcon />} label="Create" /> <Tab value='create' icon={<WorkAddIcon />} label="Create Job" />
</Tabs> </Tabs>
</Box> </Box>
{jobTab === 'load' && {jobTab === 'select' &&
<JobPicker onSelect={onJobSelect} /> <JobPicker onSelect={onJobSelect} />
} }
{jobTab === 'create' && user && {jobTab === 'create' && user &&
@ -201,32 +245,47 @@ const JobAnalysisPage: React.FC<BackstoryPageProps> = (props: BackstoryPageProps
} }
const onAnalysisComplete = (skills: SkillAssessment[]) => { const onAnalysisComplete = (skills: SkillAssessment[]) => {
setSkills(skills); if (!analysisState) {
return;
}
analysisState.analysis = skills;
setAnalysisState({ ...analysisState });
}; };
// Render function for the analysis step // Render function for the analysis step
const renderAnalysis = () => ( const renderAnalysis = () => {
<Box sx={{ mt: 3 }}> if (!analysisState) {
{selectedCandidate && selectedJob && ( return;
<JobMatchAnalysis }
job={selectedJob} if (!analysisState.job || !analysisState.candidate) {
candidate={selectedCandidate} return <Box>{JSON.stringify({ job: analysisState.job, candidate: analysisState.candidate })}</Box>
onAnalysisComplete={onAnalysisComplete} }
/> return (<Box sx={{ mt: 3 }}>
)} <JobMatchAnalysis
</Box> variant="small"
); job={analysisState.job}
candidate={analysisState.candidate}
onAnalysisComplete={onAnalysisComplete}
/>
</Box>);
};
const renderResume = () => ( const renderResume = () => {
<Box sx={{ mt: 3 }}> if (!analysisState) {
{skills && selectedCandidate && selectedJob && return;
}
if (!analysisState.job || !analysisState.candidate || !analysisState.analysis) {
return <></>;
}
return (<Box sx={{ mt: 3 }}>
<ResumeGenerator <ResumeGenerator
job={selectedJob} job={analysisState.job}
candidate={selectedCandidate} candidate={analysisState.candidate}
skills={skills} skills={analysisState.analysis}
/>} />
</Box> </Box>);
); };
return ( return (
<Box sx={{ <Box sx={{
@ -240,8 +299,48 @@ const JobAnalysisPage: React.FC<BackstoryPageProps> = (props: BackstoryPageProps
}, },
position: "relative", position: "relative",
}}> }}>
<Paper elevation={4} sx={{ m: 0, borderRadius: 0, mb: 1, p: 0 }}>{selectedCandidate && <CandidateInfo variant="small" candidate={selectedCandidate} sx={{ width: "100%" }} />}</Paper> <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>
<StepLabel sx={{ cursor: "pointer" }} onClick={() => { moveToStep(index); }}
slots={{
stepIcon: () => (
<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: "column", width: "100%" }}>
<Box sx={{ ml: 3, fontWeight: "bold" }}>Selected Job</Box>
<JobInfo variant="small" job={analysisState.job} />
</Box>
}
{isMobile && <Box sx={{ display: "flex", borderBottom: "1px solid grey" }} />}
{!isMobile && <Box sx={{ display: "flex", borderLeft: "1px solid lightgrey" }} />}
{analysisState && analysisState.candidate &&
<Box sx={{ display: "flex", flexDirection: "column", width: "100%" }}>
<Box sx={{ ml: 3, fontWeight: "bold" }}>Selected Candidate</Box>
<CandidateInfo variant="small" candidate={analysisState.candidate} sx={{}} />
</Box>
}
</Box>
</Paper>
<Scrollable <Scrollable
ref={scrollRef}
sx={{ sx={{
position: "relative", position: "relative",
maxHeight: "100%", maxHeight: "100%",
@ -250,46 +349,16 @@ const JobAnalysisPage: React.FC<BackstoryPageProps> = (props: BackstoryPageProps
flex: 1, /* Take remaining space in some-container */ flex: 1, /* Take remaining space in some-container */
overflowY: "auto", /* Scroll if content overflows */ overflowY: "auto", /* Scroll if content overflows */
}}> }}>
<Box sx={{ display: "flex", justifyContent: "center" }}> {activeStep.label === 'job-selection' && renderJobDescription()}
<Typography variant="subtitle1" color="text.secondary" gutterBottom> {activeStep.label === 'select-candidate' && renderCandidateSelection()}
Match candidates to job requirements with AI-powered analysis {activeStep.label === 'job-analysis' && renderAnalysis()}
</Typography> {activeStep.label === 'generated-resume' && renderResume()}
</Box>
<Box sx={{ mt: 4, mb: 4 }}>
<Stepper activeStep={activeStep} alternativeLabel>
{steps.map((step, index) => (
<Step>
<StepLabel sx={{ cursor: "pointer" }} onClick={() => { moveToStep(index); }}
slots={{
stepIcon: () => (
<Avatar key={step.index}
sx={{
bgcolor: activeStep >= step.index ? theme.palette.primary.main : theme.palette.grey[300],
color: 'white'
}}
>
{step.icon}
</Avatar>
)
}}
>
{step.label}
</StepLabel>
</Step>
))}
</Stepper>
</Box>
{activeStep === 0 && renderCandidateSelection()}
{activeStep === 1 && renderJobDescription()}
{activeStep === 2 && renderAnalysis()}
{activeStep === 3 && renderResume()}
</Scrollable> </Scrollable>
<Box sx={{ display: 'flex', flexDirection: 'row', pt: 2 }}> <Box sx={{ display: 'flex', flexDirection: 'row', pt: 2 }}>
<Button <Button
color="inherit" color="inherit"
disabled={activeStep === steps[0].index} disabled={activeStep.index === steps[0].index}
onClick={handleBack} onClick={handleBack}
sx={{ mr: 1 }} sx={{ mr: 1 }}
> >
@ -297,13 +366,13 @@ const JobAnalysisPage: React.FC<BackstoryPageProps> = (props: BackstoryPageProps
</Button> </Button>
<Box sx={{ flex: '1 1 auto' }} /> <Box sx={{ flex: '1 1 auto' }} />
{activeStep === steps[steps.length - 1].index ? ( {activeStep.index === steps[steps.length - 1].index ? (
<Button onClick={() => { moveToStep(0) }} variant="outlined"> <Button disabled={!canAdvance} onClick={() => { moveToStep(0) }} variant="outlined">
Start New Analysis Start New Analysis
</Button> </Button>
) : ( ) : (
<Button onClick={handleNext} variant="contained"> <Button disabled={!canAdvance} onClick={handleNext} variant="contained">
{activeStep === steps.length - 1 ? 'Done' : 'Next'} {activeStep.index === steps.length - 1 ? 'Done' : 'Next'}
</Button> </Button>
)} )}
</Box> </Box>