653 lines
21 KiB
TypeScript

import React, { useEffect, useRef, useState } from 'react';
import {
Box,
Typography,
Grid,
SxProps,
Stack,
CardHeader,
Button,
LinearProgress,
IconButton,
Tooltip,
Card,
CardContent,
Divider,
useTheme,
useMediaQuery,
Dialog,
DialogTitle,
DialogContent,
DialogActions,
Tabs,
Tab,
Paper,
} 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,
} 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 { 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';
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, 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 [status, setStatus] = useState<string>('');
const [statusType, setStatusType] = useState<Types.ApiActivityType | null>(null);
const [error, setError] = useState<Types.ChatMessageError | null>(null);
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]);
// Check if content needs truncation
const deleteResume = async (id: string | undefined) => {
if (id) {
try {
await apiClient.deleteResume(id);
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 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.');
} catch (error) {
setSnack('Failed to update resume.');
} finally {
setSaving(false);
}
};
const handleEditOpen = () => {
setEditContent(activeResume.resume);
setEditSystemPrompt(activeResume.systemPrompt || '');
setEditPrompt(activeResume.prompt || '');
setEditDialogOpen(true);
};
if (!resume) {
return <Box>No resume provided.</Box>;
}
const formatDate = (date: Date | undefined) => {
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: '' }); // Reset resume content
const request = await apiClient.generateResume(
activeResume.candidateId || '',
activeResume.jobId || '',
generateResumeHandlers
);
await request.promise;
};
const handleTabChange = (event: React.SyntheticEvent, newValue: string) => {
if (newValue === 'print') {
reactToPrintFn();
return;
}
if (newValue === 'regenerate') {
// Handle resume regeneration logic here
setSnack('Regenerating resume...');
generateResume();
return;
}
setTabValue(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,
}}
>
<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.id}
</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' }}>
<Scrollable sx={{ maxHeight: '10rem', overflowY: 'auto' }}>
<Box
sx={{
display: 'flex',
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}
</Box>
</Scrollable>
</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
disableEscapeKeyDown={true}
fullScreen={true}
>
<DialogTitle>
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>
</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',
}}
>
<Tabs value={tabValue} onChange={handleTabChange} centered>
<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>
{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>
)}
<Scrollable
sx={{
display: 'flex',
flexDirection: 'column',
height: '100%' /* Restrict to main-container's height */,
width: '100%',
minHeight: 0 /* Prevent flex overflow */,
//maxHeight: "min-content",
'& > *:not(.Scrollable)': {
flexShrink: 0 /* Prevent shrinking */,
},
position: 'relative',
}}
>
{tabValue === 'markdown' && (
<BackstoryTextField
value={editContent}
onChange={value => setEditContent(value)}
style={{
position: 'relative',
maxHeight: '100%',
height: '100%',
width: '100%',
display: 'flex',
minHeight: '100%',
flexGrow: 1,
flex: 1 /* Take remaining space in some-container */,
overflowY: 'auto' /* Scroll if content overflows */,
}}
placeholder="Enter resume content..."
/>
)}
{tabValue === 'systemPrompt' && (
<BackstoryTextField
value={editSystemPrompt}
onChange={value => setEditSystemPrompt(value)}
style={{
position: 'relative',
maxHeight: '100%',
// height: '100%',
width: '100%',
display: 'flex',
minHeight: '100%',
flexGrow: 1,
flex: 1 /* Take remaining space in some-container */,
overflowY: 'auto' /* Scroll if content overflows */,
}}
placeholder="Edit system prompt..."
/>
)}
{tabValue === 'prompt' && (
<BackstoryTextField
value={editPrompt}
onChange={value => setEditPrompt(value)}
style={{
position: 'relative',
maxHeight: '100%',
height: '100%',
width: '100%',
display: 'flex',
minHeight: '100%',
flexGrow: 1,
flex: 1 /* Take remaining space in some-container */,
overflowY: 'auto' /* Scroll if content overflows */,
}}
placeholder="Edit prompt..."
/>
)}
{tabValue === 'preview' && (
<Box className="document-container" ref={printContentRef}>
<Box className="a4-document">
<StyledMarkdown
sx={{
position: 'relative',
maxHeight: '100%',
display: 'flex',
flexGrow: 1,
flex: 1 /* Take remaining space in some-container */,
// overflowY: 'auto' /* Scroll if content overflows */,
}}
content={editContent}
/>
</Box>
<Box sx={{ p: 2 }}>&nbsp;</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',
backgroundColor: '#f8f0e0',
}}
>
{activeResume.job !== undefined && (
<JobInfo
variant={'all'}
job={activeResume.job}
sx={{
mt: 2,
backgroundColor: '#f8f0e0', //theme.palette.background.paper,
}}
/>
)}
</Paper>
</Scrollable>
</Box>
</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 };