502 lines
16 KiB
TypeScript
502 lines
16 KiB
TypeScript
import React, { JSX, useEffect, useRef, useState } from 'react';
|
|
import {
|
|
Box,
|
|
Link,
|
|
Typography,
|
|
SxProps,
|
|
Chip,
|
|
LinearProgress,
|
|
IconButton,
|
|
Tooltip,
|
|
} from '@mui/material';
|
|
import { useTheme } from '@mui/material';
|
|
import DeleteIcon from '@mui/icons-material/Delete';
|
|
import CloseIcon from '@mui/icons-material/Close';
|
|
import { useMediaQuery } from '@mui/material';
|
|
import { Job } from 'types/types';
|
|
import { rest } from 'lodash';
|
|
import { useAuth } from 'hooks/AuthContext';
|
|
import { Build, CheckCircle, Description, Psychology, Star, Work } from '@mui/icons-material';
|
|
import ModelTrainingIcon from '@mui/icons-material/ModelTraining';
|
|
import { StatusIcon, StatusBox } from 'components/ui/StatusIcon';
|
|
import RestoreIcon from '@mui/icons-material/Restore';
|
|
import SaveIcon from '@mui/icons-material/Save';
|
|
import * as Types from 'types/types';
|
|
import { useAppState } from 'hooks/GlobalContext';
|
|
import { StyledMarkdown } from 'components/StyledMarkdown';
|
|
|
|
interface JobInfoProps {
|
|
job: Job;
|
|
sx?: SxProps;
|
|
action?: string;
|
|
elevation?: number;
|
|
variant?: 'minimal' | 'small' | 'normal' | 'all' | null;
|
|
onClose?: () => void;
|
|
inDialog?: boolean; // Whether this is rendered in a dialog
|
|
}
|
|
|
|
const JobInfo: React.FC<JobInfoProps> = (props: JobInfoProps) => {
|
|
const { setSnack } = useAppState();
|
|
const { user, apiClient } = useAuth();
|
|
const { sx, variant = 'normal', job, onClose, inDialog = false } = props;
|
|
const theme = useTheme();
|
|
const isMobile = useMediaQuery(theme.breakpoints.down('md')) || variant === 'minimal';
|
|
const isAdmin = user?.isAdmin;
|
|
const [adminStatus, setAdminStatus] = useState<string | null>(null);
|
|
const [adminStatusType, setAdminStatusType] = useState<Types.ApiActivityType | null>(null);
|
|
const [activeJob, setActiveJob] = useState<Types.Job>({
|
|
...job,
|
|
}); /* Copy of job */
|
|
// State for description expansion
|
|
const [isDescriptionExpanded, setIsDescriptionExpanded] = useState(false);
|
|
const [shouldShowMoreButton, setShouldShowMoreButton] = useState(false);
|
|
const [deleted, setDeleted] = useState<boolean>(false);
|
|
const descriptionRef = useRef<HTMLDivElement>(null);
|
|
|
|
useEffect(() => {
|
|
if (job && job.id !== activeJob?.id) {
|
|
setActiveJob(job);
|
|
}
|
|
}, [job, activeJob, setActiveJob]);
|
|
|
|
// Check if description needs truncation
|
|
useEffect(() => {
|
|
if (descriptionRef.current && job.summary) {
|
|
const element = descriptionRef.current;
|
|
// Check if the scrollHeight is greater than clientHeight (meaning content is truncated)
|
|
setShouldShowMoreButton(element.scrollHeight > element.clientHeight);
|
|
}
|
|
}, [job.summary]);
|
|
|
|
const deleteJob = async (jobId: string | undefined): Promise<void> => {
|
|
if (jobId) {
|
|
await apiClient.deleteJob(jobId);
|
|
}
|
|
};
|
|
|
|
const handleReset = async (): Promise<void> => {
|
|
setActiveJob({ ...job });
|
|
};
|
|
|
|
if (!job) {
|
|
return <Box>No job provided.</Box>;
|
|
}
|
|
|
|
const handleSave = async (): Promise<void> => {
|
|
const newJob = await apiClient.updateJob(job.id || '', {
|
|
description: activeJob.description,
|
|
requirements: activeJob.requirements,
|
|
});
|
|
job.updatedAt = newJob.updatedAt;
|
|
setActiveJob(newJob);
|
|
setSnack('Job updated.');
|
|
};
|
|
|
|
const handleRefresh = (): void => {
|
|
setAdminStatus('Re-extracting Job information...');
|
|
const jobStatusHandlers = {
|
|
onStatus: (status: Types.ChatMessageStatus): void => {
|
|
console.log('status:', status.content);
|
|
setAdminStatusType(status.activity);
|
|
setAdminStatus(status.content);
|
|
},
|
|
onMessage: async (jobRequirementsMessage: Types.JobRequirementsMessage): Promise<void> => {
|
|
console.log('onMessage - job', jobRequirementsMessage);
|
|
setActiveJob(jobRequirementsMessage.job);
|
|
},
|
|
onError: (error: Types.ChatMessageError): void => {
|
|
console.log('onError', error);
|
|
setAdminStatusType(null);
|
|
setAdminStatus(null);
|
|
},
|
|
onComplete: (): void => {
|
|
setAdminStatusType(null);
|
|
setAdminStatus(null);
|
|
},
|
|
};
|
|
apiClient.regenerateJob(activeJob, jobStatusHandlers);
|
|
};
|
|
|
|
const renderRequirementSection = (
|
|
title: string,
|
|
items: string[] | undefined,
|
|
icon: JSX.Element,
|
|
required = false
|
|
): JSX.Element => {
|
|
if (!items || items.length === 0) return <></>;
|
|
|
|
return (
|
|
<Box sx={{ mb: 2, display: 'flex', position: 'relative', flexDirection: 'column' }}>
|
|
<Box sx={{ display: 'flex', alignItems: 'center', mb: 1.5 }}>
|
|
{icon}
|
|
<Typography
|
|
variant="subtitle1"
|
|
sx={{ ml: 1, fontWeight: 600, fontSize: '0.85rem !important' }}
|
|
>
|
|
{title}
|
|
</Typography>
|
|
{required && (
|
|
<Chip
|
|
label="Required"
|
|
size="small"
|
|
color="error"
|
|
sx={{ ml: 1, fontSize: '0.75rem !important' }}
|
|
/>
|
|
)}
|
|
</Box>
|
|
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 0.5, position: 'relative' }}>
|
|
{items.map((item, index) => (
|
|
<Box
|
|
key={index}
|
|
sx={{
|
|
border: '1px solid grey',
|
|
p: 0.5,
|
|
borderRadius: 1,
|
|
fontSize: '0.75rem !important',
|
|
display: 'flex',
|
|
}}
|
|
>
|
|
{item}
|
|
</Box>
|
|
))}
|
|
</Box>
|
|
</Box>
|
|
);
|
|
};
|
|
|
|
const renderJobRequirements = (): JSX.Element => {
|
|
if (!activeJob.requirements) return <></>;
|
|
|
|
return (
|
|
<Box
|
|
sx={{
|
|
maxWidth: '100%',
|
|
position: 'relative',
|
|
m: 0,
|
|
p: 0,
|
|
background: 'transparent !important',
|
|
display: 'flex',
|
|
flexDirection: 'column',
|
|
}}
|
|
>
|
|
<Box
|
|
sx={{ p: 0, pb: 1, alignItems: 'center', display: 'flex', flexDirection: 'row', gap: 1 }}
|
|
>
|
|
<CheckCircle color="success" />
|
|
<Box>Job Requirements Analysis</Box>
|
|
</Box>
|
|
<Box sx={{ p: 0 }}>
|
|
{renderRequirementSection(
|
|
'Technical Skills (Required)',
|
|
activeJob.requirements.technicalSkills.required,
|
|
<Build color="primary" />,
|
|
true
|
|
)}
|
|
{renderRequirementSection(
|
|
'Technical Skills (Preferred)',
|
|
activeJob.requirements.technicalSkills.preferred,
|
|
<Build color="action" />
|
|
)}
|
|
{renderRequirementSection(
|
|
'Experience Requirements (Required)',
|
|
activeJob.requirements.experienceRequirements.required,
|
|
<Work color="primary" />,
|
|
true
|
|
)}
|
|
{renderRequirementSection(
|
|
'Experience Requirements (Preferred)',
|
|
activeJob.requirements.experienceRequirements.preferred,
|
|
<Work color="action" />
|
|
)}
|
|
{renderRequirementSection(
|
|
'Soft Skills',
|
|
activeJob.requirements.softSkills,
|
|
<Psychology color="secondary" />
|
|
)}
|
|
{renderRequirementSection(
|
|
'Experience',
|
|
activeJob.requirements.experience,
|
|
<Star color="warning" />
|
|
)}
|
|
{renderRequirementSection(
|
|
'Education',
|
|
activeJob.requirements.education,
|
|
<Description color="info" />
|
|
)}
|
|
{renderRequirementSection(
|
|
'Certifications',
|
|
activeJob.requirements.certifications,
|
|
<CheckCircle color="success" />
|
|
)}
|
|
{renderRequirementSection(
|
|
'Preferred Attributes',
|
|
activeJob.requirements.preferredAttributes,
|
|
<Star color="secondary" />
|
|
)}
|
|
</Box>
|
|
</Box>
|
|
);
|
|
};
|
|
|
|
return (
|
|
<Box
|
|
className="JobInfo"
|
|
sx={{
|
|
display: 'flex',
|
|
borderColor: 'transparent',
|
|
borderWidth: 2,
|
|
borderStyle: 'solid',
|
|
flexDirection: 'column',
|
|
minWidth: 0,
|
|
maxWidth: '100%',
|
|
opacity: deleted ? 0.5 : 1.0,
|
|
backgroundColor: deleted
|
|
? theme.palette.action.disabledBackground
|
|
: theme.palette.background.paper,
|
|
pointerEvents: deleted ? 'none' : 'auto',
|
|
...sx,
|
|
}}
|
|
{...rest}
|
|
>
|
|
<Box
|
|
sx={{
|
|
display: 'flex',
|
|
flexGrow: 1,
|
|
p: 1,
|
|
pb: 0,
|
|
height: '100%',
|
|
flexDirection: 'column',
|
|
alignItems: 'stretch',
|
|
position: 'relative',
|
|
}}
|
|
>
|
|
{onClose && !inDialog && (
|
|
<Box sx={{ position: 'absolute', top: 0, right: 0, zIndex: 1 }}>
|
|
<IconButton onClick={onClose} size="small">
|
|
<CloseIcon />
|
|
</IconButton>
|
|
</Box>
|
|
)}
|
|
|
|
<Box
|
|
sx={{
|
|
display: 'flex',
|
|
flexDirection: isMobile || variant === 'small' ? 'column' : 'row',
|
|
'& > div > div > :first-of-type': {
|
|
fontWeight: 'bold',
|
|
whiteSpace: 'nowrap',
|
|
},
|
|
'& > div > div > :last-of-type': { mb: 0.75, mr: 1 },
|
|
position: 'relative',
|
|
}}
|
|
>
|
|
<Box
|
|
sx={{
|
|
display: 'flex',
|
|
flexDirection: isMobile ? 'row' : 'column',
|
|
flexGrow: 1,
|
|
gap: 1,
|
|
}}
|
|
>
|
|
{activeJob.company && (
|
|
<Box sx={{ fontSize: '0.8rem' }}>
|
|
<Box>Company</Box>
|
|
<Box sx={{ whiteSpace: 'nowrap' }}>{activeJob.company}</Box>
|
|
</Box>
|
|
)}
|
|
{activeJob.title && (
|
|
<Box sx={{ fontSize: '0.8rem' }}>
|
|
<Box>Title</Box>
|
|
<Box>{activeJob.title}</Box>
|
|
</Box>
|
|
)}
|
|
</Box>
|
|
<Box
|
|
sx={{
|
|
display: 'flex',
|
|
flexDirection: 'column',
|
|
width: variant !== 'small' && variant !== 'minimal' ? '75%' : '100%',
|
|
}}
|
|
>
|
|
{!isMobile && activeJob.summary && (
|
|
<Box sx={{ fontSize: '0.8rem' }}>
|
|
<Box>Summary</Box>
|
|
<Box sx={{ minHeight: variant === 'small' ? '5rem' : 'inherit' }}>
|
|
<Typography
|
|
ref={descriptionRef}
|
|
variant="body1"
|
|
color="text.secondary"
|
|
sx={{
|
|
display: '-webkit-box',
|
|
WebkitLineClamp: isDescriptionExpanded ? 'unset' : 3,
|
|
WebkitBoxOrient: 'vertical',
|
|
overflow: 'hidden',
|
|
textOverflow: 'ellipsis',
|
|
lineHeight: 1.5,
|
|
fontSize: '0.8rem !important',
|
|
}}
|
|
>
|
|
{activeJob.summary}
|
|
</Typography>
|
|
{shouldShowMoreButton && (
|
|
<Link
|
|
component="button"
|
|
variant="body2"
|
|
onClick={(e): void => {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
setIsDescriptionExpanded(!isDescriptionExpanded);
|
|
}}
|
|
sx={{
|
|
color: theme.palette.primary.main,
|
|
textDecoration: 'none',
|
|
cursor: 'pointer',
|
|
fontSize: '0.725rem',
|
|
fontWeight: 500,
|
|
mt: 0.5,
|
|
display: 'block',
|
|
'&:hover': {
|
|
textDecoration: 'underline',
|
|
},
|
|
}}
|
|
>
|
|
[{isDescriptionExpanded ? 'less' : 'more'}]
|
|
</Link>
|
|
)}
|
|
</Box>
|
|
</Box>
|
|
)}
|
|
</Box>
|
|
</Box>
|
|
|
|
{variant !== 'small' && variant !== 'minimal' && (
|
|
<>
|
|
<Box sx={{ mb: 2 }}>
|
|
<Chip
|
|
label={job.details?.isActive ? 'Active' : 'Inactive'}
|
|
color={job.details?.isActive ? 'success' : 'default'}
|
|
size="small"
|
|
sx={{ mr: 1 }}
|
|
/>
|
|
{job.details?.employmentType && (
|
|
<Chip
|
|
label={job.details.employmentType}
|
|
variant="outlined"
|
|
size="small"
|
|
sx={{ mr: 1 }}
|
|
/>
|
|
)}
|
|
</Box>
|
|
|
|
{activeJob.details?.location && (
|
|
<Typography variant="body2" color="text.secondary" gutterBottom>
|
|
📍 {activeJob.details.location.city},{' '}
|
|
{activeJob.details.location.state || activeJob.details.location.country}
|
|
</Typography>
|
|
)}
|
|
{activeJob.owner && (
|
|
<Typography variant="body2">
|
|
<strong>Submitted by:</strong> {activeJob.owner.fullName}
|
|
</Typography>
|
|
)}
|
|
{activeJob.createdAt && (
|
|
<Typography variant="caption">
|
|
Created: {activeJob.createdAt.toISOString()}
|
|
</Typography>
|
|
)}
|
|
{activeJob.updatedAt && (
|
|
<Typography variant="caption">
|
|
Updated: {activeJob.updatedAt.toISOString()}
|
|
</Typography>
|
|
)}
|
|
<Typography variant="caption">Job ID: {job.id}</Typography>
|
|
</>
|
|
)}
|
|
{variant === 'all' && (
|
|
<StyledMarkdown sx={{ display: 'flex' }} content={activeJob.description} />
|
|
)}
|
|
|
|
{variant !== 'small' && variant !== 'minimal' && renderJobRequirements()}
|
|
|
|
{isAdmin && (
|
|
<Box sx={{ display: 'flex', flexDirection: 'column', p: 1 }}>
|
|
<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()) && (
|
|
<Tooltip title="Save Job">
|
|
<IconButton
|
|
size="small"
|
|
onClick={(e): void => {
|
|
e.stopPropagation();
|
|
handleSave();
|
|
}}
|
|
>
|
|
<SaveIcon />
|
|
</IconButton>
|
|
</Tooltip>
|
|
)}
|
|
<Tooltip title="Delete Job">
|
|
<IconButton
|
|
size="small"
|
|
onClick={(e): void => {
|
|
e.stopPropagation();
|
|
deleteJob(job.id);
|
|
setDeleted(true);
|
|
}}
|
|
>
|
|
<DeleteIcon />
|
|
</IconButton>
|
|
</Tooltip>
|
|
<Tooltip title="Reset Job">
|
|
<IconButton
|
|
size="small"
|
|
onClick={(e): void => {
|
|
e.stopPropagation();
|
|
handleReset();
|
|
}}
|
|
>
|
|
<RestoreIcon />
|
|
</IconButton>
|
|
</Tooltip>
|
|
<Tooltip title="Reprocess Job">
|
|
<IconButton
|
|
size="small"
|
|
onClick={(e): void => {
|
|
e.stopPropagation();
|
|
handleRefresh();
|
|
}}
|
|
>
|
|
<ModelTrainingIcon />
|
|
</IconButton>
|
|
</Tooltip>
|
|
</Box>
|
|
{adminStatus && (
|
|
<Box sx={{ mt: 3 }}>
|
|
<StatusBox>
|
|
{adminStatusType && <StatusIcon type={adminStatusType} />}
|
|
<Typography variant="body2" sx={{ ml: 1 }}>
|
|
{adminStatus || 'Processing...'}
|
|
</Typography>
|
|
</StatusBox>
|
|
{adminStatus && <LinearProgress sx={{ mt: 1 }} />}
|
|
</Box>
|
|
)}
|
|
</Box>
|
|
)}
|
|
</Box>
|
|
</Box>
|
|
);
|
|
};
|
|
|
|
export { JobInfo };
|