560 lines
17 KiB
TypeScript
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 };
|