381 lines
12 KiB
TypeScript
381 lines
12 KiB
TypeScript
import React, { useEffect, useRef, useState } from 'react';
|
|
import {
|
|
Box,
|
|
Link,
|
|
Typography,
|
|
Avatar,
|
|
Grid,
|
|
SxProps,
|
|
CardActions,
|
|
Chip,
|
|
Stack,
|
|
CardHeader,
|
|
Button,
|
|
LinearProgress,
|
|
IconButton,
|
|
Tooltip,
|
|
Card,
|
|
CardContent,
|
|
Divider,
|
|
useTheme,
|
|
useMediaQuery,
|
|
TextField,
|
|
Dialog,
|
|
DialogTitle,
|
|
DialogContent,
|
|
DialogActions
|
|
} from '@mui/material';
|
|
import {
|
|
Delete as DeleteIcon,
|
|
Restore as RestoreIcon,
|
|
Save as SaveIcon,
|
|
Edit as EditIcon,
|
|
Description as DescriptionIcon,
|
|
Work as WorkIcon,
|
|
Person as PersonIcon,
|
|
Schedule as ScheduleIcon,
|
|
Visibility as VisibilityIcon,
|
|
VisibilityOff as VisibilityOffIcon
|
|
} from '@mui/icons-material';
|
|
import { useAuth } from 'hooks/AuthContext';
|
|
import { useAppState } from 'hooks/GlobalContext';
|
|
import { StyledMarkdown } from 'components/StyledMarkdown';
|
|
import { Resume } from 'types/types';
|
|
|
|
interface ResumeInfoProps {
|
|
resume: Resume;
|
|
sx?: SxProps;
|
|
action?: string;
|
|
elevation?: number;
|
|
variant?: "minimal" | "small" | "normal" | "all" | null;
|
|
}
|
|
|
|
const ResumeInfo: React.FC<ResumeInfoProps> = (props: ResumeInfoProps) => {
|
|
const { setSnack } = useAppState();
|
|
const { resume } = 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 [activeResume, setActiveResume] = useState<Resume>({ ...resume });
|
|
const [isContentExpanded, setIsContentExpanded] = useState(false);
|
|
const [shouldShowMoreButton, setShouldShowMoreButton] = useState(false);
|
|
const [deleted, setDeleted] = useState<boolean>(false);
|
|
const [editDialogOpen, setEditDialogOpen] = useState<boolean>(false);
|
|
const [editContent, setEditContent] = useState<string>('');
|
|
const [saving, setSaving] = useState<boolean>(false);
|
|
const contentRef = useRef<HTMLDivElement>(null);
|
|
|
|
useEffect(() => {
|
|
if (resume && resume.id !== activeResume?.id) {
|
|
setActiveResume(resume);
|
|
}
|
|
}, [resume, activeResume]);
|
|
|
|
// Check if content needs truncation
|
|
useEffect(() => {
|
|
if (contentRef.current && resume.resume) {
|
|
const element = contentRef.current;
|
|
setShouldShowMoreButton(element.scrollHeight > element.clientHeight);
|
|
}
|
|
}, [resume.resume]);
|
|
|
|
const deleteResume = async (resumeId: string | undefined) => {
|
|
if (resumeId) {
|
|
try {
|
|
await apiClient.deleteResume(resumeId);
|
|
setDeleted(true);
|
|
setSnack('Resume deleted successfully.');
|
|
} catch (error) {
|
|
setSnack('Failed to delete resume.');
|
|
}
|
|
}
|
|
};
|
|
|
|
const handleReset = async () => {
|
|
setActiveResume({ ...resume });
|
|
};
|
|
|
|
const handleSave = async () => {
|
|
setSaving(true);
|
|
try {
|
|
const result = await apiClient.updateResume(activeResume.id || '', editContent);
|
|
const updatedResume = { ...activeResume, resume: editContent, updatedAt: new Date() };
|
|
setActiveResume(updatedResume);
|
|
setEditDialogOpen(false);
|
|
setSnack('Resume updated successfully.');
|
|
} catch (error) {
|
|
setSnack('Failed to update resume.');
|
|
} finally {
|
|
setSaving(false);
|
|
}
|
|
};
|
|
|
|
const handleEditOpen = () => {
|
|
setEditContent(activeResume.resume);
|
|
setEditDialogOpen(true);
|
|
};
|
|
|
|
if (!resume) {
|
|
return <Box>No resume provided.</Box>;
|
|
}
|
|
|
|
const formatDate = (date: Date | undefined) => {
|
|
if (!date) return 'N/A';
|
|
return new Intl.DateTimeFormat('en-US', {
|
|
month: 'short',
|
|
day: 'numeric',
|
|
year: 'numeric',
|
|
hour: '2-digit',
|
|
minute: '2-digit'
|
|
}).format(date);
|
|
};
|
|
|
|
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,
|
|
}}
|
|
>
|
|
<Box sx={{ display: "flex", flexGrow: 1, p: 1, pb: 0, height: '100%', flexDirection: 'column', alignItems: 'stretch', position: "relative" }}>
|
|
|
|
{/* Header Information */}
|
|
<Box sx={{
|
|
display: "flex",
|
|
flexDirection: isMobile ? "column" : "row",
|
|
gap: 2,
|
|
mb: 2
|
|
}}>
|
|
<Grid container spacing={2}>
|
|
<Grid size={{ xs: 12, md: 6 }}>
|
|
<Stack spacing={1}>
|
|
{activeResume.candidate && (
|
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
|
<PersonIcon color="primary" fontSize="small" />
|
|
<Typography variant="subtitle2" fontWeight="bold">
|
|
Candidate
|
|
</Typography>
|
|
</Box>
|
|
)}
|
|
<Typography variant="body2" color="text.secondary">
|
|
{activeResume.candidate?.fullName || activeResume.candidateId}
|
|
</Typography>
|
|
|
|
{activeResume.job && (
|
|
<>
|
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mt: 1 }}>
|
|
<WorkIcon color="primary" fontSize="small" />
|
|
<Typography variant="subtitle2" fontWeight="bold">
|
|
Job
|
|
</Typography>
|
|
</Box>
|
|
<Typography variant="body2" color="text.secondary">
|
|
{activeResume.job.title} at {activeResume.job.company}
|
|
</Typography>
|
|
</>
|
|
)}
|
|
</Stack>
|
|
</Grid>
|
|
|
|
<Grid size={{ xs: 12, md: 6 }}>
|
|
<Stack spacing={1}>
|
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
|
<ScheduleIcon color="primary" fontSize="small" />
|
|
<Typography variant="subtitle2" fontWeight="bold">
|
|
Timeline
|
|
</Typography>
|
|
</Box>
|
|
<Typography variant="caption" color="text.secondary">
|
|
Created: {formatDate(activeResume.createdAt)}
|
|
</Typography>
|
|
<Typography variant="caption" color="text.secondary">
|
|
Updated: {formatDate(activeResume.updatedAt)}
|
|
</Typography>
|
|
<Typography variant="caption" color="text.secondary">
|
|
Resume ID: {activeResume.resumeId}
|
|
</Typography>
|
|
</Stack>
|
|
</Grid>
|
|
</Grid>
|
|
</Box>
|
|
|
|
<Divider sx={{ mb: 2 }} />
|
|
|
|
{/* Resume Content */}
|
|
{activeResume.resume && (
|
|
<Card elevation={0} sx={{ m: 0, p: 0, background: "transparent !important" }}>
|
|
<CardHeader
|
|
title="Resume Content"
|
|
avatar={<DescriptionIcon color="success" />}
|
|
sx={{ p: 0, pb: 1 }}
|
|
action={
|
|
isAdmin && (
|
|
<Tooltip title="Edit Resume Content">
|
|
<IconButton size="small" onClick={handleEditOpen}>
|
|
<EditIcon />
|
|
</IconButton>
|
|
</Tooltip>
|
|
)
|
|
}
|
|
/>
|
|
<CardContent sx={{ p: 0 }}>
|
|
<Box sx={{ position: 'relative' }}>
|
|
<Typography
|
|
ref={contentRef}
|
|
variant="body2"
|
|
component="div"
|
|
sx={{
|
|
display: '-webkit-box',
|
|
WebkitLineClamp: isContentExpanded ? 'unset' : (variant === "small" ? 5 : variant === "minimal" ? 3 : 10),
|
|
WebkitBoxOrient: 'vertical',
|
|
overflow: 'hidden',
|
|
textOverflow: 'ellipsis',
|
|
lineHeight: 1.6,
|
|
fontSize: "0.875rem !important",
|
|
whiteSpace: 'pre-wrap',
|
|
fontFamily: 'monospace',
|
|
backgroundColor: theme.palette.action.hover,
|
|
p: 2,
|
|
borderRadius: 1,
|
|
border: `1px solid ${theme.palette.divider}`,
|
|
}}
|
|
>
|
|
{activeResume.resume}
|
|
</Typography>
|
|
|
|
{shouldShowMoreButton && variant !== "all" && (
|
|
<Box sx={{ display: 'flex', justifyContent: 'center', mt: 1 }}>
|
|
<Button
|
|
variant="text"
|
|
size="small"
|
|
onClick={() => setIsContentExpanded(!isContentExpanded)}
|
|
startIcon={isContentExpanded ? <VisibilityOffIcon /> : <VisibilityIcon />}
|
|
sx={{ fontSize: '0.75rem' }}
|
|
>
|
|
{isContentExpanded ? "Show Less" : "Show More"}
|
|
</Button>
|
|
</Box>
|
|
)}
|
|
</Box>
|
|
</CardContent>
|
|
</Card>
|
|
)}
|
|
|
|
{variant === 'all' && activeResume.resume && (
|
|
<Box sx={{ mt: 2 }}>
|
|
<StyledMarkdown content={activeResume.resume} />
|
|
</Box>
|
|
)}
|
|
|
|
</Box>
|
|
|
|
{/* Admin Controls */}
|
|
{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" }}>
|
|
<Tooltip title="Edit Resume">
|
|
<IconButton
|
|
size="small"
|
|
onClick={(e) => { e.stopPropagation(); handleEditOpen(); }}
|
|
>
|
|
<EditIcon />
|
|
</IconButton>
|
|
</Tooltip>
|
|
|
|
<Tooltip title="Delete Resume">
|
|
<IconButton
|
|
size="small"
|
|
onClick={(e) => { e.stopPropagation(); deleteResume(activeResume.id); }}
|
|
>
|
|
<DeleteIcon />
|
|
</IconButton>
|
|
</Tooltip>
|
|
|
|
<Tooltip title="Reset Resume">
|
|
<IconButton
|
|
size="small"
|
|
onClick={(e) => { e.stopPropagation(); handleReset(); }}
|
|
>
|
|
<RestoreIcon />
|
|
</IconButton>
|
|
</Tooltip>
|
|
</Box>
|
|
|
|
{saving && (
|
|
<Box sx={{ mt: 1 }}>
|
|
<LinearProgress />
|
|
<Typography variant="caption" sx={{ mt: 0.5 }}>
|
|
Saving resume...
|
|
</Typography>
|
|
</Box>
|
|
)}
|
|
</Box>
|
|
)}
|
|
|
|
{/* Edit Dialog */}
|
|
<Dialog
|
|
open={editDialogOpen}
|
|
onClose={() => setEditDialogOpen(false)}
|
|
maxWidth="lg"
|
|
fullWidth
|
|
fullScreen={isMobile}
|
|
>
|
|
<DialogTitle>
|
|
Edit Resume Content
|
|
<Typography variant="caption" display="block" color="text.secondary">
|
|
Resume for {activeResume.candidate?.fullName || activeResume.candidateId}
|
|
</Typography>
|
|
</DialogTitle>
|
|
<DialogContent>
|
|
<TextField
|
|
multiline
|
|
fullWidth
|
|
rows={20}
|
|
value={editContent}
|
|
onChange={(e) => setEditContent(e.target.value)}
|
|
variant="outlined"
|
|
sx={{
|
|
mt: 1,
|
|
'& .MuiInputBase-input': {
|
|
fontFamily: 'monospace',
|
|
fontSize: '0.875rem'
|
|
}
|
|
}}
|
|
placeholder="Enter resume content..."
|
|
/>
|
|
</DialogContent>
|
|
<DialogActions>
|
|
<Button onClick={() => setEditDialogOpen(false)}>
|
|
Cancel
|
|
</Button>
|
|
<Button
|
|
onClick={handleSave}
|
|
variant="contained"
|
|
disabled={saving}
|
|
startIcon={<SaveIcon />}
|
|
>
|
|
{saving ? 'Saving...' : 'Save'}
|
|
</Button>
|
|
</DialogActions>
|
|
</Dialog>
|
|
</Box>
|
|
);
|
|
};
|
|
|
|
export { ResumeInfo }; |