497 lines
16 KiB
TypeScript
497 lines
16 KiB
TypeScript
import React, { useState, useEffect, JSX } from 'react';
|
|
import {
|
|
Box,
|
|
Button,
|
|
Grid,
|
|
useMediaQuery,
|
|
Typography,
|
|
Card,
|
|
CardContent,
|
|
List,
|
|
ListItem,
|
|
ListItemText,
|
|
ListItemSecondaryAction,
|
|
IconButton,
|
|
Switch,
|
|
FormControlLabel,
|
|
Dialog,
|
|
DialogTitle,
|
|
DialogContent,
|
|
DialogActions,
|
|
TextField,
|
|
Chip,
|
|
Divider,
|
|
Paper,
|
|
} from '@mui/material';
|
|
import { styled } from '@mui/material/styles';
|
|
import { CloudUpload, Edit, Delete, Visibility, Close } from '@mui/icons-material';
|
|
import { useTheme } from '@mui/material/styles';
|
|
|
|
import { useAuth } from 'hooks/AuthContext';
|
|
import * as Types from 'types/types';
|
|
import { BackstoryElementProps } from './BackstoryTab';
|
|
import { useAppState } from 'hooks/GlobalContext';
|
|
|
|
const VisuallyHiddenInput = styled('input')({
|
|
clip: 'rect(0 0 0 0)',
|
|
clipPath: 'inset(50%)',
|
|
height: 1,
|
|
overflow: 'hidden',
|
|
position: 'absolute',
|
|
bottom: 0,
|
|
left: 0,
|
|
whiteSpace: 'nowrap',
|
|
width: 1,
|
|
});
|
|
|
|
const DocumentManager = (_props: BackstoryElementProps): JSX.Element => {
|
|
const theme = useTheme();
|
|
const { setSnack } = useAppState();
|
|
const isMobile = useMediaQuery(theme.breakpoints.down('sm'));
|
|
const { user, apiClient } = useAuth();
|
|
|
|
const [documents, setDocuments] = useState<Types.Document[]>([]);
|
|
const [selectedDocument, setSelectedDocument] = useState<Types.Document | null>(null);
|
|
const [documentContent, setDocumentContent] = useState<string>('');
|
|
const [isViewingContent, setIsViewingContent] = useState(false);
|
|
const [editingDocument, setEditingDocument] = useState<Types.Document | null>(null);
|
|
const [editingName, setEditingName] = useState('');
|
|
const [isRenameDialogOpen, setIsRenameDialogOpen] = useState(false);
|
|
|
|
// Check if user is a candidate
|
|
const candidate = user?.userType === 'candidate' ? (user as Types.Candidate) : null;
|
|
|
|
// Load documents on component mount
|
|
useEffect(() => {
|
|
const loadDocuments = async (): Promise<void> => {
|
|
try {
|
|
const results = await apiClient.getCandidateDocuments();
|
|
setDocuments(results.documents);
|
|
} catch (error) {
|
|
console.error(error);
|
|
setSnack('Failed to load documents', 'error');
|
|
}
|
|
};
|
|
|
|
if (candidate) {
|
|
loadDocuments();
|
|
}
|
|
}, [candidate, apiClient, setSnack]);
|
|
|
|
// Handle document upload
|
|
const handleDocumentUpload = async (e: React.ChangeEvent<HTMLInputElement>): Promise<void> => {
|
|
if (e.target.files && e.target.files[0]) {
|
|
const file = e.target.files[0];
|
|
const fileExtension = '.' + file.name.split('.').pop()?.toLowerCase();
|
|
let docType: Types.DocumentType | null = null;
|
|
switch (fileExtension.substring(1)) {
|
|
case 'pdf':
|
|
docType = 'pdf';
|
|
break;
|
|
case 'docx':
|
|
docType = 'docx';
|
|
break;
|
|
case 'md':
|
|
docType = 'markdown';
|
|
break;
|
|
case 'txt':
|
|
docType = 'txt';
|
|
break;
|
|
}
|
|
|
|
if (!docType) {
|
|
setSnack('Invalid file type. Please upload .txt, .md, .docx, or .pdf files only.', 'error');
|
|
return;
|
|
}
|
|
|
|
try {
|
|
// Upload file (replace with actual API call)
|
|
const controller = apiClient.uploadCandidateDocument(
|
|
file,
|
|
{ includeInRag: true, isJobDocument: false },
|
|
{
|
|
onError: error => {
|
|
console.error(error);
|
|
setSnack(error.content, 'error');
|
|
},
|
|
}
|
|
);
|
|
const result = await controller.promise;
|
|
if (result && result.document) {
|
|
setDocuments(prev => [...prev, result.document]);
|
|
setSnack(`Document uploaded: ${file.name}`, 'success');
|
|
}
|
|
|
|
// Reset file input
|
|
e.target.value = '';
|
|
} catch (error) {
|
|
console.error(error);
|
|
setSnack('Failed to upload document', 'error');
|
|
}
|
|
}
|
|
};
|
|
|
|
// Handle document deletion
|
|
const handleDeleteDocument = async (document: Types.Document): Promise<void> => {
|
|
try {
|
|
// Call API to delete document
|
|
await apiClient.deleteCandidateDocument(document);
|
|
|
|
setDocuments(prev => prev.filter(doc => doc.id !== document.id));
|
|
setSnack('Document deleted successfully', 'success');
|
|
|
|
// Close content view if this document was being viewed
|
|
if (selectedDocument?.id === document.id) {
|
|
setIsViewingContent(false);
|
|
setSelectedDocument(null);
|
|
setDocumentContent('');
|
|
}
|
|
} catch (error) {
|
|
setSnack('Failed to delete document', 'error');
|
|
}
|
|
};
|
|
|
|
// Handle RAG flag toggle
|
|
const handleRAGToggle = async (
|
|
document: Types.Document,
|
|
includeInRag: boolean
|
|
): Promise<void> => {
|
|
try {
|
|
document.options = { includeInRag };
|
|
// Call API to update RAG flag
|
|
await apiClient.updateCandidateDocument(document);
|
|
|
|
setDocuments(prev =>
|
|
prev.map(doc => (doc.id === document.id ? { ...doc, includeInRag } : doc))
|
|
);
|
|
setSnack(`Document ${includeInRag ? 'included in' : 'excluded from'} RAG`, 'success');
|
|
} catch (error) {
|
|
setSnack('Failed to update RAG setting', 'error');
|
|
}
|
|
};
|
|
|
|
// Handle document rename
|
|
const handleRenameDocument = async (document: Types.Document, newName: string): Promise<void> => {
|
|
if (!newName.trim()) {
|
|
setSnack('Document name cannot be empty', 'error');
|
|
return;
|
|
}
|
|
|
|
try {
|
|
// Call API to rename document
|
|
document.filename = newName;
|
|
await apiClient.updateCandidateDocument(document);
|
|
|
|
setDocuments(prev =>
|
|
prev.map(doc => (doc.id === document.id ? { ...doc, filename: newName.trim() } : doc))
|
|
);
|
|
setSnack('Document renamed successfully', 'success');
|
|
setIsRenameDialogOpen(false);
|
|
setEditingDocument(null);
|
|
setEditingName('');
|
|
} catch (error) {
|
|
setSnack('Failed to rename document', 'error');
|
|
}
|
|
};
|
|
|
|
// Handle document content viewing
|
|
const handleViewDocument = async (document: Types.Document): Promise<void> => {
|
|
try {
|
|
setSelectedDocument(document);
|
|
setIsViewingContent(true);
|
|
|
|
// Call API to get document content
|
|
const result = await apiClient.getCandidateDocumentText(document);
|
|
setDocumentContent(result.content);
|
|
} catch (error) {
|
|
setSnack('Failed to load document content', 'error');
|
|
setIsViewingContent(false);
|
|
}
|
|
};
|
|
|
|
// Start rename process
|
|
const startRename = (document: Types.Document, currentName: string): void => {
|
|
setEditingDocument(document);
|
|
setEditingName(currentName);
|
|
setIsRenameDialogOpen(true);
|
|
};
|
|
|
|
// Format file size
|
|
const formatFileSize = (bytes: number): string => {
|
|
if (bytes === 0) return '0 Bytes';
|
|
const k = 1024;
|
|
const sizes = ['Bytes', 'KB', 'MB', 'GB'];
|
|
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
|
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
|
|
};
|
|
|
|
// Get file type color
|
|
const getFileTypeColor = (type: string): 'primary' | 'secondary' | 'success' | 'warning' => {
|
|
switch (type) {
|
|
case 'pdf':
|
|
return 'primary';
|
|
case 'docx':
|
|
return 'secondary';
|
|
case 'txt':
|
|
return 'success';
|
|
case 'md':
|
|
return 'warning';
|
|
default:
|
|
return 'primary';
|
|
}
|
|
};
|
|
|
|
if (!candidate) {
|
|
return <Box>You must be logged in as a candidate to view this content.</Box>;
|
|
}
|
|
|
|
return (
|
|
<>
|
|
<Grid container spacing={{ xs: 1, sm: 3 }} sx={{ maxWidth: '100%' }}>
|
|
<Box
|
|
sx={{
|
|
display: 'flex',
|
|
justifyContent: 'space-between',
|
|
alignItems: 'center',
|
|
mb: 2,
|
|
width: '100%',
|
|
verticalAlign: 'center',
|
|
}}
|
|
>
|
|
<Typography variant={isMobile ? 'subtitle2' : 'h6'}>Documents</Typography>
|
|
<Button
|
|
component="label"
|
|
variant="contained"
|
|
startIcon={<CloudUpload />}
|
|
size={isMobile ? 'small' : 'medium'}
|
|
>
|
|
Upload Document
|
|
<VisuallyHiddenInput
|
|
type="file"
|
|
accept=".txt,.md,.docx,.pdf"
|
|
onChange={handleDocumentUpload}
|
|
/>
|
|
</Button>
|
|
</Box>
|
|
|
|
<Grid size={{ xs: 12 }}>
|
|
<Card variant="outlined">
|
|
<CardContent sx={{ p: { xs: 1.5, sm: 3 } }}>
|
|
{documents.length === 0 ? (
|
|
<Typography
|
|
variant="body2"
|
|
color="text.secondary"
|
|
sx={{
|
|
fontSize: { xs: '0.8rem', sm: '0.875rem' },
|
|
textAlign: 'center',
|
|
py: 3,
|
|
}}
|
|
>
|
|
No additional documents uploaded
|
|
</Typography>
|
|
) : (
|
|
<List sx={{ width: '100%' }}>
|
|
{documents.map((doc, index) => (
|
|
<React.Fragment key={doc.id}>
|
|
{index > 0 && <Divider />}
|
|
<ListItem sx={{ px: 0 }}>
|
|
<ListItemText
|
|
primary={
|
|
<Box
|
|
sx={{
|
|
display: 'flex',
|
|
alignItems: 'center',
|
|
gap: 1,
|
|
flexWrap: 'wrap',
|
|
}}
|
|
>
|
|
<Typography
|
|
variant="body1"
|
|
sx={{
|
|
wordBreak: 'break-word',
|
|
fontSize: { xs: '0.9rem', sm: '1rem' },
|
|
}}
|
|
>
|
|
{doc.filename}
|
|
</Typography>
|
|
<Chip
|
|
label={doc.type.toUpperCase()}
|
|
size="small"
|
|
color={getFileTypeColor(doc.type)}
|
|
/>
|
|
{doc.options?.includeInRag && (
|
|
<Chip label="RAG" size="small" color="success" variant="outlined" />
|
|
)}
|
|
</Box>
|
|
}
|
|
secondary={
|
|
<Box sx={{ mt: 0.5 }}>
|
|
<Typography variant="caption" color="text.secondary">
|
|
{formatFileSize(doc.size)} • {doc?.uploadDate?.toLocaleDateString()}
|
|
</Typography>
|
|
<Box sx={{ mt: 1 }}>
|
|
<FormControlLabel
|
|
control={
|
|
<Switch
|
|
checked={doc.options?.includeInRag}
|
|
onChange={(e): void => {
|
|
handleRAGToggle(doc, e.target.checked);
|
|
}}
|
|
size="small"
|
|
/>
|
|
}
|
|
label={<Typography variant="caption">Include in RAG</Typography>}
|
|
/>
|
|
</Box>
|
|
</Box>
|
|
}
|
|
/>
|
|
<ListItemSecondaryAction>
|
|
<Box sx={{ display: 'flex', gap: 0.5 }}>
|
|
<IconButton
|
|
edge="end"
|
|
size="small"
|
|
onClick={(): void => {
|
|
handleViewDocument(doc);
|
|
}}
|
|
title="View content"
|
|
>
|
|
<Visibility />
|
|
</IconButton>
|
|
<IconButton
|
|
edge="end"
|
|
size="small"
|
|
onClick={(): void => {
|
|
startRename(doc, doc.filename);
|
|
}}
|
|
title="Rename"
|
|
>
|
|
<Edit />
|
|
</IconButton>
|
|
<IconButton
|
|
edge="end"
|
|
size="small"
|
|
onClick={(): void => {
|
|
handleDeleteDocument(doc);
|
|
}}
|
|
title="Delete"
|
|
color="error"
|
|
>
|
|
<Delete />
|
|
</IconButton>
|
|
</Box>
|
|
</ListItemSecondaryAction>
|
|
</ListItem>
|
|
</React.Fragment>
|
|
))}
|
|
</List>
|
|
)}
|
|
</CardContent>
|
|
</Card>
|
|
</Grid>
|
|
|
|
{/* Document Content Viewer */}
|
|
{isViewingContent && (
|
|
<Grid size={{ xs: 12 }}>
|
|
<Card variant="outlined">
|
|
<CardContent sx={{ p: { xs: 1.5, sm: 3 } }}>
|
|
<Box
|
|
sx={{
|
|
display: 'flex',
|
|
justifyContent: 'space-between',
|
|
alignItems: 'center',
|
|
mb: 2,
|
|
}}
|
|
>
|
|
<Typography variant={isMobile ? 'subtitle2' : 'h6'}>Document Content</Typography>
|
|
<IconButton
|
|
size="small"
|
|
onClick={(): void => {
|
|
setIsViewingContent(false);
|
|
setSelectedDocument(null);
|
|
setDocumentContent('');
|
|
}}
|
|
>
|
|
<Close />
|
|
</IconButton>
|
|
</Box>
|
|
<Paper
|
|
variant="outlined"
|
|
sx={{
|
|
p: 2,
|
|
maxHeight: 400,
|
|
overflow: 'auto',
|
|
backgroundColor: 'grey.50',
|
|
}}
|
|
>
|
|
<pre
|
|
style={{
|
|
margin: 0,
|
|
fontFamily: 'monospace',
|
|
fontSize: isMobile ? '0.75rem' : '0.875rem',
|
|
whiteSpace: 'pre-wrap',
|
|
wordBreak: 'break-word',
|
|
}}
|
|
>
|
|
{documentContent || 'Loading content...'}
|
|
</pre>
|
|
</Paper>
|
|
</CardContent>
|
|
</Card>
|
|
</Grid>
|
|
)}
|
|
|
|
{/* Rename Dialog */}
|
|
<Dialog
|
|
open={isRenameDialogOpen}
|
|
onClose={(): void => {
|
|
setIsRenameDialogOpen(false);
|
|
}}
|
|
maxWidth="sm"
|
|
fullWidth
|
|
>
|
|
<DialogTitle>Rename Document</DialogTitle>
|
|
<DialogContent>
|
|
<TextField
|
|
autoFocus
|
|
margin="dense"
|
|
label="Document Name"
|
|
fullWidth
|
|
variant="outlined"
|
|
value={editingName}
|
|
onChange={(e): void => {
|
|
setEditingName(e.target.value);
|
|
}}
|
|
onKeyUp={(e): void => {
|
|
if (e.key === 'Enter' && editingDocument) {
|
|
handleRenameDocument(editingDocument, editingName);
|
|
}
|
|
}}
|
|
/>
|
|
</DialogContent>
|
|
<DialogActions>
|
|
<Button
|
|
onClick={(): void => {
|
|
setIsRenameDialogOpen(false);
|
|
}}
|
|
>
|
|
Cancel
|
|
</Button>
|
|
<Button
|
|
onClick={(): void => {
|
|
editingDocument && handleRenameDocument(editingDocument, editingName);
|
|
}}
|
|
variant="contained"
|
|
disabled={!editingName.trim()}
|
|
>
|
|
Rename
|
|
</Button>
|
|
</DialogActions>
|
|
</Dialog>
|
|
</Grid>
|
|
</>
|
|
);
|
|
};
|
|
|
|
export { DocumentManager };
|