backstory/frontend/src/components/JobCreator.tsx

560 lines
17 KiB
TypeScript

import React, { useState, useRef, JSX } from 'react';
import {
Box,
Button,
Typography,
TextField,
Grid,
useTheme,
useMediaQuery,
Chip,
Card,
CardContent,
CardHeader,
LinearProgress,
Stack,
} from '@mui/material';
import {
AutoFixHigh,
Psychology,
Build,
CloudUpload,
Description,
Business,
Work,
CheckCircle,
Star,
} from '@mui/icons-material';
import { styled } from '@mui/material/styles';
import FileUploadIcon from '@mui/icons-material/FileUpload';
import { useAuth } from 'hooks/AuthContext';
import { useAppState } from 'hooks/GlobalContext';
import { BackstoryElementProps } from './BackstoryTab';
import * as Types from 'types/types';
import { StyledMarkdown } from './StyledMarkdown';
import { JobInfo } from './ui/JobInfo';
import { Scrollable } from './Scrollable';
import { StatusIcon, StatusBox } from 'components/ui/StatusIcon';
const VisuallyHiddenInput = styled('input')({
clip: 'rect(0 0 0 0)',
clipPath: 'inset(50%)',
height: 1,
overflow: 'hidden',
position: 'absolute',
bottom: 0,
left: 0,
whiteSpace: 'nowrap',
width: 1,
});
const UploadBox = styled(Box)(({ theme }) => ({
border: `2px dashed ${theme.palette.primary.main}`,
borderRadius:
(typeof theme.shape.borderRadius === 'string'
? parseInt(theme.shape.borderRadius)
: theme.shape.borderRadius) * 2,
padding: theme.spacing(4),
textAlign: 'center',
backgroundColor: theme.palette.action.hover,
transition: 'all 0.3s ease',
cursor: 'pointer',
'&:hover': {
backgroundColor: theme.palette.action.selected,
borderColor: theme.palette.primary.dark,
},
}));
interface JobCreatorProps extends BackstoryElementProps {
onSave?: (job: Types.Job) => void;
}
const JobCreator = (props: JobCreatorProps): JSX.Element => {
const { user, apiClient } = useAuth();
const { onSave } = props;
const { setSnack } = useAppState();
const theme = useTheme();
const isMobile = useMediaQuery(theme.breakpoints.down('sm'));
const [jobDescription, setJobDescription] = useState<string>('');
const [jobRequirements, setJobRequirements] = useState<Types.JobRequirements | null>(null);
const [jobTitle, setJobTitle] = useState<string>('');
const [company, setCompany] = useState<string>('');
const [summary, setSummary] = useState<string>('');
const [job, setJob] = useState<Types.Job | null>(null);
const [jobStatus, setJobStatus] = useState<string>('');
const [jobStatusType, setJobStatusType] = useState<Types.ApiActivityType | null>(null);
const [isProcessing, setIsProcessing] = useState<boolean>(false);
const fileInputRef = useRef<HTMLInputElement>(null);
const jobStatusHandlers = {
onStatus: (status: Types.ChatMessageStatus): void => {
console.log('status:', status.content);
setJobStatusType(status.activity);
setJobStatus(status.content);
},
onMessage: (jobMessage: Types.JobRequirementsMessage): void => {
const job: Types.Job = jobMessage.job;
console.log('onMessage - job', job);
setJob(job);
setCompany(job.company || '');
setJobDescription(job.description);
setSummary(job.summary || '');
setJobTitle(job.title || '');
setJobRequirements(job.requirements || null);
setJobStatusType(null);
setJobStatus('');
},
onError: (error: Types.ChatMessageError): void => {
console.log('onError', error);
setSnack(error.content, 'error');
setIsProcessing(false);
},
onComplete: (): void => {
setJobStatusType(null);
setJobStatus('');
setIsProcessing(false);
},
};
const handleJobUpload = async (e: React.ChangeEvent<HTMLInputElement>): Promise<void> => {
if (e.target.files && e.target.files[0]) {
const file = e.target.files[0];
const fileExtension = '.' + file.name.split('.').pop()?.toLowerCase();
let docType: Types.DocumentType | null = null;
switch (fileExtension.substring(1)) {
case 'pdf':
docType = 'pdf';
break;
case 'docx':
docType = 'docx';
break;
case 'md':
docType = 'markdown';
break;
case 'txt':
docType = 'txt';
break;
}
if (!docType) {
setSnack('Invalid file type. Please upload .txt, .md, .docx, or .pdf files only.', 'error');
return;
}
try {
setIsProcessing(true);
setJobDescription('');
setJobTitle('');
setJobRequirements(null);
setSummary('');
const controller = apiClient.createJobFromFile(file, jobStatusHandlers);
const job = await controller.promise;
if (!job) {
return;
}
console.log(`Job id: ${job.id}`);
e.target.value = '';
} catch (error) {
console.error(error);
setSnack('Failed to upload document', 'error');
setIsProcessing(false);
}
}
};
const handleUploadClick = (): void => {
fileInputRef.current?.click();
};
const renderRequirementSection = (
title: string,
items: string[] | undefined,
icon: JSX.Element,
required = false
): JSX.Element => {
if (!items || items.length === 0) return <></>;
return (
<Box sx={{ mb: 3 }}>
<Box sx={{ display: 'flex', alignItems: 'center', mb: 1.5 }}>
{icon}
<Typography variant="subtitle1" sx={{ ml: 1, fontWeight: 600 }}>
{title}
</Typography>
{required && <Chip label="Required" size="small" color="error" sx={{ ml: 1 }} />}
</Box>
<Stack direction="row" spacing={1} flexWrap="wrap" useFlexGap>
{items.map((item, index) => (
<Chip key={index} label={item} variant="outlined" size="small" sx={{ mb: 1 }} />
))}
</Stack>
</Box>
);
};
const renderJobRequirements = (): JSX.Element => {
if (!jobRequirements) return <></>;
return (
<Card elevation={2} sx={{ mt: 3 }}>
<CardHeader
title="Job Requirements Analysis"
avatar={<CheckCircle color="success" />}
sx={{ pb: 1 }}
/>
<CardContent sx={{ pt: 0 }}>
{renderRequirementSection(
'Technical Skills (Required)',
jobRequirements.technicalSkills.required,
<Build color="primary" />,
true
)}
{renderRequirementSection(
'Technical Skills (Preferred)',
jobRequirements.technicalSkills.preferred,
<Build color="action" />
)}
{renderRequirementSection(
'Experience Requirements (Required)',
jobRequirements.experienceRequirements.required,
<Work color="primary" />,
true
)}
{renderRequirementSection(
'Experience Requirements (Preferred)',
jobRequirements.experienceRequirements.preferred,
<Work color="action" />
)}
{renderRequirementSection(
'Soft Skills',
jobRequirements.softSkills,
<Psychology color="secondary" />
)}
{renderRequirementSection(
'Experience',
jobRequirements.experience,
<Star color="warning" />
)}
{renderRequirementSection(
'Education',
jobRequirements.education,
<Description color="info" />
)}
{renderRequirementSection(
'Certifications',
jobRequirements.certifications,
<CheckCircle color="success" />
)}
{renderRequirementSection(
'Preferred Attributes',
jobRequirements.preferredAttributes,
<Star color="secondary" />
)}
</CardContent>
</Card>
);
};
const handleSave = async (): Promise<void> => {
const newJob: Types.Job = {
ownerId: user?.id || '',
ownerType: 'candidate',
description: jobDescription,
company: company,
summary: summary,
title: jobTitle,
requirements: jobRequirements || undefined,
createdAt: new Date(),
updatedAt: new Date(),
};
setIsProcessing(true);
const job = await apiClient.createJob(newJob);
setIsProcessing(false);
if (!job) {
setSnack('Failed to save job', 'error');
return;
}
onSave && onSave(job);
};
const handleExtractRequirements = async (): Promise<void> => {
try {
setIsProcessing(true);
const controller = apiClient.createJobFromDescription(jobDescription, jobStatusHandlers);
const job = await controller.promise;
if (!job) {
setIsProcessing(false);
return;
}
console.log(`Job id: ${job.id}`);
} catch (error) {
console.error(error);
setSnack('Failed to upload document', 'error');
setIsProcessing(false);
}
setIsProcessing(false);
};
const renderJobCreation = (): JSX.Element => {
return (
<Box
sx={{
width: '100%',
p: 1,
}}
>
{/* Upload Section */}
<Card elevation={3} sx={{ mb: 4 }}>
<CardHeader
title="Job Information"
subheader="Upload a job description or enter details manually"
avatar={<Work color="primary" />}
/>
<CardContent>
<Grid container spacing={3}>
<Grid size={{ xs: 12, md: 6 }}>
<Typography
variant="h6"
gutterBottom
sx={{ display: 'flex', alignItems: 'center' }}
>
<CloudUpload sx={{ mr: 1 }} />
Upload Job Description
</Typography>
<UploadBox onClick={handleUploadClick}>
<CloudUpload sx={{ fontSize: 48, color: 'primary.main', mb: 2 }} />
<Typography variant="h6" gutterBottom>
Drop your job description here
</Typography>
<Typography variant="body2" color="text.secondary" sx={{ mb: 2 }}>
Supported formats: PDF, DOCX, TXT, MD
</Typography>
<Button
variant="contained"
startIcon={<FileUploadIcon />}
disabled={isProcessing}
// onClick={handleUploadClick}
>
Choose File
</Button>
</UploadBox>
<VisuallyHiddenInput
ref={fileInputRef}
type="file"
accept=".txt,.md,.docx,.pdf"
onChange={handleJobUpload}
/>
</Grid>
<Grid size={{ xs: 12, md: 6 }}>
<Typography
variant="h6"
gutterBottom
sx={{ display: 'flex', alignItems: 'center' }}
>
<Description sx={{ mr: 1 }} />
Or Enter Manually
</Typography>
<TextField
fullWidth
multiline
rows={isMobile ? 8 : 12}
placeholder="Paste or type the job description here..."
variant="outlined"
value={jobDescription}
onChange={(e): void => {
setJobDescription(e.target.value);
}}
disabled={isProcessing}
sx={{ mb: 2 }}
/>
{jobRequirements === null && jobDescription && (
<Button
variant="outlined"
onClick={handleExtractRequirements}
startIcon={<AutoFixHigh />}
disabled={isProcessing}
fullWidth={isMobile}
>
Extract Requirements
</Button>
)}
</Grid>
</Grid>
{(jobStatus || isProcessing) && (
<Box sx={{ mt: 3 }}>
<StatusBox>
{jobStatusType && <StatusIcon type={jobStatusType} />}
<Typography variant="body2" sx={{ ml: 1 }}>
{jobStatus || 'Processing...'}
</Typography>
</StatusBox>
{isProcessing && <LinearProgress sx={{ mt: 1 }} />}
</Box>
)}
</CardContent>
</Card>
{/* Job Details Section */}
<Card elevation={3} sx={{ mb: 4 }}>
<CardHeader
title="Job Details"
subheader="Enter specific information about the position"
avatar={<Business color="primary" />}
/>
<CardContent>
<Grid container spacing={3}>
<Grid size={{ xs: 12, md: 6 }}>
<TextField
fullWidth
label="Job Title"
variant="outlined"
value={jobTitle}
onChange={(e): void => {
setJobTitle(e.target.value);
}}
required
disabled={isProcessing}
InputProps={{
startAdornment: <Work sx={{ mr: 1, color: 'text.secondary' }} />,
}}
/>
</Grid>
<Grid size={{ xs: 12, md: 6 }}>
<TextField
fullWidth
label="Company"
variant="outlined"
value={company}
onChange={(e): void => {
setCompany(e.target.value);
}}
required
disabled={isProcessing}
InputProps={{
startAdornment: <Business sx={{ mr: 1, color: 'text.secondary' }} />,
}}
/>
</Grid>
{/* <Grid size={{ xs: 12, md: 6 }}>
<TextField
fullWidth
label="Job Location"
variant="outlined"
value={jobLocation}
onChange={(e) => setJobLocation(e.target.value)}
disabled={isProcessing}
InputProps={{
startAdornment: <LocationOn sx={{ mr: 1, color: 'text.secondary' }} />
}}
/>
</Grid> */}
</Grid>
</CardContent>
</Card>
{/* Job Summary */}
{summary !== '' && (
<Card elevation={2} sx={{ mt: 3 }}>
<CardHeader
title="Job Summary"
avatar={<CheckCircle color="success" />}
sx={{ pb: 1 }}
/>
<CardContent sx={{ pt: 0 }}>{summary}</CardContent>
</Card>
)}
{/* Requirements Display */}
{renderJobRequirements()}
</Box>
);
};
return (
<Box
className="JobManagement"
sx={{
background: 'white',
p: 0,
width: '100%',
display: 'flex',
flexDirection: 'column',
}}
>
{job === null && renderJobCreation()}
{job && (
<Box
sx={{
display: 'flex',
flexDirection: 'column',
height: '100%' /* Restrict to main-container's height */,
width: '100%',
minHeight: 0 /* Prevent flex overflow */,
maxHeight: 'min-content',
position: 'relative',
}}
>
<Box
sx={{
display: 'flex',
flexDirection: 'row',
flexGrow: 1,
gap: 1,
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',
}}
>
<Scrollable
sx={{
display: 'flex',
flexGrow: 1,
position: 'relative',
maxHeight: '30rem',
}}
>
<JobInfo job={job} />
</Scrollable>
<Scrollable
sx={{
display: 'flex',
flexGrow: 1,
position: 'relative',
maxHeight: '30rem',
}}
>
<StyledMarkdown content={job.description} />
</Scrollable>
</Box>
<Box sx={{ display: 'flex', gap: 2, alignItems: 'flex-end' }}>
<Button
variant="contained"
onClick={handleSave}
disabled={!jobTitle || !company || !jobDescription || isProcessing}
fullWidth={isMobile}
size="large"
startIcon={<CheckCircle />}
>
Save Job
</Button>
</Box>
</Box>
)}
</Box>
);
};
export { JobCreator };