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