Improved resume editing
This commit is contained in:
parent
65471b9fe0
commit
35a296889b
@ -86,7 +86,6 @@ const ResumeChat = forwardRef<ConversationHandle, ResumeChatProps>(
|
||||
const [loading, setLoading] = useState<boolean>(false);
|
||||
const [streaming, setStreaming] = useState<boolean>(false);
|
||||
const messagesEndRef = useRef<HTMLDivElement>(null);
|
||||
const [lastPrompt, setLastPrompt] = useState<string>('');
|
||||
|
||||
const onDelete = async (session: ChatSession): Promise<void> => {
|
||||
if (!session.id) {
|
||||
@ -109,7 +108,6 @@ const ResumeChat = forwardRef<ConversationHandle, ResumeChatProps>(
|
||||
|
||||
const messageContent = message;
|
||||
setStreaming(true);
|
||||
setLastPrompt(message);
|
||||
|
||||
const chatMessage: ChatMessageUser = {
|
||||
sessionId: chatSession.id,
|
||||
@ -141,7 +139,7 @@ const ResumeChat = forwardRef<ConversationHandle, ResumeChatProps>(
|
||||
isAnswer: true,
|
||||
};
|
||||
} else {
|
||||
onResumeChange(lastPrompt, msg.content);
|
||||
onResumeChange(message, msg.content);
|
||||
}
|
||||
|
||||
setMessages(prev => {
|
||||
@ -167,11 +165,21 @@ const ResumeChat = forwardRef<ConversationHandle, ResumeChatProps>(
|
||||
},
|
||||
onStreaming: (chunk: ChatMessageStreaming): void => {
|
||||
// console.log("onStreaming:", chunk);
|
||||
if (chunk.content[0] === 'A') {
|
||||
chunk.content = chunk.content.replace(/A[^:]*:?/, '');
|
||||
const prefix = 'ANSWER:';
|
||||
let content = chunk.content || '';
|
||||
for (let i = prefix.length; i >= 1; i--) {
|
||||
if (chunk.content.startsWith(prefix.slice(0, i))) {
|
||||
content = chunk.content.slice(i);
|
||||
break;
|
||||
}
|
||||
}
|
||||
content = content.trim();
|
||||
if (streamingMessage?.content === content) {
|
||||
return; // Avoid duplicate updates
|
||||
}
|
||||
setStreamingMessage({
|
||||
...chunk,
|
||||
content,
|
||||
role: 'assistant',
|
||||
metadata: emptyMetadata,
|
||||
});
|
||||
@ -226,9 +234,10 @@ const ResumeChat = forwardRef<ConversationHandle, ResumeChatProps>(
|
||||
try {
|
||||
const result = await apiClient.getChatMessages(chatSession.id);
|
||||
const chatMessages: ChatMessage[] = result.data;
|
||||
console.log(`getChatMessages returned ${chatMessages.length} messages.`, chatMessages);
|
||||
chatMessages.forEach(msg => {
|
||||
if (msg.role === 'assistant' && msg.content.match(/^ANSWER:\s*/)) {
|
||||
msg.content = msg.content.replace(/^ANSWER:\s*/, '');
|
||||
msg.content = msg.content.replace(/^ANSWER: */, '');
|
||||
msg.extraContext = {
|
||||
isAnswer: true,
|
||||
};
|
||||
@ -238,7 +247,6 @@ const ResumeChat = forwardRef<ConversationHandle, ResumeChatProps>(
|
||||
setMessages(chatMessages);
|
||||
setProcessingMessage(null);
|
||||
setStreamingMessage(null);
|
||||
console.log(`getChatMessages returned ${chatMessages.length} messages.`, chatMessages);
|
||||
} catch (error) {
|
||||
console.error('Failed to load messages:', error);
|
||||
}
|
||||
|
@ -27,7 +27,7 @@ interface BackstoryPageContainerProps {
|
||||
|
||||
const BackstoryPageContainer = (props: BackstoryPageContainerProps): JSX.Element => {
|
||||
const { children, variant = 'normal' } = props;
|
||||
console.log({ variant });
|
||||
|
||||
return (
|
||||
<Container
|
||||
className="BackstoryPageContainer"
|
||||
|
792
frontend/src/components/ui/ResumeEdit.tsx
Normal file
792
frontend/src/components/ui/ResumeEdit.tsx
Normal file
@ -0,0 +1,792 @@
|
||||
import React, { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import {
|
||||
Box,
|
||||
Typography,
|
||||
Button,
|
||||
LinearProgress,
|
||||
IconButton,
|
||||
Tooltip,
|
||||
Tabs,
|
||||
Tab,
|
||||
Paper,
|
||||
FormControl,
|
||||
Select,
|
||||
MenuItem,
|
||||
InputLabel,
|
||||
Chip,
|
||||
Alert,
|
||||
Stack,
|
||||
SelectChangeEvent,
|
||||
} from '@mui/material';
|
||||
import PrintIcon from '@mui/icons-material/Print';
|
||||
import DifferenceIcon from '@mui/icons-material/Difference';
|
||||
import {
|
||||
Save as SaveIcon,
|
||||
ModelTraining,
|
||||
History as HistoryIcon,
|
||||
RestoreFromTrash as RestoreFromTrashIcon,
|
||||
Refresh as RefreshIcon,
|
||||
Work as WorkIcon,
|
||||
Undo as UndoIcon,
|
||||
} from '@mui/icons-material';
|
||||
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 { Resume } from 'types/types';
|
||||
import { BackstoryTextField } from 'components/BackstoryTextField';
|
||||
import { JobInfo } from './JobInfo';
|
||||
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';
|
||||
import { DiffViewer } from 'components/DiffViewer';
|
||||
import { ResumePreview, resumeStyles } from './ResumePreview';
|
||||
import { useAppState } from 'hooks/GlobalContext';
|
||||
|
||||
interface ResumeRevision {
|
||||
revisionId: string;
|
||||
revisionTimestamp: string;
|
||||
updatedAt: string;
|
||||
createdAt: string;
|
||||
candidateId: string;
|
||||
jobId: string;
|
||||
}
|
||||
|
||||
interface ResumeEditProps {
|
||||
onClose?: () => void;
|
||||
resumeId?: string;
|
||||
onSave?: (updatedResume: Resume) => void;
|
||||
}
|
||||
|
||||
const ResumeEdit: React.FC<ResumeEditProps> = (props: ResumeEditProps) => {
|
||||
const { setSnack } = useAppState();
|
||||
const { onClose, resumeId, onSave } = props;
|
||||
const { selectedCandidate, selectedJob } = useAppState();
|
||||
const { apiClient } = useAuth();
|
||||
const [editContent, setEditContent] = 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');
|
||||
const [resume, setResume] = useState<Resume | null>(null);
|
||||
const [isAIGenerated, setIsAIGenerated] = useState<boolean>(false);
|
||||
const [editPrompt, setEditPrompt] = useState<string>('');
|
||||
|
||||
// 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 [saveDisabled, setSaveDisabled] = useState<boolean>(true);
|
||||
const [current, setCurrent] = useState<Types.Resume | null>(null);
|
||||
const [backupContent, setBackupContent] = useState<string>('');
|
||||
|
||||
const printContentRef = useRef<HTMLDivElement>(null);
|
||||
const reactToPrintFn = useReactToPrint({
|
||||
contentRef: printContentRef,
|
||||
pageStyle: '@page { margin: 8mm !important; }',
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
setSaveDisabled(
|
||||
!resume ||
|
||||
statusType !== null ||
|
||||
saving ||
|
||||
editContent.trim() === '' ||
|
||||
selectedRevision !== 'current' ||
|
||||
(editContent === resume?.resume && isAIGenerated === false)
|
||||
);
|
||||
}, [resume, statusType, saving, editContent, isAIGenerated]);
|
||||
|
||||
// Initialize edit content when dialog opens
|
||||
useEffect(() => {
|
||||
if (!selectedCandidate || !selectedJob) {
|
||||
setSnack('No candidate or job selected for resume generation.', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!resumeId && !resume) {
|
||||
apiClient
|
||||
.getResumesByCandidate(selectedCandidate.id || '', selectedJob.id || '')
|
||||
.then(resumes => {
|
||||
if (resumes.length > 0) {
|
||||
console.log(
|
||||
`Found ${resumes.length} resumes for candidate ${selectedCandidate.id} and job ${selectedJob.id}`
|
||||
);
|
||||
setResume(resumes[0]);
|
||||
setCurrent(resumes[0]);
|
||||
setBackupContent(resumes[0].resume);
|
||||
} else {
|
||||
console.log(
|
||||
`No resumes found for candidate ${selectedCandidate.id} and job ${selectedJob.id}. Generating new resume...`
|
||||
);
|
||||
generateResume();
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
console.log(error);
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (!resume) {
|
||||
apiClient.getResume(resumeId || '').then(loadedResume => {
|
||||
console.log(`Loading resume with ID: ${resumeId}`);
|
||||
setResume(loadedResume);
|
||||
setCurrent(loadedResume);
|
||||
setBackupContent(loadedResume.resume);
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`Resume was ${resume.aiGenerated ? 'AI-generated' : 'user-generated'}`, resume);
|
||||
setEditPrompt(resume.prompt || '');
|
||||
setIsAIGenerated(resume.aiGenerated || false);
|
||||
setEditContent(resume.resume);
|
||||
loadResumeRevisions();
|
||||
}, [resume]);
|
||||
|
||||
const currentStyle = useMemo(() => {
|
||||
return resumeStyles[selectedStyle];
|
||||
}, [selectedStyle]);
|
||||
|
||||
// Load resume revisions
|
||||
const loadResumeRevisions = async (): Promise<void> => {
|
||||
if (!resume) return;
|
||||
|
||||
setLoadingRevisions(true);
|
||||
try {
|
||||
const response = await apiClient.getResumeRevisions(resume.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'}`, 'error');
|
||||
}
|
||||
} finally {
|
||||
setLoadingRevisions(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Load specific revision content
|
||||
const loadRevision = async (revisionId: string): Promise<void> => {
|
||||
if (!resume || revisionId === 'current') {
|
||||
return;
|
||||
}
|
||||
|
||||
setLoadingRevision(true);
|
||||
try {
|
||||
const response = await apiClient.getResumeRevision(resume.id || '', revisionId);
|
||||
setResume(response.revision);
|
||||
setEditPrompt(response.revision.prompt || '');
|
||||
setIsAIGenerated(response.revision.aiGenerated || false);
|
||||
} catch (error) {
|
||||
console.error('Failed to load revision:', error);
|
||||
setSnack('Failed to load revision content.', 'error');
|
||||
} finally {
|
||||
setLoadingRevision(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Handle revision selection
|
||||
const handleRevisionChange = async (event: SelectChangeEvent<string>): Promise<void> => {
|
||||
const newRevisionId = event.target.value;
|
||||
if (selectedRevision === 'current') {
|
||||
setEditPrompt(current?.prompt || '');
|
||||
setIsAIGenerated(current?.aiGenerated || false);
|
||||
setSelectedRevision(newRevisionId);
|
||||
setBackupContent(editContent);
|
||||
} else {
|
||||
setSelectedRevision(newRevisionId);
|
||||
if (newRevisionId === 'current') {
|
||||
setEditContent(backupContent);
|
||||
}
|
||||
await loadRevision(newRevisionId);
|
||||
}
|
||||
};
|
||||
|
||||
// Restore revision to editor
|
||||
const restoreRevision = (): void => {
|
||||
if (!resume) {
|
||||
setSnack('No revision selected to restore.', 'error');
|
||||
return;
|
||||
}
|
||||
setResume(resume);
|
||||
setEditContent(resume.resume);
|
||||
setSelectedRevision('current');
|
||||
setSnack('Revision restored to editor.', 'warning');
|
||||
};
|
||||
|
||||
// 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;
|
||||
}
|
||||
};
|
||||
|
||||
const handleSave = async (): Promise<void> => {
|
||||
if (!resume) {
|
||||
setSnack('No resume to save.', 'error');
|
||||
return;
|
||||
}
|
||||
setSaving(true);
|
||||
try {
|
||||
const resumeUpdate = {
|
||||
...resume,
|
||||
resume: editContent,
|
||||
prompt: isAIGenerated ? editPrompt : 'Manual edits',
|
||||
};
|
||||
const result = await apiClient.updateResume(resumeUpdate);
|
||||
console.log('Resume updated:', result);
|
||||
const updatedResume = {
|
||||
...resume,
|
||||
...result,
|
||||
};
|
||||
setResume(updatedResume);
|
||||
setCurrent(updatedResume);
|
||||
setBackupContent(updatedResume.resume);
|
||||
onSave && onSave(updatedResume);
|
||||
setSnack('Resume updated successfully.');
|
||||
// Reload revisions to include the new version
|
||||
await loadResumeRevisions();
|
||||
} catch (error) {
|
||||
setSnack('Failed to update resume.', 'error');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const generateResumeHandlers: StreamingOptions<Types.ChatMessageResume> = {
|
||||
onMessage: (_message: Types.ChatMessageResume) => {
|
||||
setStatus('');
|
||||
setStatusType(null);
|
||||
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<Resume> => {
|
||||
if (!selectedCandidate || !selectedJob) {
|
||||
setSnack('No candidate or job selected for resume generation.', 'error');
|
||||
return Promise.reject('No candidate or job selected');
|
||||
}
|
||||
setEditContent('');
|
||||
setStatusType('thinking');
|
||||
setStatus('Starting resume generation...');
|
||||
setSelectedRevision('current');
|
||||
const request = apiClient.generateResume(
|
||||
selectedCandidate.id || '',
|
||||
selectedJob.id || '',
|
||||
generateResumeHandlers
|
||||
);
|
||||
const generatedResume = (await request.promise).resume;
|
||||
setResume(generatedResume);
|
||||
setCurrent(generatedResume);
|
||||
setBackupContent(generatedResume.resume);
|
||||
setSelectedRevision('current');
|
||||
setEditPrompt(generatedResume.prompt || '');
|
||||
setIsAIGenerated(true);
|
||||
await loadResumeRevisions();
|
||||
return generatedResume;
|
||||
};
|
||||
|
||||
const handleTabChange = (event: React.SyntheticEvent, newValue: string): void => {
|
||||
if (newValue === 'print') {
|
||||
console.log('Printing resume...');
|
||||
reactToPrintFn();
|
||||
return;
|
||||
}
|
||||
if (newValue === 'regenerate') {
|
||||
setSnack('Regenerating resume...');
|
||||
setTabValue('markdown');
|
||||
generateResume();
|
||||
return;
|
||||
}
|
||||
if (newValue === 'undo') {
|
||||
setEditContent(current?.resume || '');
|
||||
setEditPrompt(current?.prompt || '');
|
||||
setIsAIGenerated(current?.aiGenerated || false);
|
||||
setTabValue('markdown');
|
||||
return;
|
||||
}
|
||||
setTabValue(newValue);
|
||||
};
|
||||
|
||||
const handleJobTabChange = (event: React.SyntheticEvent, newValue: string): void => {
|
||||
setJobTabValue(newValue);
|
||||
};
|
||||
|
||||
const handleClose = (): void => {
|
||||
onClose && onClose();
|
||||
};
|
||||
|
||||
const changeLog =
|
||||
(selectedRevision !== 'current' && resume?.aiGenerated ? resume?.prompt : '') ||
|
||||
(isAIGenerated ? editPrompt : '') ||
|
||||
'Manual edits';
|
||||
console.log(
|
||||
`Change log: ${changeLog}, isAIGenerated: ${isAIGenerated}, editPrompt: ${editPrompt}, selectedRevision: ${selectedRevision}, resume?.aiGenerated: ${resume?.aiGenerated}`
|
||||
);
|
||||
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
flexGrow: 1,
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
flexDirection: 'column',
|
||||
position: 'relative',
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
p: 2,
|
||||
position: 'relative',
|
||||
}}
|
||||
>
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column' }}>
|
||||
<Box sx={{ display: 'flex', flexDirection: 'row', gap: 1 }}>
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: 0.5,
|
||||
m: 0,
|
||||
p: 0,
|
||||
minWidth: '25rem',
|
||||
}}
|
||||
>
|
||||
{resume && (
|
||||
<>
|
||||
<Typography variant="caption" display="block" color="text.secondary">
|
||||
Resume for {resume.candidate?.fullName || resume.candidateId},{' '}
|
||||
{resume.job?.title || 'No Job Title Assigned'},{' '}
|
||||
{resume.job?.company || 'No Company Assigned'}
|
||||
</Typography>
|
||||
<Typography variant="caption" display="block" color="text.secondary">
|
||||
Resume ID: {resume.id}
|
||||
</Typography>
|
||||
</>
|
||||
)}
|
||||
{!resume && (
|
||||
<Typography variant="caption" display="block" color="text.secondary">
|
||||
No resume loaded. Please wait for generation to complete.
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
<Box sx={{ display: 'flex', alignItems: 'flex-start', 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 sx={{ display: 'flex', flexDirection: 'row' }}>
|
||||
<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>
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
<Box
|
||||
sx={{
|
||||
position: 'relative',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
overflow: 'hidden',
|
||||
height: '100%',
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
flexDirection: 'row',
|
||||
height: '100%',
|
||||
gap: 1,
|
||||
pt: 1,
|
||||
width: '100%',
|
||||
position: 'relative',
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
<Paper
|
||||
sx={{
|
||||
flex: 1,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
position: 'relative',
|
||||
maxWidth: '100%',
|
||||
height: '100%',
|
||||
overflow: 'hidden',
|
||||
alignItems: 'center',
|
||||
}}
|
||||
>
|
||||
<Box sx={{ display: 'flex', m: 0, p: 0 }}>
|
||||
<Tabs value={tabValue} onChange={handleTabChange}>
|
||||
<Tab value="markdown" icon={<EditDocumentIcon />} label="Markdown" />
|
||||
<Tab
|
||||
value="diff"
|
||||
disabled={editContent === current?.resume}
|
||||
icon={<DifferenceIcon />}
|
||||
label="Changes"
|
||||
/>
|
||||
<Tab value="preview" icon={<PreviewIcon />} label="Preview" />
|
||||
<Tab
|
||||
disabled={tabValue !== 'preview'}
|
||||
value="print"
|
||||
icon={<PrintIcon />}
|
||||
label="Print"
|
||||
/>
|
||||
<Tab value="regenerate" icon={<ModelTraining />} label="Regenerate" />
|
||||
<Tab
|
||||
value="undo"
|
||||
disabled={editContent === resume?.resume}
|
||||
icon={<UndoIcon />}
|
||||
label="Revert"
|
||||
/>
|
||||
</Tabs>
|
||||
</Box>
|
||||
|
||||
{status && (
|
||||
<Box sx={{ mt: 1, mb: 1, width: '100%' }}>
|
||||
<StatusBox>
|
||||
{statusType && <StatusIcon type={statusType} />}
|
||||
<Typography variant="body2" sx={{ ml: 1 }}>
|
||||
{status || 'Processing...'}
|
||||
</Typography>
|
||||
</StatusBox>
|
||||
{status && !error && <LinearProgress sx={{ mt: 1 }} />}
|
||||
</Box>
|
||||
)}
|
||||
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
flexGrow: 1,
|
||||
width: '100%',
|
||||
position: 'relative',
|
||||
flexDirection: 'column',
|
||||
m: 0,
|
||||
p: 0,
|
||||
gap: 0,
|
||||
}}
|
||||
>
|
||||
{selectedRevision !== 'current' && (
|
||||
<Alert severity="info" sx={{ width: '100%' }}>
|
||||
You are viewing a previous version. Click "Restore" to load this content
|
||||
into the editor.
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{isAIGenerated && (
|
||||
<Alert severity="warning" sx={{ width: '100%' }}>
|
||||
This resume was generated by AI and has not been manually edited. Review and then
|
||||
selecte 'Save'.
|
||||
</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',
|
||||
fontFamily: 'monospace',
|
||||
backgroundColor: '#fafafa',
|
||||
fontSize: '12px',
|
||||
}}
|
||||
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 }}>{resume?.resume}</pre>
|
||||
)}
|
||||
</Box>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{tabValue === 'diff' && current && (
|
||||
<DiffViewer
|
||||
changeLog={changeLog}
|
||||
original={{ content: current.resume || '', name: 'original' }}
|
||||
modified={{
|
||||
content: selectedRevision !== 'current' && resume ? resume.resume : editContent,
|
||||
name: 'modified',
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{tabValue === 'preview' && resume && resume.candidate && (
|
||||
<Box
|
||||
className="document-container"
|
||||
ref={printContentRef}
|
||||
sx={currentStyle.contentStyle}
|
||||
>
|
||||
<ResumePreview resume={resume} selectedStyle={selectedStyle} />
|
||||
</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}>
|
||||
{resume && resume.job !== undefined && (
|
||||
<Tab value="job" icon={<WorkIcon />} label="Job" />
|
||||
)}
|
||||
<Tab value="chat" icon={<ModelTraining />} label="AI Edit" />
|
||||
</Tabs>
|
||||
|
||||
{resume && resume.job !== undefined && jobTabValue === 'job' && (
|
||||
<JobInfo
|
||||
variant={'all'}
|
||||
job={resume.job}
|
||||
sx={{
|
||||
m: 0,
|
||||
p: 1,
|
||||
backgroundColor: '#f8f0e0',
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{jobTabValue === 'chat' && resume && (
|
||||
<ResumeChat
|
||||
session={resume.id || ''}
|
||||
resume={editContent}
|
||||
onResumeChange={(prompt: string, newResume: string): void => {
|
||||
console.log('onResumeChange:', prompt);
|
||||
if (newResume !== editContent) {
|
||||
setBackupContent(editContent);
|
||||
setEditPrompt(prompt);
|
||||
setEditContent(newResume);
|
||||
setIsAIGenerated(true);
|
||||
}
|
||||
}}
|
||||
sx={{
|
||||
m: 1,
|
||||
p: 1,
|
||||
flexGrow: 1,
|
||||
position: 'relative',
|
||||
maxWidth: 'fit-content',
|
||||
minWidth: '100%',
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Paper>
|
||||
</Scrollable>
|
||||
</Box>
|
||||
</Box>
|
||||
{resume && (
|
||||
<Box
|
||||
sx={{ display: 'flex', flexDirection: 'row', gap: 1, alignItems: 'center', pl: 1, pt: 1 }}
|
||||
>
|
||||
{onClose && <Button onClick={handleClose}>Cancel</Button>}
|
||||
<Button
|
||||
onClick={() => {
|
||||
handleSave();
|
||||
}}
|
||||
variant="contained"
|
||||
disabled={saveDisabled}
|
||||
startIcon={<SaveIcon />}
|
||||
>
|
||||
{saving ? 'Saving...' : 'Save'}
|
||||
</Button>
|
||||
<Typography variant="caption" display="block" color="text.secondary">
|
||||
Last saved: {resume.updatedAt ? new Date(resume.updatedAt).toLocaleString() : 'N/A'}
|
||||
</Typography>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export { ResumeEdit };
|
@ -1,11 +1,9 @@
|
||||
import React, { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import {
|
||||
Box,
|
||||
Typography,
|
||||
SxProps,
|
||||
CardHeader,
|
||||
Button,
|
||||
LinearProgress,
|
||||
IconButton,
|
||||
Tooltip,
|
||||
Card,
|
||||
@ -13,63 +11,21 @@ import {
|
||||
Divider,
|
||||
useTheme,
|
||||
useMediaQuery,
|
||||
Dialog,
|
||||
DialogTitle,
|
||||
DialogContent,
|
||||
DialogActions,
|
||||
Tabs,
|
||||
Tab,
|
||||
Paper,
|
||||
FormControl,
|
||||
Select,
|
||||
MenuItem,
|
||||
InputLabel,
|
||||
Chip,
|
||||
Alert,
|
||||
Stack,
|
||||
SelectChangeEvent,
|
||||
} from '@mui/material';
|
||||
import PrintIcon from '@mui/icons-material/Print';
|
||||
// import SaveIcon from '@mui/icons-material/Save';
|
||||
// import UndoIcon from '@mui/icons-material/Undo';
|
||||
// import PreviewIcon from '@mui/icons-material/Preview';
|
||||
import DifferenceIcon from '@mui/icons-material/Difference';
|
||||
// import EditDocumentIcon from '@mui/icons-material/EditDocument';
|
||||
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,
|
||||
History as HistoryIcon,
|
||||
RestoreFromTrash as RestoreFromTrashIcon,
|
||||
Refresh as RefreshIcon,
|
||||
PrecisionManufacturing,
|
||||
// Language as WebsiteIcon,
|
||||
Work as WorkIcon,
|
||||
} from '@mui/icons-material';
|
||||
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 { 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';
|
||||
import { DiffViewer } from 'components/DiffViewer';
|
||||
import { ResumePreview, resumeStyles } from './ResumePreview';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
|
||||
interface ResumeInfoProps {
|
||||
@ -80,16 +36,6 @@ interface ResumeInfoProps {
|
||||
variant?: 'minimal' | 'small' | 'normal' | 'all' | null;
|
||||
}
|
||||
|
||||
// Resume revision interface
|
||||
interface ResumeRevision {
|
||||
revisionId: string;
|
||||
revisionTimestamp: string;
|
||||
updatedAt: string;
|
||||
createdAt: string;
|
||||
candidateId: string;
|
||||
jobId: string;
|
||||
}
|
||||
|
||||
const ResumeInfo: React.FC<ResumeInfoProps> = (props: ResumeInfoProps) => {
|
||||
const { setSnack } = useAppState();
|
||||
const navigate = useNavigate();
|
||||
@ -101,31 +47,6 @@ const ResumeInfo: React.FC<ResumeInfoProps> = (props: ResumeInfoProps) => {
|
||||
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 [lastRevision, setLastRevision] = useState<Resume | null>(null);
|
||||
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) {
|
||||
@ -133,90 +54,6 @@ const ResumeInfo: React.FC<ResumeInfoProps> = (props: ResumeInfoProps) => {
|
||||
}
|
||||
}, [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;
|
||||
if (selectedRevision === 'current') {
|
||||
setLastRevision(activeResume);
|
||||
}
|
||||
setSelectedRevision(newRevisionId);
|
||||
await loadRevisionContent(newRevisionId);
|
||||
};
|
||||
|
||||
// Restore revision to editor
|
||||
const restoreRevision = (): void => {
|
||||
setEditContent(revisionContent);
|
||||
setSelectedRevision('current');
|
||||
setLastRevision(null);
|
||||
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 {
|
||||
@ -233,40 +70,8 @@ const ResumeInfo: React.FC<ResumeInfoProps> = (props: ResumeInfoProps) => {
|
||||
setActiveResume({ ...resume });
|
||||
};
|
||||
|
||||
const handleSave = async (prompt = ''): Promise<void> => {
|
||||
setSaving(true);
|
||||
try {
|
||||
const resumeUpdate = {
|
||||
...activeResume,
|
||||
resume: editContent,
|
||||
systemPrompt: editSystemPrompt,
|
||||
prompt: 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');
|
||||
setLastRevision(null);
|
||||
setRevisionContent(activeResume.resume);
|
||||
setEditDialogOpen(true);
|
||||
navigate(`/candidate/resumes/${activeResume.id}/edit`);
|
||||
};
|
||||
|
||||
if (!resume) {
|
||||
@ -289,66 +94,6 @@ const ResumeInfo: React.FC<ResumeInfoProps> = (props: ResumeInfoProps) => {
|
||||
}
|
||||
};
|
||||
|
||||
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') {
|
||||
console.log('Printing resume...');
|
||||
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={{
|
||||
@ -467,7 +212,7 @@ const ResumeInfo: React.FC<ResumeInfoProps> = (props: ResumeInfoProps) => {
|
||||
avatar={<DescriptionIcon color="success" />}
|
||||
sx={{ p: 0, pb: 1 }}
|
||||
action={
|
||||
<Tooltip title="Edit Resume Content">
|
||||
<Tooltip title="Edit Resume">
|
||||
<IconButton size="small" onClick={handleEditOpen}>
|
||||
<EditIcon />
|
||||
</IconButton>
|
||||
@ -533,416 +278,8 @@ const ResumeInfo: React.FC<ResumeInfoProps> = (props: ResumeInfoProps) => {
|
||||
</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" />
|
||||
{lastRevision && <Tab value="diff" icon={<DifferenceIcon />} label="Changes" />}
|
||||
<Tab value="preview" icon={<PreviewIcon />} label="Preview" />
|
||||
<Tab value="print" icon={<PrintIcon />} label="Print" />
|
||||
{activeResume.systemPrompt && (
|
||||
<Tab value="context" icon={<TuneIcon />} label="Context" />
|
||||
)}
|
||||
<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="See changes against current version">
|
||||
<Button
|
||||
size="small"
|
||||
startIcon={<PrecisionManufacturing />}
|
||||
onClick={() => setTabValue('diff')}
|
||||
disabled={loadingRevision}
|
||||
>
|
||||
Changes
|
||||
</Button>
|
||||
</Tooltip>
|
||||
|
||||
<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',
|
||||
fontFamily: 'monospace',
|
||||
backgroundColor: '#fafafa',
|
||||
fontSize: '12px',
|
||||
}}
|
||||
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 === 'context' && (
|
||||
<StyledMarkdown content={activeResume.systemPrompt || ''} />
|
||||
)}
|
||||
{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 === 'diff' && lastRevision && (
|
||||
<DiffViewer
|
||||
changeLog={activeResume.prompt || ''}
|
||||
original={{ content: revisionContent, name: 'original' }}
|
||||
modified={{ content: lastRevision.resume, name: 'modified' }}
|
||||
/>
|
||||
)}
|
||||
{tabValue === 'preview' && activeResume.candidate && (
|
||||
<Box
|
||||
className="document-container"
|
||||
ref={printContentRef}
|
||||
sx={currentStyle.contentStyle}
|
||||
>
|
||||
<ResumePreview resume={activeResume} selectedStyle={selectedStyle} />
|
||||
</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={(prompt: string, newResume: string): void => {
|
||||
if (newResume !== editContent) {
|
||||
handleSave(prompt).then(() => {
|
||||
setEditContent(newResume);
|
||||
setLastRevision(activeResume);
|
||||
setActiveResume({ ...activeResume, prompt, resume: newResume });
|
||||
});
|
||||
}
|
||||
}}
|
||||
sx={{
|
||||
m: 1,
|
||||
p: 1,
|
||||
flexGrow: 1,
|
||||
position: 'relative',
|
||||
maxWidth: 'fit-content',
|
||||
minWidth: '100%',
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</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>
|
||||
);
|
||||
};
|
||||
|
@ -153,78 +153,6 @@ const generateResumeStyles = (): Record<string, ResumeStyle> => {
|
||||
background: '#ffffff',
|
||||
},
|
||||
},
|
||||
creative: {
|
||||
name: 'Creative',
|
||||
description: 'Colorful, unique design with personality',
|
||||
headerStyle: {
|
||||
display: 'flex',
|
||||
flexDirection: 'row',
|
||||
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,
|
||||
} as SxProps<Theme>,
|
||||
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,
|
||||
} as SxProps<Theme>,
|
||||
contentStyle: {
|
||||
fontFamily: '"Open Sans", Arial, sans-serif',
|
||||
lineHeight: 1.6,
|
||||
color: '#444444',
|
||||
} as SxProps<Theme>,
|
||||
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,
|
||||
},
|
||||
} as SxProps<Theme>,
|
||||
color: {
|
||||
primary: '#667eea',
|
||||
secondary: '#764ba2',
|
||||
accent: '#f093fb',
|
||||
text: '#444444',
|
||||
background: '#ffffff',
|
||||
},
|
||||
},
|
||||
corporate: {
|
||||
name: 'Corporate',
|
||||
description: 'Formal, structured business format',
|
||||
@ -444,7 +372,7 @@ const StyledFooter: React.FC<StyledFooterProps> = ({ resume, style, isMobile })
|
||||
sx={{
|
||||
...style.footerStyle,
|
||||
color: style.color.secondary,
|
||||
fontSize: isMobile ? '0.6rem' : '1rem',
|
||||
fontSize: isMobile ? '0.6rem' : '0.8rem',
|
||||
}}
|
||||
>
|
||||
Dive deeper into my qualifications at Backstory...
|
||||
@ -455,7 +383,9 @@ const StyledFooter: React.FC<StyledFooterProps> = ({ resume, style, isMobile })
|
||||
className="qr-code"
|
||||
sx={{ display: 'flex', mt: 1, mb: 1 }}
|
||||
/>
|
||||
{window.location.protocol}://{window.location.host}/chat/{resume.id}
|
||||
{window.location.protocol}
|
||||
{'//'}
|
||||
{window.location.host}/chat/{resume.id}
|
||||
</Box>
|
||||
<Box sx={{ pb: 2 }}> </Box>
|
||||
</>
|
||||
|
@ -40,6 +40,7 @@ import { useAppState, useSelectedResume } from 'hooks/GlobalContext'; // Assumin
|
||||
import { useNavigate, useParams } from 'react-router-dom';
|
||||
import { Resume } from 'types/types';
|
||||
import { formatDate } from 'utils/formatDate';
|
||||
import { ResumeEdit } from './ResumeEdit';
|
||||
|
||||
type SortField = 'updatedAt' | 'createdAt' | 'candidateId' | 'jobId';
|
||||
type SortOrder = 'asc' | 'desc';
|
||||
@ -64,6 +65,7 @@ const ResumeViewer: React.FC<ResumeViewerProps> = ({ onSelect, candidateId, jobI
|
||||
const theme = useTheme();
|
||||
const isMobile = useMediaQuery(theme.breakpoints.down('md'));
|
||||
const isSmall = useMediaQuery(theme.breakpoints.down('sm'));
|
||||
const { resumeId, mode = 'view' } = useParams<{ resumeId?: string; mode?: string }>();
|
||||
|
||||
const { apiClient } = useAuth();
|
||||
const { selectedResume, setSelectedResume } = useSelectedResume(); // Assuming similar context
|
||||
@ -74,7 +76,6 @@ const ResumeViewer: React.FC<ResumeViewerProps> = ({ onSelect, candidateId, jobI
|
||||
const [mobileDialogOpen, setMobileDialogOpen] = useState(false);
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [filteredResumes, setFilteredResumes] = useState<Resume[]>([]);
|
||||
const { resumeId } = useParams<{ resumeId?: string }>();
|
||||
|
||||
useEffect(() => {
|
||||
if (resumeId) {
|
||||
@ -103,19 +104,18 @@ const ResumeViewer: React.FC<ResumeViewerProps> = ({ onSelect, candidateId, jobI
|
||||
|
||||
const getResumes = async (): Promise<void> => {
|
||||
try {
|
||||
let results;
|
||||
let result: Resume[];
|
||||
|
||||
if (candidateId) {
|
||||
results = await apiClient.getResumesByCandidate(candidateId);
|
||||
result = await apiClient.getResumesByCandidate(candidateId);
|
||||
} else if (jobId) {
|
||||
results = await apiClient.getResumesByJob(jobId);
|
||||
result = await apiClient.getResumesByJob(jobId);
|
||||
} else {
|
||||
results = await apiClient.getResumes();
|
||||
result = await apiClient.getResumes();
|
||||
}
|
||||
|
||||
const resumesData: Resume[] = results.resumes || [];
|
||||
setResumes(resumesData);
|
||||
setFilteredResumes(resumesData);
|
||||
setResumes(result);
|
||||
setFilteredResumes(result);
|
||||
} catch (err) {
|
||||
console.error('Failed to load resumes:', err);
|
||||
setSnack('Failed to load resumes: ' + err, 'error');
|
||||
@ -521,6 +521,22 @@ const ResumeViewer: React.FC<ResumeViewerProps> = ({ onSelect, candidateId, jobI
|
||||
</Box>
|
||||
);
|
||||
|
||||
const handleSave = (updatedResume: Resume): void => {
|
||||
setSelectedResume(updatedResume);
|
||||
};
|
||||
|
||||
if (mode === 'edit' && selectedResume) {
|
||||
return (
|
||||
<ResumeEdit
|
||||
onClose={() => {
|
||||
navigate(window.location.pathname.replace('/edit', ''));
|
||||
}}
|
||||
resumeId={selectedResume.id}
|
||||
onSave={handleSave}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (isMobile) {
|
||||
return (
|
||||
<Box
|
||||
|
@ -136,7 +136,7 @@ export const navigationConfig: NavigationConfig = {
|
||||
{
|
||||
id: 'explore-resumes',
|
||||
label: 'Resumes',
|
||||
path: '/candidate/resumes/:resumeId?',
|
||||
path: '/candidate/resumes/:resumeId?/:mode?',
|
||||
icon: <EditDocumentIcon />,
|
||||
component: <ResumeViewer />,
|
||||
variant: 'fullWidth',
|
||||
|
@ -12,6 +12,7 @@ import * as Types from 'types/types';
|
||||
import { useLocation, useNavigate } from 'react-router-dom';
|
||||
import { useAuth } from 'hooks/AuthContext';
|
||||
import { SetSnackType, SeverityType, Snack } from 'components/Snack';
|
||||
import { ApiErrorDetails } from 'services/api-client';
|
||||
|
||||
// ============================
|
||||
// Storage Keys
|
||||
@ -411,8 +412,8 @@ const AppStateContext = createContext<AppStateContextType | null>(null);
|
||||
|
||||
export function AppStateProvider({ children }: { children: React.ReactNode }): JSX.Element {
|
||||
const appState = useAppStateLogic();
|
||||
const { apiClient } = useAuth();
|
||||
const snackRef = useRef<{ setSnack: SetSnackType }>(null);
|
||||
|
||||
// Global UI components
|
||||
appState.setSnack = useCallback(
|
||||
(message: string, severity?: SeverityType) => {
|
||||
@ -421,6 +422,27 @@ export function AppStateProvider({ children }: { children: React.ReactNode }): J
|
||||
[snackRef]
|
||||
);
|
||||
|
||||
apiClient.setErrorCallback((error: ApiErrorDetails) => {
|
||||
console.log('API Error:', error);
|
||||
|
||||
// Handle authorization failures
|
||||
if (error.status === 401) {
|
||||
// Token expired or invalid - redirect to login
|
||||
appState.setSnack('Authentication token expired or invalid. Please login again.', 'error');
|
||||
} else if (error.status === 403) {
|
||||
// Forbidden - show access denied message
|
||||
appState.setSnack('Access denied', 'error');
|
||||
} else if (error.status >= 500) {
|
||||
// Server error - show generic error message
|
||||
appState.setSnack('Server error. Please try again later.', 'error');
|
||||
} else {
|
||||
appState.setSnack(
|
||||
`Error: ${('message' in error && error.message) || 'An unexpected error occurred.'}`,
|
||||
'error'
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
return (
|
||||
<AppStateContext.Provider value={appState}>
|
||||
{children}
|
||||
|
@ -27,9 +27,9 @@ import { useSelectedCandidate, useSelectedJob } from 'hooks/GlobalContext';
|
||||
import { CandidatePicker } from 'components/ui/CandidatePicker';
|
||||
import { JobCreator } from 'components/JobCreator';
|
||||
import { LoginRestricted } from 'components/ui/LoginRestricted';
|
||||
import { ResumeGenerator } from 'components/ResumeGenerator';
|
||||
import { JobsView } from 'components/ui/JobsView';
|
||||
import { useNavigate, useParams } from 'react-router-dom';
|
||||
import { ResumeEdit } from 'components/ui/ResumeEdit';
|
||||
|
||||
function WorkAddIcon(): JSX.Element {
|
||||
return (
|
||||
@ -403,13 +403,7 @@ const JobAnalysisPage: React.FC<BackstoryPageProps> = () => {
|
||||
return <></>;
|
||||
}
|
||||
|
||||
return (
|
||||
<ResumeGenerator
|
||||
job={analysisState.job}
|
||||
candidate={analysisState.candidate}
|
||||
skills={analysisState.analysis.skills}
|
||||
/>
|
||||
);
|
||||
return <ResumeEdit />;
|
||||
};
|
||||
|
||||
// Don't render until initialized to avoid flash of incorrect state
|
||||
@ -652,7 +646,7 @@ const JobAnalysisPage: React.FC<BackstoryPageProps> = () => {
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Box sx={{ mb: 1, justifyContent: 'center' }}>{!isMobile && 'Generate '}Resume</Box>
|
||||
<Box sx={{ mb: 1, justifyContent: 'center' }}>{!isMobile && 'Edit '}Resume</Box>
|
||||
</Box>
|
||||
</StepLabel>
|
||||
</Step>
|
||||
|
@ -24,7 +24,7 @@ import {
|
||||
InputAdornment,
|
||||
} from '@mui/material';
|
||||
import { Visibility, VisibilityOff } from '@mui/icons-material';
|
||||
import { ApiClient, RegistrationResponse } from 'services/api-client';
|
||||
import { RegistrationResponse } from 'services/api-client';
|
||||
import { RegistrationSuccessDialog } from 'components/EmailVerificationComponents';
|
||||
import { useAuth } from 'hooks/AuthContext';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
@ -390,6 +390,7 @@ const CandidateRegistrationForm = (): JSX.Element => {
|
||||
|
||||
// Employer Registration Form
|
||||
const EmployerRegistrationForm = (): JSX.Element => {
|
||||
const { apiClient } = useAuth();
|
||||
const [formData, setFormData] = useState({
|
||||
email: '',
|
||||
username: '',
|
||||
@ -412,8 +413,6 @@ const EmployerRegistrationForm = (): JSX.Element => {
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
const [showConfirmPassword, setShowConfirmPassword] = useState(false);
|
||||
|
||||
const apiClient = new ApiClient();
|
||||
|
||||
const industryOptions = [
|
||||
'Technology',
|
||||
'Healthcare',
|
||||
|
File diff suppressed because it is too large
Load Diff
@ -1,6 +1,6 @@
|
||||
// Generated TypeScript types from Pydantic models
|
||||
// Source: src/backend/models.py
|
||||
// Generated on: 2025-07-16T23:28:16.752031
|
||||
// Generated on: 2025-07-17T19:28:19.417396
|
||||
// DO NOT EDIT MANUALLY - This file is auto-generated
|
||||
|
||||
// ============================
|
||||
@ -1006,6 +1006,7 @@ export interface Resume {
|
||||
updatedAt?: Date;
|
||||
job?: Job;
|
||||
candidate?: Candidate;
|
||||
aiGenerated?: boolean;
|
||||
}
|
||||
|
||||
export interface ResumeMessage {
|
||||
|
@ -536,7 +536,7 @@ Content: {content}
|
||||
umap_embedding_2d=umap_2d,
|
||||
umap_embedding_3d=umap_3d,
|
||||
)
|
||||
if rag_metadata.query_embedding:
|
||||
if rag_metadata.query_embedding and len(rag_metadata.query_embedding) > 0:
|
||||
logger.info(f"Len of embedding: {len(rag_metadata.query_embedding)}")
|
||||
else:
|
||||
logger.warning(f"No query embedding found in RAG results; {query_embedding}")
|
||||
|
@ -126,6 +126,7 @@ class EditResume(Agent):
|
||||
content = ""
|
||||
start_time = time.perf_counter()
|
||||
response = None
|
||||
|
||||
async for response in llm.chat_stream(
|
||||
model=model,
|
||||
messages=messages,
|
||||
@ -209,10 +210,10 @@ You are a professional copy editor. Your task is to edit and enhance the provide
|
||||
- DO NOT estimate, approximate, or infer numerical data
|
||||
|
||||
**RESPONSE FORMAT RULES:**
|
||||
- IF the user asks a question (contains question words like "what", "how", "why", "when", "where", "which", "who" OR ends with "?"), then:
|
||||
- IF the user prompt contains question words like "what", "how", "why", "when", "where", "which", "who" OR ends with "?", then it is a QUESTION:
|
||||
- Start response with exactly "ANSWER: " (including the space)
|
||||
- Provide only the answer, not the full resume
|
||||
- IF the user requests edits/changes to the resume, then:
|
||||
- IF the user prompt IS NOT A QUESTION:
|
||||
- Return the complete edited resume without any prefix
|
||||
- Do not include "ANSWER:" anywhere in the response
|
||||
|
||||
@ -231,9 +232,9 @@ You are a professional copy editor. Your task is to edit and enhance the provide
|
||||
- Example of UNACCEPTABLE impact: "Streamlined resume analysis processes with a 40% reduction in manual review time" (unless that 40% is stated in the original content)
|
||||
|
||||
If the user asks a question about the resume, state "ANSWER:" and then provide a brief answer based only on the content of the resume or the context provided.
|
||||
Start the response with "ANSWER:" if an answer is being rovided and not the full resume.
|
||||
Start the response with "ANSWER:" if an answer is being provided and not the full resume.
|
||||
|
||||
If the user did not ask a question, return the entire resume with the requested edits applied while maintaining the current formatting.
|
||||
If the user did not ask a question, always return the entire resume with the requested edits applied while maintaining the current formatting.
|
||||
"""
|
||||
|
||||
logger.info(f"Generating edit_resume response to: {prompt}\ncontext: {extra_context}")
|
||||
|
@ -83,38 +83,40 @@ direct duplication from the assessment.
|
||||
Do not provide header information like name, email, or phone number in the resume, as that information will be added later.
|
||||
|
||||
## CANDIDATE INFORMATION:
|
||||
Name: {self.user.full_name}
|
||||
Email: {self.user.email or "N/A"}
|
||||
Phone: {self.user.phone or "N/A"}
|
||||
{f"Location: {json.dumps(self.user.location.model_dump())}" if self.user.location else ""}
|
||||
* Name: {self.user.full_name}
|
||||
* Email: {self.user.email or "N/A"}
|
||||
* Phone: {self.user.phone or "N/A"}
|
||||
{f"* Location: {json.dumps(self.user.location.model_dump(exclude_none=True, exclude_unset=True))}" if self.user.location else ""}
|
||||
|
||||
## SKILL ASSESSMENT RESULTS:
|
||||
|
||||
"""
|
||||
|
||||
if len(skills_by_strength[SkillStrength.STRONG]):
|
||||
system_prompt += f"""\
|
||||
|
||||
### Strong Skills (prominent in resume):
|
||||
* {".\n* ".join(skills_by_strength[SkillStrength.STRONG])}
|
||||
|
||||
"""
|
||||
|
||||
if len(skills_by_strength[SkillStrength.MODERATE]):
|
||||
system_prompt += f"""\
|
||||
|
||||
### Moderate Skills (demonstrated in resume):
|
||||
* {".\n* ".join(skills_by_strength[SkillStrength.MODERATE])}
|
||||
|
||||
"""
|
||||
|
||||
if len(skills_by_strength[SkillStrength.WEAK]):
|
||||
system_prompt += f"""\
|
||||
|
||||
### Weaker Skills (mentioned or implied):
|
||||
* {".\n* ".join(skills_by_strength[SkillStrength.WEAK])}
|
||||
|
||||
"""
|
||||
|
||||
system_prompt += """\
|
||||
|
||||
system_prompt += """
|
||||
|
||||
## EXPERIENCE EVIDENCE:
|
||||
|
||||
"""
|
||||
|
||||
# Add experience evidence by source/position
|
||||
|
@ -4,6 +4,25 @@ import logging
|
||||
import defines
|
||||
|
||||
|
||||
class RelativePathFormatter(logging.Formatter):
|
||||
def __init__(self, fmt=None, datefmt=None, remove_prefix=None):
|
||||
super().__init__(fmt, datefmt)
|
||||
self.remove_prefix = remove_prefix or os.getcwd()
|
||||
# Ensure the prefix ends with a separator
|
||||
if not self.remove_prefix.endswith(os.sep):
|
||||
self.remove_prefix += os.sep
|
||||
|
||||
def format(self, record):
|
||||
# Make a copy of the record to avoid modifying the original
|
||||
record = logging.makeLogRecord(record.__dict__)
|
||||
|
||||
# Remove the prefix from pathname
|
||||
if record.pathname.startswith(self.remove_prefix):
|
||||
record.pathname = record.pathname[len(self.remove_prefix) :]
|
||||
|
||||
return super().format(record)
|
||||
|
||||
|
||||
def _setup_logging(level=defines.logging_level) -> logging.Logger:
|
||||
os.environ["TORCH_CPP_LOG_LEVEL"] = "ERROR"
|
||||
warnings.filterwarnings("ignore", message="Overriding a previously registered kernel")
|
||||
@ -18,8 +37,8 @@ def _setup_logging(level=defines.logging_level) -> logging.Logger:
|
||||
raise ValueError(f"Invalid log level: {level}")
|
||||
|
||||
# Create a custom formatter
|
||||
formatter = logging.Formatter(
|
||||
fmt="%(levelname)s - %(filename)s:%(lineno)d - %(message)s", datefmt="%Y-%m-%d %H:%M:%S"
|
||||
formatter = RelativePathFormatter(
|
||||
fmt="%(levelname)s - %(pathname)s:%(lineno)d - %(message)s", datefmt="%Y-%m-%d %H:%M:%S"
|
||||
)
|
||||
|
||||
# Create a handler (e.g., StreamHandler for console output)
|
||||
|
@ -1209,6 +1209,7 @@ class Resume(BaseModel):
|
||||
updated_at: datetime = Field(default_factory=lambda: datetime.now(UTC), alias=str("updatedAt"))
|
||||
job: Optional[Job] = None
|
||||
candidate: Optional[Candidate] = None
|
||||
ai_generated: Optional[bool] = Field(default=True, alias=str("aiGenerated"))
|
||||
model_config = ConfigDict(populate_by_name=True)
|
||||
|
||||
class ChatMessageResume(ChatMessageUser):
|
||||
|
@ -1985,9 +1985,40 @@ async def generate_resume(
|
||||
yield error_message
|
||||
return
|
||||
|
||||
resume: ChatMessageResume = final_message
|
||||
resume.resume.job_id = job.id
|
||||
yield resume
|
||||
resume_message: ChatMessageResume = final_message
|
||||
resume_message.resume.ai_generated = True
|
||||
resume_message.resume.job_id = job.id
|
||||
resume_message.resume.candidate_id = candidate.id
|
||||
|
||||
current_resumes = await database.get_resumes_by_job(current_user.id, job.id)
|
||||
current_resume = current_resumes[0] if len(current_resumes) != 0 else None
|
||||
if current_resume:
|
||||
logger.info(f"💾 Updating resume from candidate {candidate.id} and job {job.id}")
|
||||
resume_message.resume.id = current_resume.id
|
||||
current_resume.resume = resume_message.resume.resume
|
||||
updates = current_resume.model_dump()
|
||||
if current_resume.resume == resume_message.resume.resume:
|
||||
logger.info(f"✅ No changes detected in resume for candidate {candidate.id} and job {job.id}")
|
||||
resume_message.resume.job = job
|
||||
resume_message.resume.candidate = candidate
|
||||
yield resume_message
|
||||
return
|
||||
updated_resume_data = await database.update_resume(current_user.id, current_resume.id, updates)
|
||||
if updated_resume_data:
|
||||
updated_resume = Resume.model_validate(updated_resume_data) if updated_resume_data else None
|
||||
if updated_resume:
|
||||
logger.info(f"✅ Resume {updated_resume.id} updated successfully for user {current_user.id}")
|
||||
resume_message.resume = updated_resume
|
||||
else:
|
||||
success = await database.set_resume(current_user.id, resume_message.resume.model_dump())
|
||||
if not success:
|
||||
error_message = ChatMessageError(session_id=MOCK_UUID, content="Failed to save resume to database")
|
||||
yield error_message
|
||||
return
|
||||
|
||||
resume_message.resume.job = job
|
||||
resume_message.resume.candidate = candidate
|
||||
yield resume_message
|
||||
return
|
||||
|
||||
try:
|
||||
|
@ -3,7 +3,7 @@ Resume Routes
|
||||
"""
|
||||
|
||||
import json
|
||||
from typing import List
|
||||
from typing import List, Optional
|
||||
import uuid
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Depends, Body, Path, Query
|
||||
@ -13,7 +13,7 @@ import backstory_traceback as backstory_traceback
|
||||
from database.manager import RedisDatabase
|
||||
from logger import logger
|
||||
from models import MOCK_UUID, ChatMessageError, Job, Candidate, Resume, ResumeMessage
|
||||
from utils.dependencies import get_database, get_current_user, get_current_user_or_guest
|
||||
from utils.dependencies import get_database, get_current_user_or_guest
|
||||
from utils.responses import create_success_response
|
||||
|
||||
# Create router for authentication endpoints
|
||||
@ -23,7 +23,7 @@ router = APIRouter(prefix="/resumes", tags=["resumes"])
|
||||
@router.post("")
|
||||
async def create_candidate_resume(
|
||||
resume: Resume = Body(...),
|
||||
current_user=Depends(get_current_user),
|
||||
current_user=Depends(get_current_user_or_guest),
|
||||
database: RedisDatabase = Depends(get_database),
|
||||
):
|
||||
"""Create a new resume for a candidate/job combination"""
|
||||
@ -122,7 +122,9 @@ async def create_candidate_resume(
|
||||
|
||||
|
||||
@router.get("")
|
||||
async def get_user_resumes(current_user=Depends(get_current_user), database: RedisDatabase = Depends(get_database)):
|
||||
async def get_user_resumes(
|
||||
current_user=Depends(get_current_user_or_guest), database: RedisDatabase = Depends(get_database)
|
||||
):
|
||||
"""Get all resumes for the current user"""
|
||||
try:
|
||||
resumes_data = await database.get_all_resumes_for_user(current_user.id)
|
||||
@ -170,7 +172,7 @@ async def get_resume(
|
||||
@router.delete("/{resume_id}")
|
||||
async def delete_resume(
|
||||
resume_id: str = Path(..., description="ID of the resume"),
|
||||
current_user=Depends(get_current_user),
|
||||
current_user=Depends(get_current_user_or_guest),
|
||||
database: RedisDatabase = Depends(get_database),
|
||||
):
|
||||
"""Delete a specific resume"""
|
||||
@ -179,7 +181,7 @@ async def delete_resume(
|
||||
if not success:
|
||||
raise HTTPException(status_code=404, detail="Resume not found")
|
||||
|
||||
return {"success": True, "message": f"Resume {resume_id} deleted successfully"}
|
||||
return create_success_response({"message": f"Resume {resume_id} deleted successfully"})
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
@ -188,15 +190,28 @@ async def delete_resume(
|
||||
|
||||
|
||||
@router.get("/candidate/{candidate_id}")
|
||||
@router.get("/candidate/{candidate_id}/{job_id}")
|
||||
async def get_resumes_by_candidate(
|
||||
candidate_id: str = Path(..., description="ID of the candidate"),
|
||||
current_user=Depends(get_current_user),
|
||||
job_id: Optional[str] = Path(..., description="ID of the job"),
|
||||
current_user=Depends(get_current_user_or_guest),
|
||||
database: RedisDatabase = Depends(get_database),
|
||||
):
|
||||
"""Get all resumes for a specific candidate"""
|
||||
try:
|
||||
resumes = await database.get_resumes_by_candidate(current_user.id, candidate_id)
|
||||
return {"success": True, "candidate_id": candidate_id, "resumes": resumes, "count": len(resumes)}
|
||||
if job_id:
|
||||
for resume in resumes:
|
||||
logger.info(f"Checking resume {resume.id} for job {job_id}: {resume.job_id}")
|
||||
resumes = [resume for resume in resumes if resume.job_id == job_id]
|
||||
for resume in resumes:
|
||||
job_data = await database.get_job(resume.job_id)
|
||||
if job_data:
|
||||
resume.job = Job.model_validate(job_data)
|
||||
candidate_data = await database.get_candidate(resume.candidate_id)
|
||||
if candidate_data:
|
||||
resume.candidate = Candidate.model_validate(candidate_data)
|
||||
return create_success_response({"resumes": [resume.model_dump(by_alias=True) for resume in resumes]})
|
||||
except Exception as e:
|
||||
logger.error(f"❌ Error retrieving resumes for candidate {candidate_id}: {e}")
|
||||
raise HTTPException(status_code=500, detail="Failed to retrieve candidate resumes")
|
||||
@ -205,13 +220,13 @@ async def get_resumes_by_candidate(
|
||||
@router.get("/job/{job_id}")
|
||||
async def get_resumes_by_job(
|
||||
job_id: str = Path(..., description="ID of the job"),
|
||||
current_user=Depends(get_current_user),
|
||||
current_user=Depends(get_current_user_or_guest),
|
||||
database: RedisDatabase = Depends(get_database),
|
||||
):
|
||||
"""Get all resumes for a specific job"""
|
||||
try:
|
||||
resumes = await database.get_resumes_by_job(current_user.id, job_id)
|
||||
return {"success": True, "job_id": job_id, "resumes": resumes, "count": len(resumes)}
|
||||
return create_success_response({"job_id": job_id, "resumes": resumes, "count": len(resumes)})
|
||||
except Exception as e:
|
||||
logger.error(f"❌ Error retrieving resumes for job {job_id}: {e}")
|
||||
raise HTTPException(status_code=500, detail="Failed to retrieve job resumes")
|
||||
@ -220,13 +235,13 @@ async def get_resumes_by_job(
|
||||
@router.get("/search")
|
||||
async def search_resumes(
|
||||
q: str = Query(..., description="Search query"),
|
||||
current_user=Depends(get_current_user),
|
||||
current_user=Depends(get_current_user_or_guest),
|
||||
database: RedisDatabase = Depends(get_database),
|
||||
):
|
||||
"""Search resumes by content"""
|
||||
try:
|
||||
resumes = await database.search_resumes_for_user(current_user.id, q)
|
||||
return {"success": True, "query": q, "resumes": resumes, "count": len(resumes)}
|
||||
return create_success_response({"query": q, "resumes": resumes, "count": len(resumes)})
|
||||
except Exception as e:
|
||||
logger.error(f"❌ Error searching resumes for user {current_user.id}: {e}")
|
||||
raise HTTPException(status_code=500, detail="Failed to search resumes")
|
||||
@ -234,12 +249,12 @@ async def search_resumes(
|
||||
|
||||
@router.get("/stats")
|
||||
async def get_resume_statistics(
|
||||
current_user=Depends(get_current_user), database: RedisDatabase = Depends(get_database)
|
||||
current_user=Depends(get_current_user_or_guest), database: RedisDatabase = Depends(get_database)
|
||||
):
|
||||
"""Get resume statistics for the current user"""
|
||||
try:
|
||||
stats = await database.get_resume_statistics(current_user.id)
|
||||
return {"success": True, "statistics": stats}
|
||||
return create_success_response({"statistics": stats})
|
||||
except Exception as e:
|
||||
logger.error(f"❌ Error retrieving resume statistics for user {current_user.id}: {e}")
|
||||
raise HTTPException(status_code=500, detail="Failed to retrieve resume statistics")
|
||||
@ -248,11 +263,19 @@ async def get_resume_statistics(
|
||||
@router.patch("")
|
||||
async def update_resume(
|
||||
resume: Resume = Body(...),
|
||||
current_user=Depends(get_current_user),
|
||||
current_user=Depends(get_current_user_or_guest),
|
||||
database: RedisDatabase = Depends(get_database),
|
||||
):
|
||||
"""Update the content of a specific resume"""
|
||||
try:
|
||||
current_resume = await database.get_resume(resume.id)
|
||||
if not current_resume:
|
||||
logger.warning(f"⚠️ Resume {resume.id} not found for user {current_user.id}")
|
||||
raise HTTPException(status_code=404, detail="Resume not found")
|
||||
if current_resume.resume == resume.resume and resume.ai_generated is False:
|
||||
logger.info(f"✅ No changes detected in resume {resume.id} for user {current_user.id}")
|
||||
return create_success_response(resume.model_dump(by_alias=True))
|
||||
resume.ai_generated = False # Ensure we mark it as user-generated
|
||||
updates = resume.model_dump()
|
||||
updated_resume_data = await database.update_resume(current_user.id, resume.id, updates)
|
||||
if not updated_resume_data:
|
||||
@ -279,7 +302,7 @@ async def update_resume(
|
||||
@router.get("/{resume_id}/revisions")
|
||||
async def get_resume_revisions(
|
||||
resume_id: str = Path(..., description="ID of the resume"),
|
||||
current_user=Depends(get_current_user),
|
||||
current_user=Depends(get_current_user_or_guest),
|
||||
database: RedisDatabase = Depends(get_database),
|
||||
):
|
||||
"""Get list of all revisions for a resume with metadata"""
|
||||
@ -295,7 +318,7 @@ async def get_resume_revisions(
|
||||
async def get_resume_revision(
|
||||
resume_id: str = Path(..., description="ID of the resume"),
|
||||
revision_id: str = Path(..., description="Revision ID of the revision"),
|
||||
current_user=Depends(get_current_user),
|
||||
current_user=Depends(get_current_user_or_guest),
|
||||
database: RedisDatabase = Depends(get_database),
|
||||
):
|
||||
"""Get a specific revision of a resume by revision ID"""
|
||||
@ -303,7 +326,13 @@ async def get_resume_revision(
|
||||
revision = await database.get_resume_revision(current_user.id, resume_id, revision_id)
|
||||
if not revision:
|
||||
raise HTTPException(status_code=404, detail="Revision not found")
|
||||
|
||||
job_data = await database.get_job(revision.job_id)
|
||||
if job_data:
|
||||
revision.job = Job.model_validate(job_data)
|
||||
candidate_data = await database.get_candidate(revision.candidate_id)
|
||||
if candidate_data:
|
||||
revision.candidate = Candidate.model_validate(candidate_data)
|
||||
logger.info(f"📄 Retrieved revision {revision_id} for resume {resume_id}")
|
||||
return create_success_response(
|
||||
{"resume_id": resume_id, "revision_id": revision_id, "revision": revision.model_dump(by_alias=True)}
|
||||
)
|
||||
@ -317,7 +346,7 @@ async def get_resume_revision(
|
||||
@router.get("/{resume_id}/history")
|
||||
async def get_resume_history(
|
||||
resume_id: str = Path(..., description="ID of the resume"),
|
||||
current_user=Depends(get_current_user),
|
||||
current_user=Depends(get_current_user_or_guest),
|
||||
database: RedisDatabase = Depends(get_database),
|
||||
):
|
||||
"""Get all historical versions of a resume with full data"""
|
||||
@ -339,7 +368,7 @@ async def get_resume_history(
|
||||
async def restore_resume_revision(
|
||||
resume_id: str = Path(..., description="ID of the resume"),
|
||||
revision_id: str = Path(..., description="Revision ID of the revision to restore"),
|
||||
current_user=Depends(get_current_user),
|
||||
current_user=Depends(get_current_user_or_guest),
|
||||
database: RedisDatabase = Depends(get_database),
|
||||
):
|
||||
"""Restore a resume to a specific revision"""
|
||||
@ -368,7 +397,7 @@ async def restore_resume_revision(
|
||||
async def delete_resume_revision(
|
||||
resume_id: str = Path(..., description="ID of the resume"),
|
||||
revision_id: str = Path(..., description="Revision ID of the revision to delete"),
|
||||
current_user=Depends(get_current_user),
|
||||
current_user=Depends(get_current_user_or_guest),
|
||||
database: RedisDatabase = Depends(get_database),
|
||||
):
|
||||
"""Delete a specific revision from history"""
|
||||
@ -397,7 +426,7 @@ async def compare_resume_revisions(
|
||||
resume_id: str = Path(..., description="ID of the resume"),
|
||||
revision_id1: str = Path(..., description="First revision ID"),
|
||||
revision_id2: str = Path(..., description="Second revision ID"),
|
||||
current_user=Depends(get_current_user),
|
||||
current_user=Depends(get_current_user_or_guest),
|
||||
database: RedisDatabase = Depends(get_database),
|
||||
):
|
||||
"""Compare two revisions of a resume"""
|
||||
|
Loading…
x
Reference in New Issue
Block a user