backstory/frontend/src/components/JobCreator.tsx
2025-06-12 16:26:39 -07:00

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 };