1398 lines
44 KiB
TypeScript
1398 lines
44 KiB
TypeScript
import React, { useEffect, useMemo, useRef, useState } from 'react';
|
|
import {
|
|
Box,
|
|
Typography,
|
|
SxProps,
|
|
CardHeader,
|
|
Button,
|
|
LinearProgress,
|
|
IconButton,
|
|
Tooltip,
|
|
Card,
|
|
CardContent,
|
|
Divider,
|
|
useTheme,
|
|
useMediaQuery,
|
|
Dialog,
|
|
DialogTitle,
|
|
DialogContent,
|
|
DialogActions,
|
|
Tabs,
|
|
Tab,
|
|
Paper,
|
|
FormControl,
|
|
Select,
|
|
MenuItem,
|
|
InputLabel,
|
|
Theme,
|
|
Chip,
|
|
Alert,
|
|
Stack,
|
|
SelectChangeEvent,
|
|
} from '@mui/material';
|
|
import PrintIcon from '@mui/icons-material/Print';
|
|
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,
|
|
ModelTraining,
|
|
Email as EmailIcon,
|
|
Phone as PhoneIcon,
|
|
LocationOn as LocationIcon,
|
|
History as HistoryIcon,
|
|
RestoreFromTrash as RestoreFromTrashIcon,
|
|
Refresh as RefreshIcon,
|
|
// Language as WebsiteIcon,
|
|
} from '@mui/icons-material';
|
|
import InputIcon from '@mui/icons-material/Input';
|
|
import TuneIcon from '@mui/icons-material/Tune';
|
|
import PreviewIcon from '@mui/icons-material/Preview';
|
|
import EditDocumentIcon from '@mui/icons-material/EditDocument';
|
|
import { parsePhoneNumberFromString } from 'libphonenumber-js';
|
|
|
|
import { useReactToPrint } from 'react-to-print';
|
|
|
|
import { useAuth } from 'hooks/AuthContext';
|
|
import { useAppState } from 'hooks/GlobalContext';
|
|
import { StyledMarkdown } from 'components/StyledMarkdown';
|
|
import { Resume } from 'types/types';
|
|
import { BackstoryTextField } from 'components/BackstoryTextField';
|
|
import { JobInfo } from './JobInfo';
|
|
import './ResumeInfo.css';
|
|
import { Scrollable } from 'components/Scrollable';
|
|
import * as Types from 'types/types';
|
|
import { StreamingOptions } from 'services/api-client';
|
|
import { StatusBox, StatusIcon } from './StatusIcon';
|
|
import { ResumeChat } from 'components/ResumeChat';
|
|
|
|
interface ResumeInfoProps {
|
|
resume: Resume;
|
|
sx?: SxProps;
|
|
action?: string;
|
|
elevation?: number;
|
|
variant?: 'minimal' | 'small' | 'normal' | 'all' | null;
|
|
}
|
|
|
|
// Resume revision interface
|
|
interface ResumeRevision {
|
|
revisionId: string;
|
|
revisionTimestamp: string;
|
|
updatedAt: string;
|
|
createdAt: string;
|
|
candidateId: string;
|
|
jobId: string;
|
|
}
|
|
|
|
// Resume Style Definitions
|
|
interface ResumeStyle {
|
|
name: string;
|
|
description: string;
|
|
headerStyle: SxProps<Theme>;
|
|
footerStyle: SxProps<Theme>;
|
|
contentStyle: SxProps<Theme>;
|
|
markdownStyle: SxProps<Theme>;
|
|
color: {
|
|
primary: string;
|
|
secondary: string;
|
|
accent: string;
|
|
text: string;
|
|
background: string;
|
|
};
|
|
}
|
|
|
|
const generateResumeStyles = () => {
|
|
const defaultStyle = {
|
|
display: 'flex',
|
|
flexDirection: 'row',
|
|
};
|
|
|
|
return {
|
|
classic: {
|
|
name: 'Classic',
|
|
description: 'Traditional, professional serif design',
|
|
headerStyle: {
|
|
...defaultStyle,
|
|
fontFamily: '"Times New Roman", Times, serif',
|
|
borderBottom: '2px solid #2c3e50',
|
|
paddingBottom: 2,
|
|
marginBottom: 3,
|
|
},
|
|
footerStyle: {
|
|
fontFamily: '"Times New Roman", Times, serif',
|
|
borderTop: '2px solid #2c3e50',
|
|
paddingTop: 2,
|
|
display: 'flex',
|
|
flexDirection: 'column',
|
|
alignItems: 'center',
|
|
justifyContent: 'center',
|
|
textTransform: 'uppercase',
|
|
alignContent: 'center',
|
|
fontSize: '0.8rem',
|
|
pb: 2,
|
|
mb: 2,
|
|
},
|
|
contentStyle: {
|
|
fontFamily: '"Times New Roman", Times, serif',
|
|
lineHeight: 1.6,
|
|
color: '#2c3e50',
|
|
},
|
|
markdownStyle: {
|
|
fontFamily: '"Times New Roman", Times, serif',
|
|
'& h1, & h2, & h3': {
|
|
fontFamily: '"Times New Roman", Times, serif',
|
|
color: '#2c3e50',
|
|
borderBottom: '1px solid #bdc3c7',
|
|
paddingBottom: 1,
|
|
marginBottom: 2,
|
|
},
|
|
'& p, & li': {
|
|
lineHeight: 1.6,
|
|
marginBottom: 1,
|
|
},
|
|
'& ul': {
|
|
paddingLeft: 3,
|
|
},
|
|
},
|
|
color: {
|
|
primary: '#2c3e50',
|
|
secondary: '#34495e',
|
|
accent: '#3498db',
|
|
text: '#2c3e50',
|
|
background: '#ffffff',
|
|
},
|
|
},
|
|
modern: {
|
|
name: 'Modern',
|
|
description: 'Clean, minimalist sans-serif layout',
|
|
headerStyle: {
|
|
...defaultStyle,
|
|
fontFamily: '"Helvetica Neue", Helvetica, Arial, sans-serif',
|
|
borderLeft: '4px solid #3498db',
|
|
paddingLeft: 2,
|
|
marginBottom: 3,
|
|
backgroundColor: '#f8f9fa',
|
|
padding: 2,
|
|
borderRadius: 1,
|
|
},
|
|
footerStyle: {
|
|
fontFamily: '"Helvetica Neue", Helvetica, Arial, sans-serif',
|
|
borderLeft: '4px solid #3498db',
|
|
backgroundColor: '#f8f9fa',
|
|
paddingTop: 2,
|
|
borderRadius: 1,
|
|
display: 'flex',
|
|
flexDirection: 'column',
|
|
alignItems: 'center',
|
|
justifyContent: 'center',
|
|
textTransform: 'uppercase',
|
|
alignContent: 'center',
|
|
fontSize: '0.8rem',
|
|
pb: 2,
|
|
mb: 2,
|
|
},
|
|
contentStyle: {
|
|
fontFamily: '"Helvetica Neue", Helvetica, Arial, sans-serif',
|
|
lineHeight: 1.5,
|
|
color: '#2c3e50',
|
|
},
|
|
markdownStyle: {
|
|
fontFamily: '"Helvetica Neue", Helvetica, Arial, sans-serif',
|
|
'& h1, & h2, & h3': {
|
|
fontFamily: '"Helvetica Neue", Helvetica, Arial, sans-serif',
|
|
color: '#3498db',
|
|
fontWeight: 300,
|
|
marginBottom: 1.5,
|
|
},
|
|
'& h1': {
|
|
fontSize: '1.75rem',
|
|
},
|
|
'& h2': {
|
|
fontSize: '1.5rem',
|
|
},
|
|
'& h3': {
|
|
fontSize: '1.25rem',
|
|
},
|
|
'& p, & li': {
|
|
lineHeight: 1.5,
|
|
marginBottom: 0.75,
|
|
},
|
|
'& ul': {
|
|
paddingLeft: 2.5,
|
|
},
|
|
},
|
|
color: {
|
|
primary: '#3498db',
|
|
secondary: '#2c3e50',
|
|
accent: '#e74c3c',
|
|
text: '#2c3e50',
|
|
background: '#ffffff',
|
|
},
|
|
},
|
|
creative: {
|
|
name: 'Creative',
|
|
description: 'Colorful, unique design with personality',
|
|
headerStyle: {
|
|
...defaultStyle,
|
|
fontFamily: '"Montserrat", "Helvetica Neue", Arial, sans-serif',
|
|
background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
|
|
color: '#ffffff',
|
|
padding: 2.5,
|
|
borderRadius: 1.5,
|
|
marginBottom: 3,
|
|
},
|
|
footerStyle: {
|
|
fontFamily: '"Montserrat", "Helvetica Neue", Arial, sans-serif',
|
|
background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
|
|
color: '#ffffff',
|
|
paddingTop: 2,
|
|
borderRadius: 1.5,
|
|
display: 'flex',
|
|
flexDirection: 'column',
|
|
alignItems: 'center',
|
|
justifyContent: 'center',
|
|
textTransform: 'uppercase',
|
|
alignContent: 'center',
|
|
fontSize: '0.8rem',
|
|
pb: 2,
|
|
mb: 2,
|
|
},
|
|
contentStyle: {
|
|
fontFamily: '"Open Sans", Arial, sans-serif',
|
|
lineHeight: 1.6,
|
|
color: '#444444',
|
|
},
|
|
markdownStyle: {
|
|
fontFamily: '"Open Sans", Arial, sans-serif',
|
|
'& h1, & h2, & h3': {
|
|
fontFamily: '"Montserrat", "Helvetica Neue", Arial, sans-serif',
|
|
color: '#667eea',
|
|
fontWeight: 600,
|
|
marginBottom: 2,
|
|
},
|
|
'& h1': {
|
|
fontSize: '1.5rem',
|
|
},
|
|
'& h2': {
|
|
fontSize: '1.25rem',
|
|
},
|
|
'& h3': {
|
|
fontSize: '1.1rem',
|
|
},
|
|
'& p, & li': {
|
|
lineHeight: 1.6,
|
|
marginBottom: 1,
|
|
color: '#444444',
|
|
},
|
|
'& strong': {
|
|
color: '#764ba2',
|
|
fontWeight: 600,
|
|
},
|
|
'& ul': {
|
|
paddingLeft: 3,
|
|
},
|
|
},
|
|
color: {
|
|
primary: '#667eea',
|
|
secondary: '#764ba2',
|
|
accent: '#f093fb',
|
|
text: '#444444',
|
|
background: '#ffffff',
|
|
},
|
|
},
|
|
corporate: {
|
|
name: 'Corporate',
|
|
description: 'Formal, structured business format',
|
|
headerStyle: {
|
|
...defaultStyle,
|
|
fontFamily: '"Arial", sans-serif',
|
|
border: '2px solid #34495e',
|
|
padding: 2.5,
|
|
marginBottom: 3,
|
|
backgroundColor: '#ecf0f1',
|
|
},
|
|
footerStyle: {
|
|
fontFamily: '"Arial", sans-serif',
|
|
border: '2px solid #34495e',
|
|
backgroundColor: '#ecf0f1',
|
|
paddingTop: 2,
|
|
display: 'flex',
|
|
flexDirection: 'column',
|
|
alignItems: 'center',
|
|
justifyContent: 'center',
|
|
textTransform: 'uppercase',
|
|
alignContent: 'center',
|
|
fontSize: '0.8rem',
|
|
pb: 2,
|
|
mb: 2,
|
|
},
|
|
contentStyle: {
|
|
fontFamily: '"Arial", sans-serif',
|
|
lineHeight: 1.4,
|
|
color: '#2c3e50',
|
|
},
|
|
markdownStyle: {
|
|
fontFamily: '"Arial", sans-serif',
|
|
'& h1, & h2, & h3': {
|
|
fontFamily: '"Arial", sans-serif',
|
|
color: '#34495e',
|
|
fontWeight: 'bold',
|
|
textTransform: 'uppercase',
|
|
fontSize: '0.875rem',
|
|
letterSpacing: '1px',
|
|
marginBottom: 1.5,
|
|
borderBottom: '1px solid #bdc3c7',
|
|
paddingBottom: 0.5,
|
|
},
|
|
'& h1': {
|
|
fontSize: '1rem',
|
|
},
|
|
'& h2': {
|
|
fontSize: '0.875rem',
|
|
},
|
|
'& h3': {
|
|
fontSize: '0.75rem',
|
|
},
|
|
'& p, & li': {
|
|
lineHeight: 1.4,
|
|
marginBottom: 0.75,
|
|
fontSize: '0.75rem',
|
|
},
|
|
'& ul': {
|
|
paddingLeft: 2,
|
|
},
|
|
},
|
|
color: {
|
|
primary: '#34495e',
|
|
secondary: '#2c3e50',
|
|
accent: '#95a5a6',
|
|
text: '#2c3e50',
|
|
background: '#ffffff',
|
|
},
|
|
},
|
|
};
|
|
};
|
|
const resumeStyles: Record<string, ResumeStyle> = generateResumeStyles();
|
|
|
|
// Styled Header Component
|
|
interface BackstoryStyledResumeProps {
|
|
candidate: Types.Candidate;
|
|
job?: Types.Job;
|
|
style: ResumeStyle;
|
|
}
|
|
|
|
const StyledFooter: React.FC<BackstoryStyledResumeProps> = ({ candidate, job, style }) => {
|
|
return (
|
|
<>
|
|
<Box
|
|
className="BackstoryResumeFooter"
|
|
sx={{
|
|
...style.footerStyle,
|
|
display: 'flex',
|
|
flexDirection: 'column',
|
|
alignItems: 'center',
|
|
justifyContent: 'center',
|
|
textTransform: 'uppercase',
|
|
alignContent: 'center',
|
|
fontSize: '0.8rem',
|
|
pb: 2,
|
|
mb: 2,
|
|
color: style.color.secondary,
|
|
}}
|
|
>
|
|
Dive deeper into my qualifications at Backstory...
|
|
<Box
|
|
component="img"
|
|
src={`/api/1.0/candidates/qr-code/${candidate.id || ''}/${(job && job.id) || ''}`}
|
|
alt="QR Code"
|
|
className="qr-code"
|
|
sx={{ display: 'flex', mt: 1, mb: 1 }}
|
|
/>
|
|
{candidate?.username
|
|
? `${window.location.protocol}://${window.location.host}/u/${candidate?.username}`
|
|
: 'backstory'}
|
|
</Box>
|
|
<Box sx={{ pb: 2 }}> </Box>
|
|
</>
|
|
);
|
|
};
|
|
|
|
const StyledHeader: React.FC<BackstoryStyledResumeProps> = ({ candidate, style }) => {
|
|
const phone = parsePhoneNumberFromString(candidate.phone || '', 'US');
|
|
return (
|
|
<Box className="BackstoryResumeHeader" sx={style.headerStyle}>
|
|
<Box sx={{ display: 'flex', flexDirection: 'column', flexGrow: 1 }}>
|
|
<Box sx={{ display: 'flex' }}>
|
|
<Typography
|
|
variant="h4"
|
|
sx={{
|
|
fontWeight: 'bold',
|
|
mb: 1,
|
|
color: style.name === 'creative' ? '#ffffff' : style.color.primary,
|
|
fontFamily: 'inherit',
|
|
}}
|
|
>
|
|
{candidate.fullName}
|
|
</Typography>
|
|
</Box>
|
|
<Box
|
|
sx={{
|
|
display: 'flex',
|
|
flexDirection: 'row',
|
|
alignItems: 'flex-start',
|
|
gap: 1,
|
|
}}
|
|
>
|
|
{candidate.description && (
|
|
<Box sx={{ display: 'flex' }}>
|
|
<Typography
|
|
variant="h6"
|
|
sx={{
|
|
mb: 2,
|
|
fontWeight: 300,
|
|
color: style.name === 'creative' ? '#ffffff' : style.color.secondary,
|
|
fontFamily: 'inherit',
|
|
fontSize: '0.8rem !important',
|
|
}}
|
|
>
|
|
{candidate.description}
|
|
</Typography>
|
|
</Box>
|
|
)}
|
|
|
|
<Box
|
|
sx={{
|
|
display: 'flex',
|
|
flexDirection: 'column',
|
|
flexWrap: 'wrap',
|
|
alignContent: 'center',
|
|
flexGrow: 1,
|
|
minWidth: 'fit-content',
|
|
gap: 1,
|
|
}}
|
|
>
|
|
{candidate.email && (
|
|
<Box sx={{ display: 'flex', alignItems: 'center', m: 0, p: 0 }}>
|
|
<EmailIcon
|
|
fontSize="small"
|
|
sx={{ mr: 1, color: style.name === 'creative' ? '#ffffff' : style.color.accent }}
|
|
/>
|
|
<Typography
|
|
variant="body2"
|
|
sx={{
|
|
color: style.name === 'creative' ? '#ffffff' : style.color.text,
|
|
fontFamily: 'inherit',
|
|
}}
|
|
>
|
|
{candidate.email}
|
|
</Typography>
|
|
</Box>
|
|
)}
|
|
|
|
{phone?.isValid() && (
|
|
<Box sx={{ display: 'flex', alignItems: 'center' }}>
|
|
<PhoneIcon
|
|
fontSize="small"
|
|
sx={{ mr: 1, color: style.name === 'creative' ? '#ffffff' : style.color.accent }}
|
|
/>
|
|
<Typography
|
|
variant="body2"
|
|
sx={{
|
|
color: style.name === 'creative' ? '#ffffff' : style.color.text,
|
|
fontFamily: 'inherit',
|
|
}}
|
|
>
|
|
{phone.formatInternational()}
|
|
</Typography>
|
|
</Box>
|
|
)}
|
|
|
|
{candidate.location && (
|
|
<Box sx={{ display: 'flex', alignItems: 'center' }}>
|
|
<LocationIcon
|
|
fontSize="small"
|
|
sx={{ mr: 1, color: style.name === 'creative' ? '#ffffff' : style.color.accent }}
|
|
/>
|
|
<Typography
|
|
variant="body2"
|
|
sx={{
|
|
color: style.name === 'creative' ? '#ffffff' : style.color.text,
|
|
fontFamily: 'inherit',
|
|
}}
|
|
>
|
|
{candidate.location.city
|
|
? `${candidate.location.city}, ${candidate.location.state}`
|
|
: candidate.location.text}
|
|
</Typography>
|
|
</Box>
|
|
)}
|
|
</Box>
|
|
</Box>
|
|
</Box>
|
|
</Box>
|
|
);
|
|
};
|
|
|
|
const ResumeInfo: React.FC<ResumeInfoProps> = (props: ResumeInfoProps) => {
|
|
const { setSnack } = useAppState();
|
|
const { resume } = props;
|
|
const { user, apiClient } = useAuth();
|
|
const { sx, 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 [deleted, setDeleted] = useState<boolean>(false);
|
|
const [editDialogOpen, setEditDialogOpen] = useState<boolean>(false);
|
|
const [editContent, setEditContent] = useState<string>('');
|
|
const [editSystemPrompt, setEditSystemPrompt] = useState<string>('');
|
|
const [editPrompt, setEditPrompt] = useState<string>('');
|
|
const [saving, setSaving] = useState<boolean>(false);
|
|
const [tabValue, setTabValue] = useState('markdown');
|
|
const [jobTabValue, setJobTabValue] = useState('chat');
|
|
const [status, setStatus] = useState<string>('');
|
|
const [statusType, setStatusType] = useState<Types.ApiActivityType | null>(null);
|
|
const [error, setError] = useState<Types.ChatMessageError | null>(null);
|
|
const [selectedStyle, setSelectedStyle] = useState<string>('corporate');
|
|
|
|
// New revision-related state
|
|
const [revisions, setRevisions] = useState<ResumeRevision[]>([]);
|
|
const [selectedRevision, setSelectedRevision] = useState<string>('current');
|
|
const [loadingRevisions, setLoadingRevisions] = useState<boolean>(false);
|
|
const [loadingRevision, setLoadingRevision] = useState<boolean>(false);
|
|
const [revisionContent, setRevisionContent] = useState<string>('');
|
|
|
|
const printContentRef = useRef<HTMLDivElement>(null);
|
|
const reactToPrintFn = useReactToPrint({
|
|
contentRef: printContentRef,
|
|
pageStyle: '@page { margin: 8mm !important; }',
|
|
});
|
|
|
|
useEffect(() => {
|
|
if (resume && resume.id !== activeResume?.id) {
|
|
setActiveResume(resume);
|
|
}
|
|
}, [resume, activeResume]);
|
|
|
|
// Load revisions when dialog opens
|
|
useEffect(() => {
|
|
if (editDialogOpen && activeResume.id) {
|
|
loadResumeRevisions();
|
|
}
|
|
}, [editDialogOpen, activeResume.id]);
|
|
|
|
const currentStyle = useMemo(() => {
|
|
return resumeStyles[selectedStyle];
|
|
}, [selectedStyle]);
|
|
|
|
// Load resume revisions
|
|
const loadResumeRevisions = async (): Promise<void> => {
|
|
if (!activeResume.id) return;
|
|
|
|
setLoadingRevisions(true);
|
|
try {
|
|
const response = await apiClient.getResumeRevisions(activeResume.id);
|
|
console.log('Loaded revisions:', response.revisions);
|
|
setRevisions(response.revisions || []);
|
|
} catch (error) {
|
|
if (error instanceof Error) {
|
|
console.error('Failed to load revisions:', error);
|
|
setSnack(`Failed to load resume revisions: ${error.message || 'Unknown error'}`);
|
|
}
|
|
} finally {
|
|
setLoadingRevisions(false);
|
|
}
|
|
};
|
|
|
|
// Load specific revision content
|
|
const loadRevisionContent = async (revisionId: string): Promise<void> => {
|
|
if (!activeResume.id || revisionId === 'current') {
|
|
setRevisionContent(editContent);
|
|
return;
|
|
}
|
|
|
|
setLoadingRevision(true);
|
|
try {
|
|
const response = await apiClient.getResumeRevision(activeResume.id, revisionId);
|
|
setRevisionContent(response.revision.resume || '');
|
|
} catch (error) {
|
|
console.error('Failed to load revision:', error);
|
|
setSnack('Failed to load revision content.');
|
|
} finally {
|
|
setLoadingRevision(false);
|
|
}
|
|
};
|
|
|
|
// Handle revision selection
|
|
const handleRevisionChange = async (event: SelectChangeEvent<string>): Promise<void> => {
|
|
const newRevisionId = event.target.value;
|
|
setSelectedRevision(newRevisionId);
|
|
await loadRevisionContent(newRevisionId);
|
|
};
|
|
|
|
// Restore revision to editor
|
|
const restoreRevision = (): void => {
|
|
setEditContent(revisionContent);
|
|
setSelectedRevision('current');
|
|
setSnack('Revision restored to editor.');
|
|
};
|
|
|
|
// Format timestamp for display
|
|
const formatRevisionTimestamp = (timestamp: string): string => {
|
|
try {
|
|
const date = new Date(timestamp);
|
|
return date.toLocaleString('en-US', {
|
|
month: 'short',
|
|
day: 'numeric',
|
|
year: 'numeric',
|
|
hour: '2-digit',
|
|
minute: '2-digit',
|
|
});
|
|
} catch {
|
|
return timestamp;
|
|
}
|
|
};
|
|
|
|
// Rest of the existing methods...
|
|
const deleteResume = async (id: string | undefined): Promise<void> => {
|
|
if (id) {
|
|
try {
|
|
await apiClient.deleteResume(id);
|
|
setDeleted(true);
|
|
setSnack('Resume deleted successfully.');
|
|
} catch (error) {
|
|
setSnack('Failed to delete resume.');
|
|
}
|
|
}
|
|
};
|
|
|
|
const handleReset = async (): Promise<void> => {
|
|
setActiveResume({ ...resume });
|
|
};
|
|
|
|
const handleSave = async (): Promise<void> => {
|
|
setSaving(true);
|
|
try {
|
|
const resumeUpdate = {
|
|
...activeResume,
|
|
resume: editContent,
|
|
systemPrompt: editSystemPrompt,
|
|
prompt: editPrompt,
|
|
};
|
|
const result = await apiClient.updateResume(resumeUpdate);
|
|
console.log('Resume updated:', result);
|
|
const updatedResume = {
|
|
...activeResume,
|
|
...result,
|
|
};
|
|
setActiveResume(updatedResume);
|
|
setSnack('Resume updated successfully.');
|
|
// Reload revisions to include the new version
|
|
await loadResumeRevisions();
|
|
} catch (error) {
|
|
setSnack('Failed to update resume.');
|
|
} finally {
|
|
setSaving(false);
|
|
}
|
|
};
|
|
|
|
const handleEditOpen = (): void => {
|
|
setEditContent(activeResume.resume);
|
|
setEditSystemPrompt(activeResume.systemPrompt || '');
|
|
setEditPrompt(activeResume.prompt || '');
|
|
setSelectedRevision('current');
|
|
setRevisionContent(activeResume.resume);
|
|
setEditDialogOpen(true);
|
|
};
|
|
|
|
if (!resume) {
|
|
return <Box>No resume provided.</Box>;
|
|
}
|
|
|
|
const formatDate = (date: Date | undefined): string => {
|
|
if (!date) return 'N/A';
|
|
try {
|
|
return new Intl.DateTimeFormat('en-US', {
|
|
month: 'short',
|
|
day: 'numeric',
|
|
year: 'numeric',
|
|
hour: '2-digit',
|
|
minute: '2-digit',
|
|
}).format(date);
|
|
} catch (error) {
|
|
console.error('Error formatting date:', error);
|
|
return 'Invalid date';
|
|
}
|
|
};
|
|
|
|
const generateResumeHandlers: StreamingOptions<Types.ChatMessageResume> = {
|
|
onMessage: (message: Types.ChatMessageResume) => {
|
|
const resume: Resume = message.resume;
|
|
setEditSystemPrompt(resume.systemPrompt || '');
|
|
setEditPrompt(resume.prompt || '');
|
|
setEditContent(resume.resume);
|
|
setActiveResume({ ...resume });
|
|
setStatus('');
|
|
setSnack('Resume generation completed successfully.');
|
|
},
|
|
onStreaming: (chunk: Types.ChatMessageStreaming) => {
|
|
if (status === '') {
|
|
setStatus('Generating resume...');
|
|
setStatusType('generating');
|
|
}
|
|
setEditContent(chunk.content);
|
|
},
|
|
onStatus: (status: Types.ChatMessageStatus) => {
|
|
console.log('status:', status.content);
|
|
setStatusType(status.activity);
|
|
setStatus(status.content);
|
|
},
|
|
onError: (error: Types.ChatMessageError) => {
|
|
console.log('error:', error);
|
|
setStatusType(null);
|
|
setStatus(error.content);
|
|
setError(error);
|
|
},
|
|
};
|
|
|
|
const generateResume = async (): Promise<void> => {
|
|
setStatusType('thinking');
|
|
setStatus('Starting resume generation...');
|
|
setActiveResume({ ...activeResume, resume: '' });
|
|
const request = await apiClient.generateResume(
|
|
activeResume.candidateId || '',
|
|
activeResume.jobId || '',
|
|
generateResumeHandlers
|
|
);
|
|
await request.promise;
|
|
};
|
|
|
|
const handleTabChange = (event: React.SyntheticEvent, newValue: string): void => {
|
|
if (newValue === 'print') {
|
|
reactToPrintFn();
|
|
return;
|
|
}
|
|
if (newValue === 'regenerate') {
|
|
setSnack('Regenerating resume...');
|
|
generateResume();
|
|
return;
|
|
}
|
|
setTabValue(newValue);
|
|
};
|
|
|
|
const handleJobTabChange = (event: React.SyntheticEvent, newValue: string): void => {
|
|
setJobTabValue(newValue);
|
|
};
|
|
|
|
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,
|
|
}}
|
|
>
|
|
<Box sx={{ display: 'flex', flexDirection: 'column', flexGrow: 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>
|
|
</>
|
|
)}
|
|
</Box>
|
|
|
|
<Box sx={{ display: 'flex', flexDirection: 'column', flexGrow: 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">
|
|
Job ID: {activeResume.job?.id}
|
|
</Typography>
|
|
<Typography variant="caption" color="text.secondary">
|
|
Resume ID: {activeResume.id}
|
|
</Typography>
|
|
</Box>
|
|
</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={
|
|
<Tooltip title="Edit Resume Content">
|
|
<IconButton size="small" onClick={handleEditOpen}>
|
|
<EditIcon />
|
|
</IconButton>
|
|
</Tooltip>
|
|
}
|
|
/>
|
|
{variant === 'all' && activeResume.resume && (
|
|
<CardContent sx={{ p: 0 }}>
|
|
<StyledMarkdown content={activeResume.resume} />
|
|
</CardContent>
|
|
)}
|
|
</Card>
|
|
)}
|
|
</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): void => {
|
|
e.stopPropagation();
|
|
handleEditOpen();
|
|
}}
|
|
>
|
|
<EditIcon />
|
|
</IconButton>
|
|
</Tooltip>
|
|
|
|
<Tooltip title="Delete Resume">
|
|
<IconButton
|
|
size="small"
|
|
onClick={(e): void => {
|
|
e.stopPropagation();
|
|
deleteResume(activeResume.id);
|
|
}}
|
|
>
|
|
<DeleteIcon />
|
|
</IconButton>
|
|
</Tooltip>
|
|
|
|
<Tooltip title="Reset Resume">
|
|
<IconButton
|
|
size="small"
|
|
onClick={(e): void => {
|
|
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={(): void => {
|
|
setEditDialogOpen(false);
|
|
}}
|
|
maxWidth="lg"
|
|
fullWidth
|
|
disableEscapeKeyDown={true}
|
|
fullScreen={true}
|
|
>
|
|
<DialogTitle>
|
|
<Box sx={{ display: 'flex', flexDirection: 'row', gap: 1 }}>
|
|
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.5, m: 0, p: 0 }}>
|
|
Edit Resume Content
|
|
<Typography variant="caption" display="block" color="text.secondary">
|
|
Resume for {activeResume.candidate?.fullName || activeResume.candidateId},{' '}
|
|
{activeResume.job?.title || 'No Job Title Assigned'},{' '}
|
|
{activeResume.job?.company || 'No Company Assigned'}
|
|
</Typography>
|
|
<Typography variant="caption" display="block" color="text.secondary">
|
|
Resume ID: # {activeResume.id}
|
|
</Typography>
|
|
<Typography variant="caption" display="block" color="text.secondary">
|
|
Last saved:{' '}
|
|
{activeResume.updatedAt ? new Date(activeResume.updatedAt).toLocaleString() : 'N/A'}
|
|
</Typography>
|
|
</Box>
|
|
<Box
|
|
sx={{
|
|
display: 'flex',
|
|
flexGrow: 1,
|
|
flexDirection: 'row',
|
|
gap: 1,
|
|
}}
|
|
>
|
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2 }}>
|
|
{/* Style Selector */}
|
|
<FormControl size="small" sx={{ minWidth: 'min-content' }}>
|
|
<InputLabel id="resume-style-label">Resume Style</InputLabel>
|
|
<Select
|
|
labelId="resume-style-label"
|
|
value={selectedStyle}
|
|
onChange={e => setSelectedStyle(e.target.value)}
|
|
label="Resume Style"
|
|
>
|
|
{Object.entries(resumeStyles).map(([key, style]) => (
|
|
<MenuItem key={key} value={key}>
|
|
<Box
|
|
sx={{
|
|
display: 'flex',
|
|
flexDirection: 'column',
|
|
alignItems: 'flex-start',
|
|
}}
|
|
>
|
|
<Typography variant="body2" fontWeight="bold">
|
|
{style.name}
|
|
</Typography>
|
|
<Typography variant="caption" color="text.secondary">
|
|
{style.description}
|
|
</Typography>
|
|
</Box>
|
|
</MenuItem>
|
|
))}
|
|
</Select>
|
|
</FormControl>
|
|
</Box>
|
|
|
|
<Tabs value={tabValue} onChange={handleTabChange}>
|
|
<Tab value="markdown" icon={<EditDocumentIcon />} label="Markdown" />
|
|
{activeResume.systemPrompt && (
|
|
<Tab value="systemPrompt" icon={<TuneIcon />} label="System Prompt" />
|
|
)}
|
|
{activeResume.systemPrompt && (
|
|
<Tab value="prompt" icon={<InputIcon />} label="Prompt" />
|
|
)}
|
|
<Tab value="preview" icon={<PreviewIcon />} label="Preview" />
|
|
<Tab value="print" icon={<PrintIcon />} label="Print" />
|
|
<Tab value="regenerate" icon={<ModelTraining />} label="Regenerate" />
|
|
</Tabs>
|
|
</Box>
|
|
</Box>
|
|
</DialogTitle>
|
|
<DialogContent
|
|
sx={{
|
|
position: 'relative',
|
|
display: 'flex',
|
|
flexDirection: 'column',
|
|
height: '100%',
|
|
}}
|
|
>
|
|
<Box
|
|
sx={{
|
|
display: 'flex',
|
|
flexDirection: 'row',
|
|
height: '100%',
|
|
gap: 1,
|
|
pt: 1,
|
|
width: '100%',
|
|
position: 'relative',
|
|
overflow: 'hidden',
|
|
}}
|
|
>
|
|
<Paper
|
|
sx={{
|
|
p: 1,
|
|
flex: 1,
|
|
display: 'flex',
|
|
flexDirection: 'column',
|
|
position: 'relative',
|
|
maxWidth: '100%',
|
|
height: '100%',
|
|
overflow: 'hidden',
|
|
}}
|
|
>
|
|
{status && (
|
|
<Box sx={{ mt: 0, mb: 1 }}>
|
|
<StatusBox>
|
|
{statusType && <StatusIcon type={statusType} />}
|
|
<Typography variant="body2" sx={{ ml: 1 }}>
|
|
{status || 'Processing...'}
|
|
</Typography>
|
|
</StatusBox>
|
|
{status && !error && <LinearProgress sx={{ mt: 1 }} />}
|
|
</Box>
|
|
)}
|
|
|
|
{/* Revision History Dropdown for Markdown Tab */}
|
|
{tabValue === 'markdown' && (
|
|
<Box sx={{ mb: 2 }}>
|
|
<Stack direction="row" spacing={2} alignItems="center">
|
|
<FormControl size="small" sx={{ minWidth: 200 }}>
|
|
<InputLabel id="revision-select-label">
|
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
|
<HistoryIcon fontSize="small" />
|
|
Version History
|
|
</Box>
|
|
</InputLabel>
|
|
<Select
|
|
labelId="revision-select-label"
|
|
value={selectedRevision}
|
|
onChange={handleRevisionChange}
|
|
label="Version History"
|
|
disabled={loadingRevisions}
|
|
>
|
|
<MenuItem value="current">
|
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
|
<Chip label="CURRENT" size="small" color="primary" variant="outlined" />
|
|
<Typography variant="body2">Current Version</Typography>
|
|
</Box>
|
|
</MenuItem>
|
|
{revisions.map(revision => (
|
|
<MenuItem key={revision.revisionId} value={revision.revisionId}>
|
|
<Box
|
|
sx={{
|
|
display: 'flex',
|
|
flexDirection: 'column',
|
|
alignItems: 'flex-start',
|
|
}}
|
|
>
|
|
<Typography variant="body2">
|
|
{formatRevisionTimestamp(revision.revisionTimestamp)}
|
|
</Typography>
|
|
<Typography variant="caption" color="text.secondary">
|
|
Updated: {formatRevisionTimestamp(revision.updatedAt)}
|
|
</Typography>
|
|
<Typography
|
|
variant="caption"
|
|
color="text.secondary"
|
|
sx={{ fontSize: '0.65rem' }}
|
|
>
|
|
ID: {revision.revisionId.substring(0, 8)}...
|
|
</Typography>
|
|
</Box>
|
|
</MenuItem>
|
|
))}
|
|
</Select>
|
|
</FormControl>
|
|
|
|
<Tooltip title="Refresh Revisions">
|
|
<IconButton
|
|
size="small"
|
|
onClick={loadResumeRevisions}
|
|
disabled={loadingRevisions}
|
|
>
|
|
<RefreshIcon />
|
|
</IconButton>
|
|
</Tooltip>
|
|
|
|
{selectedRevision !== 'current' && (
|
|
<Tooltip title="Restore this revision to editor">
|
|
<Button
|
|
size="small"
|
|
startIcon={<RestoreFromTrashIcon />}
|
|
onClick={restoreRevision}
|
|
disabled={loadingRevision}
|
|
>
|
|
Restore
|
|
</Button>
|
|
</Tooltip>
|
|
)}
|
|
</Stack>
|
|
|
|
{selectedRevision !== 'current' && (
|
|
<Alert severity="info" sx={{ mt: 1 }}>
|
|
You are viewing a previous version. Click "Restore" to load this
|
|
content into the editor.
|
|
</Alert>
|
|
)}
|
|
</Box>
|
|
)}
|
|
|
|
<Scrollable
|
|
sx={{
|
|
display: 'flex',
|
|
flexDirection: 'column',
|
|
height: '100%',
|
|
width: '100%',
|
|
minHeight: 0,
|
|
'& > *:not(.Scrollable)': {
|
|
flexShrink: 0,
|
|
},
|
|
position: 'relative',
|
|
}}
|
|
>
|
|
{tabValue === 'markdown' && (
|
|
<>
|
|
{selectedRevision === 'current' ? (
|
|
<BackstoryTextField
|
|
value={editContent}
|
|
onChange={(value): void => setEditContent(value)}
|
|
style={{
|
|
position: 'relative',
|
|
maxHeight: '100%',
|
|
height: '100%',
|
|
width: '100%',
|
|
display: 'flex',
|
|
minHeight: '100%',
|
|
flexGrow: 1,
|
|
flex: 1,
|
|
overflowY: 'auto',
|
|
}}
|
|
placeholder="Enter resume content..."
|
|
/>
|
|
) : (
|
|
<Box
|
|
sx={{
|
|
p: 2,
|
|
border: '1px solid',
|
|
borderColor: 'divider',
|
|
borderRadius: 1,
|
|
backgroundColor: 'grey.50',
|
|
height: '100%',
|
|
overflow: 'auto',
|
|
}}
|
|
>
|
|
{loadingRevision ? (
|
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
|
<LinearProgress sx={{ flexGrow: 1 }} />
|
|
<Typography variant="body2">Loading revision...</Typography>
|
|
</Box>
|
|
) : (
|
|
<pre style={{ border: 0 }}>{revisionContent}</pre>
|
|
)}
|
|
</Box>
|
|
)}
|
|
</>
|
|
)}
|
|
{tabValue === 'systemPrompt' && (
|
|
<BackstoryTextField
|
|
value={editSystemPrompt}
|
|
onChange={(value): void => setEditSystemPrompt(value)}
|
|
style={{
|
|
position: 'relative',
|
|
maxHeight: '100%',
|
|
width: '100%',
|
|
display: 'flex',
|
|
minHeight: '100%',
|
|
flexGrow: 1,
|
|
flex: 1,
|
|
overflowY: 'auto',
|
|
}}
|
|
placeholder="Edit system prompt..."
|
|
/>
|
|
)}
|
|
{tabValue === 'prompt' && (
|
|
<BackstoryTextField
|
|
value={editPrompt}
|
|
onChange={(value): void => setEditPrompt(value)}
|
|
style={{
|
|
position: 'relative',
|
|
maxHeight: '100%',
|
|
height: '100%',
|
|
width: '100%',
|
|
display: 'flex',
|
|
minHeight: '100%',
|
|
flexGrow: 1,
|
|
flex: 1,
|
|
overflowY: 'auto',
|
|
}}
|
|
placeholder="Edit prompt..."
|
|
/>
|
|
)}
|
|
{tabValue === 'preview' && (
|
|
<Box
|
|
className="document-container"
|
|
ref={printContentRef}
|
|
sx={currentStyle.contentStyle}
|
|
>
|
|
<Box
|
|
className="a4-document"
|
|
sx={{
|
|
backgroundColor: currentStyle.color.background,
|
|
padding: 5,
|
|
minHeight: '100vh',
|
|
boxShadow: '0 0 10px rgba(0,0,0,0.1)',
|
|
}}
|
|
>
|
|
{/* Custom Header */}
|
|
{activeResume.candidate && (
|
|
<StyledHeader candidate={activeResume.candidate} style={currentStyle} />
|
|
)}
|
|
|
|
{/* Styled Markdown Content */}
|
|
<Box sx={currentStyle.markdownStyle}>
|
|
<StyledMarkdown
|
|
sx={{
|
|
position: 'relative',
|
|
maxHeight: '100%',
|
|
display: 'flex',
|
|
flexGrow: 1,
|
|
flex: 1,
|
|
...currentStyle.markdownStyle,
|
|
}}
|
|
content={editContent || activeResume.resume || ''}
|
|
/>
|
|
</Box>
|
|
|
|
{/* QR Code Footer */}
|
|
{activeResume.candidate && activeResume.job && (
|
|
<StyledFooter
|
|
candidate={activeResume.candidate}
|
|
job={activeResume.job}
|
|
style={currentStyle}
|
|
/>
|
|
)}
|
|
</Box>
|
|
</Box>
|
|
)}
|
|
</Scrollable>
|
|
</Paper>
|
|
<Scrollable
|
|
sx={{
|
|
flex: 1,
|
|
display: 'flex',
|
|
height: '100%',
|
|
overflowY: 'auto',
|
|
position: 'relative',
|
|
}}
|
|
>
|
|
<Paper
|
|
sx={{
|
|
p: 1,
|
|
flex: 1,
|
|
display: 'flex',
|
|
flexDirection: 'column',
|
|
position: 'relative',
|
|
}}
|
|
>
|
|
<Tabs value={jobTabValue} onChange={handleJobTabChange}>
|
|
{activeResume.job !== undefined && (
|
|
<Tab value="job" icon={<WorkIcon />} label="Job" />
|
|
)}
|
|
<Tab value="chat" icon={<ModelTraining />} label="AI Edit" />
|
|
</Tabs>
|
|
|
|
{activeResume.job !== undefined && jobTabValue === 'job' && (
|
|
<JobInfo
|
|
variant={'all'}
|
|
job={activeResume.job}
|
|
sx={{
|
|
m: 0,
|
|
p: 1,
|
|
backgroundColor: '#f8f0e0',
|
|
}}
|
|
/>
|
|
)}
|
|
{jobTabValue === 'chat' && (
|
|
<ResumeChat
|
|
session={activeResume.id || ''}
|
|
resume={editContent}
|
|
onResumeChange={(newResume: string): void => {
|
|
setEditContent(newResume);
|
|
setActiveResume({ ...activeResume, resume: newResume });
|
|
}}
|
|
sx={{
|
|
m: 1,
|
|
p: 1,
|
|
flexGrow: 1,
|
|
}}
|
|
/>
|
|
)}
|
|
</Paper>
|
|
</Scrollable>
|
|
</Box>
|
|
</DialogContent>
|
|
<DialogActions>
|
|
<Button onClick={(): void => setEditDialogOpen(false)}>Cancel</Button>
|
|
<Button
|
|
onClick={handleSave}
|
|
variant="contained"
|
|
disabled={saving}
|
|
startIcon={<SaveIcon />}
|
|
>
|
|
{saving ? 'Saving...' : 'Save'}
|
|
</Button>
|
|
</DialogActions>
|
|
</Dialog>
|
|
</Box>
|
|
);
|
|
};
|
|
|
|
export { ResumeInfo };
|