528 lines
17 KiB
TypeScript
528 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,
|
|
Paper,
|
|
} from '@mui/material';
|
|
import {
|
|
SyncAlt,
|
|
Favorite,
|
|
Settings,
|
|
Info,
|
|
Search,
|
|
AutoFixHigh,
|
|
Image,
|
|
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, useSelectedJob } from 'hooks/GlobalContext';
|
|
import { BackstoryElementProps } from './BackstoryTab';
|
|
import { LoginRequired } from 'components/ui/LoginRequired';
|
|
|
|
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: 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) => {
|
|
const { user, apiClient } = useAuth();
|
|
const { onSave } = props;
|
|
const { selectedJob, setSelectedJob } = useSelectedJob();
|
|
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) => {
|
|
console.log('status:', status.content);
|
|
setJobStatusType(status.activity);
|
|
setJobStatus(status.content);
|
|
},
|
|
onMessage: (jobMessage: Types.JobRequirementsMessage) => {
|
|
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) => {
|
|
console.log('onError', error);
|
|
setSnack(error.content, "error");
|
|
setIsProcessing(false);
|
|
},
|
|
onComplete: () => {
|
|
setJobStatusType(null);
|
|
setJobStatus('');
|
|
setIsProcessing(false);
|
|
}
|
|
};
|
|
|
|
const handleJobUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
|
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 = () => {
|
|
fileInputRef.current?.click();
|
|
};
|
|
|
|
const renderRequirementSection = (title: string, items: string[] | undefined, icon: JSX.Element, required = false) => {
|
|
if (!items || items.length === 0) return null;
|
|
|
|
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 = () => {
|
|
if (!jobRequirements) return null;
|
|
|
|
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 () => {
|
|
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 () => {
|
|
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 = () => {
|
|
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) => 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) => 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) => 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 }; |