379 lines
14 KiB
TypeScript
379 lines
14 KiB
TypeScript
import React, { JSX, useActionState, useEffect, useRef, useState } from 'react';
|
|
import { Box, Link, Typography, Avatar, Grid, SxProps, CardActions, Chip, Stack, CardHeader, Button, styled, LinearProgress, IconButton, Tooltip } from '@mui/material';
|
|
import {
|
|
Card,
|
|
CardContent,
|
|
Divider,
|
|
useTheme,
|
|
} from '@mui/material';
|
|
import DeleteIcon from '@mui/icons-material/Delete';
|
|
import { useMediaQuery } from '@mui/material';
|
|
import { Job } from 'types/types';
|
|
import { CopyBubble } from "components/CopyBubble";
|
|
import { rest } from 'lodash';
|
|
import { AIBanner } from 'components/ui/AIBanner';
|
|
import { useAuth } from 'hooks/AuthContext';
|
|
import { DeleteConfirmation } from '../DeleteConfirmation';
|
|
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
|
|
};
|
|
|
|
const JobInfo: React.FC<JobInfoProps> = (props: JobInfoProps) => {
|
|
const { setSnack } = useAppState();
|
|
const { job } = props;
|
|
const { user, apiClient } = useAuth();
|
|
const {
|
|
sx,
|
|
action = '',
|
|
elevation = 1,
|
|
variant = "normal"
|
|
} = 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) => {
|
|
if (jobId) {
|
|
await apiClient.deleteJob(jobId);
|
|
}
|
|
}
|
|
|
|
const handleReset = async () => {
|
|
setActiveJob({ ...job });
|
|
}
|
|
|
|
if (!job) {
|
|
return <Box>No job provided.</Box>;
|
|
}
|
|
|
|
const handleSave = async () => {
|
|
const newJob = await apiClient.updateJob(job.id || '', {
|
|
description: activeJob.description,
|
|
requirements: activeJob.requirements,
|
|
});
|
|
job.updatedAt = newJob.updatedAt;
|
|
setActiveJob(newJob)
|
|
setSnack('Job updated.');
|
|
}
|
|
|
|
const handleRefresh = () => {
|
|
setAdminStatus("Re-extracting Job information...");
|
|
const jobStatusHandlers = {
|
|
onStatus: (status: Types.ChatMessageStatus) => {
|
|
console.log('status:', status.content);
|
|
setAdminStatusType(status.activity);
|
|
setAdminStatus(status.content);
|
|
},
|
|
onMessage: async (jobMessage: Types.JobRequirementsMessage) => {
|
|
const newJob: Types.Job = jobMessage.job
|
|
console.log('onMessage - job', newJob);
|
|
newJob.id = job.id;
|
|
newJob.createdAt = job.createdAt;
|
|
const updatedJob: Types.Job = await apiClient.updateJob(job.id || '', newJob);
|
|
setActiveJob(updatedJob);
|
|
},
|
|
onError: (error: Types.ChatMessageError) => {
|
|
console.log('onError', error);
|
|
setAdminStatusType(null);
|
|
setAdminStatus(null);
|
|
},
|
|
onComplete: () => {
|
|
setAdminStatusType(null);
|
|
setAdminStatus(null);
|
|
}
|
|
};
|
|
apiClient.createJobFromDescription(activeJob.description, jobStatusHandlers);
|
|
};
|
|
|
|
const renderRequirementSection = (title: string, items: string[] | undefined, icon: JSX.Element, required = false) => {
|
|
if (!items || items.length === 0) return null;
|
|
|
|
return (
|
|
<Box sx={{ mb: 2 }}>
|
|
<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>
|
|
<Stack direction="row" spacing={1} flexWrap="wrap" useFlexGap>
|
|
{items.map((item, index) => (
|
|
<Chip
|
|
key={index}
|
|
label={item}
|
|
variant="outlined"
|
|
size="small"
|
|
sx={{ mb: 1, fontSize: '0.75rem !important' }}
|
|
/>
|
|
))}
|
|
</Stack>
|
|
</Box>
|
|
);
|
|
};
|
|
|
|
const renderJobRequirements = () => {
|
|
if (!activeJob.requirements) return null;
|
|
|
|
return (
|
|
<Card elevation={0} sx={{ m: 0, p: 0, mt: 2, background: "transparent !important" }}>
|
|
<CardHeader
|
|
title="Job Requirements Analysis"
|
|
avatar={<CheckCircle color="success" />}
|
|
sx={{ p: 0, pb: 1 }}
|
|
/>
|
|
<CardContent 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" />
|
|
)}
|
|
</CardContent>
|
|
</Card>
|
|
);
|
|
};
|
|
|
|
return (
|
|
<Box
|
|
sx={{
|
|
display: "flex",
|
|
borderColor: 'transparent',
|
|
borderWidth: 2,
|
|
borderStyle: 'solid',
|
|
transition: 'all 0.3s ease',
|
|
flexDirection: "column",
|
|
minWidth: 0,
|
|
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" }}>
|
|
<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 }
|
|
}}>
|
|
<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) => {
|
|
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") && <>
|
|
{activeJob.details &&
|
|
<Typography variant="body2" sx={{ mb: 1 }}>
|
|
<strong>Location:</strong> {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') && <Box><Divider />{renderJobRequirements()}</Box>}
|
|
|
|
{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) => { e.stopPropagation(); handleSave(); }}
|
|
>
|
|
<SaveIcon />
|
|
</IconButton>
|
|
</Tooltip>
|
|
}
|
|
<Tooltip title="Delete Job">
|
|
<IconButton
|
|
size="small"
|
|
onClick={(e) => { e.stopPropagation(); deleteJob(job.id); setDeleted(true) }}
|
|
>
|
|
<DeleteIcon />
|
|
</IconButton>
|
|
</Tooltip>
|
|
<Tooltip title="Reset Job">
|
|
<IconButton
|
|
size="small"
|
|
onClick={(e) => { e.stopPropagation(); handleReset(); }}
|
|
>
|
|
<RestoreIcon />
|
|
</IconButton>
|
|
</Tooltip>
|
|
<Tooltip title="Reprocess Job">
|
|
<IconButton
|
|
size="small"
|
|
onClick={(e) => { 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 };
|