Readur/frontend/src/pages/DocumentDetailsPage.tsx

1497 lines
59 KiB
TypeScript

import React, { useState, useEffect } from 'react';
import { useParams, useNavigate } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import {
Box,
Typography,
Card,
CardContent,
Button,
Chip,
Stack,
Divider,
IconButton,
Paper,
Alert,
CircularProgress,
Tooltip,
Dialog,
DialogContent,
DialogTitle,
DialogActions,
Container,
Fade,
Skeleton,
TextField,
InputAdornment,
} from '@mui/material';
import Grid from '@mui/material/GridLegacy';
import {
ArrowBack as BackIcon,
Download as DownloadIcon,
PictureAsPdf as PdfIcon,
Image as ImageIcon,
Description as DocIcon,
TextSnippet as TextIcon,
CalendarToday as DateIcon,
Storage as SizeIcon,
Tag as TagIcon,
Label as LabelIcon,
Visibility as ViewIcon,
Search as SearchIcon,
Edit as EditIcon,
PhotoFilter as ProcessedImageIcon,
Source as SourceIcon,
AccessTime as AccessTimeIcon,
Create as CreateIcon,
Info as InfoIcon,
Refresh as RefreshIcon,
History as HistoryIcon,
Speed as SpeedIcon,
MoreVert as MoreIcon,
OpenInFull as ExpandIcon,
Close as CloseIcon,
Delete as DeleteIcon,
} from '@mui/icons-material';
import { documentService, OcrResponse, type Document } from '../services/api';
import DocumentViewer from '../components/DocumentViewer';
import LabelSelector from '../components/Labels/LabelSelector';
import { type LabelData } from '../components/Labels/Label';
import MetadataDisplay from '../components/MetadataDisplay';
import FileIntegrityDisplay from '../components/FileIntegrityDisplay';
import ProcessingTimeline from '../components/ProcessingTimeline';
import { RetryHistoryModal } from '../components/RetryHistoryModal';
import { useTheme } from '../contexts/ThemeContext';
import { useTheme as useMuiTheme } from '@mui/material/styles';
import api from '../services/api';
const DocumentDetailsPage: React.FC = () => {
const { t } = useTranslation();
const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
const { mode, modernTokens, glassEffect } = useTheme();
const theme = useMuiTheme();
const [document, setDocument] = useState<Document | null>(null);
const [loading, setLoading] = useState<boolean>(true);
const [error, setError] = useState<string | null>(null);
const [ocrText, setOcrText] = useState<string>('');
const [ocrData, setOcrData] = useState<OcrResponse | null>(null);
const [showOcrDialog, setShowOcrDialog] = useState<boolean>(false);
const [ocrLoading, setOcrLoading] = useState<boolean>(false);
const [showViewDialog, setShowViewDialog] = useState<boolean>(false);
const [showProcessedImageDialog, setShowProcessedImageDialog] = useState<boolean>(false);
const [processedImageUrl, setProcessedImageUrl] = useState<string | null>(null);
const [processedImageLoading, setProcessedImageLoading] = useState<boolean>(false);
const [thumbnailUrl, setThumbnailUrl] = useState<string | null>(null);
const [documentLabels, setDocumentLabels] = useState<LabelData[]>([]);
const [ocrSearchTerm, setOcrSearchTerm] = useState<string>(''); const [expandedOcrText, setExpandedOcrText] = useState<boolean>(false);
const [availableLabels, setAvailableLabels] = useState<LabelData[]>([]);
const [showLabelDialog, setShowLabelDialog] = useState<boolean>(false);
const [labelsLoading, setLabelsLoading] = useState<boolean>(false);
// Retry functionality state
const [retryingOcr, setRetryingOcr] = useState<boolean>(false);
const [retryHistoryModalOpen, setRetryHistoryModalOpen] = useState<boolean>(false);
// Delete functionality state
const [deleting, setDeleting] = useState<boolean>(false);
const [deleteConfirmOpen, setDeleteConfirmOpen] = useState<boolean>(false);
// Retry handlers
const handleRetryOcr = async () => {
if (!document) return;
setRetryingOcr(true);
try {
await documentService.bulkRetryOcr({
mode: 'specific',
document_ids: [document.id],
priority_override: 15,
});
// Show success message and refresh document
setTimeout(() => {
fetchDocumentDetails();
}, 1000);
} catch (error) {
console.error('Failed to retry OCR:', error);
} finally {
setRetryingOcr(false);
}
};
const handleShowRetryHistory = () => {
setRetryHistoryModalOpen(true);
};
// Delete handlers
const handleDeleteDocument = async () => {
if (!document) return;
setDeleting(true);
try {
await documentService.delete(document.id);
// Navigate back to documents page after successful deletion
navigate('/documents');
} catch (error) {
console.error('Failed to delete document:', error);
// Show error message to user
alert(t('common.status.error'));
} finally {
setDeleting(false);
setDeleteConfirmOpen(false);
}
};
const handleDeleteClick = () => {
setDeleteConfirmOpen(true);
};
useEffect(() => {
if (id) {
fetchDocumentDetails();
}
}, [id]);
useEffect(() => {
if (document && document.has_ocr_text && !ocrData) {
fetchOcrText();
}
}, [document]);
useEffect(() => {
if (document) {
loadThumbnail();
fetchDocumentLabels();
}
}, [document]);
useEffect(() => {
fetchAvailableLabels();
}, []);
const fetchDocumentDetails = async (): Promise<void> => {
if (!id) {
setError(t('documentDetails.errors.notFound'));
setLoading(false);
return;
}
try {
setLoading(true);
setError(null);
const response = await documentService.getById(id);
setDocument(response.data);
} catch (err: any) {
const errorMessage = err.message || t('common.status.error');
setError(errorMessage);
console.error('Failed to fetch document details:', err);
} finally {
setLoading(false);
}
};
const handleDownload = async (): Promise<void> => {
if (!document) return;
try {
const response = await documentService.download(document.id);
const url = window.URL.createObjectURL(new Blob([response.data]));
const link = window.document.createElement('a');
link.href = url;
link.setAttribute('download', document.original_filename);
window.document.body.appendChild(link);
link.click();
link.remove();
window.URL.revokeObjectURL(url);
} catch (err) {
console.error('Download failed:', err);
}
};
const fetchOcrText = async (): Promise<void> => {
if (!document || !document.has_ocr_text) return;
try {
setOcrLoading(true);
const response = await documentService.getOcrText(document.id);
setOcrData(response.data);
setOcrText(response.data.ocr_text || t('documentDetails.ocr.noText'));
} catch (err) {
console.error('Failed to fetch OCR text:', err);
setOcrText(t('documentDetails.ocr.loadFailed'));
} finally {
setOcrLoading(false);
}
};
const handleViewOcr = (): void => {
setShowOcrDialog(true);
if (!ocrData) {
fetchOcrText();
}
};
const handleViewProcessedImage = async (): Promise<void> => {
if (!document) return;
setProcessedImageLoading(true);
try {
const response = await documentService.getProcessedImage(document.id);
const url = window.URL.createObjectURL(new Blob([response.data], { type: 'image/png' }));
setProcessedImageUrl(url);
setShowProcessedImageDialog(true);
} catch (err: any) {
console.log('Processed image not available:', err);
alert(t('documentDetails.dialogs.processedImage.noImage'));
} finally {
setProcessedImageLoading(false);
}
};
const loadThumbnail = async (): Promise<void> => {
if (!document) return;
try {
const response = await documentService.getThumbnail(document.id);
const url = window.URL.createObjectURL(new Blob([response.data]));
setThumbnailUrl(url);
} catch (err) {
console.log('Thumbnail not available:', err);
// Thumbnail not available, use fallback icon
}
};
const handleViewDocument = (): void => {
setShowViewDialog(true);
};
const getFileIcon = (mimeType?: string): React.ReactElement => {
if (mimeType?.includes('pdf')) return <PdfIcon color="error" sx={{ fontSize: 64 }} />;
if (mimeType?.includes('image')) return <ImageIcon color="primary" sx={{ fontSize: 64 }} />;
if (mimeType?.includes('text')) return <TextIcon color="info" sx={{ fontSize: 64 }} />;
return <DocIcon color="secondary" sx={{ fontSize: 64 }} />;
};
const formatFileSize = (bytes: number): string => {
const sizes = ['Bytes', 'KB', 'MB', 'GB'];
if (bytes === 0) return '0 Bytes';
const i = Math.floor(Math.log(bytes) / Math.log(1024));
return Math.round(bytes / Math.pow(1024, i) * 100) / 100 + ' ' + sizes[i];
};
const formatDate = (dateString: string): string => {
return new Date(dateString).toLocaleString('en-US', {
year: 'numeric',
month: 'long',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
});
};
const fetchDocumentLabels = async (): Promise<void> => {
if (!id) return;
try {
const response = await api.get(`/labels/documents/${id}`);
if (response.status === 200 && Array.isArray(response.data)) {
setDocumentLabels(response.data);
}
} catch (error) {
console.error('Failed to fetch document labels:', error);
}
};
const fetchAvailableLabels = async (): Promise<void> => {
try {
setLabelsLoading(true);
const response = await api.get('/labels?include_counts=false');
if (response.status === 200 && Array.isArray(response.data)) {
setAvailableLabels(response.data);
}
} catch (error) {
console.error('Failed to fetch available labels:', error);
} finally {
setLabelsLoading(false);
}
};
const handleCreateLabel = async (labelData: Omit<LabelData, 'id' | 'is_system' | 'created_at' | 'updated_at' | 'document_count' | 'source_count'>) => {
try {
const response = await api.post('/labels', labelData);
const newLabel = response.data;
setAvailableLabels(prev => [...prev, newLabel]);
return newLabel;
} catch (error) {
console.error('Failed to create label:', error);
throw error;
}
};
const handleSaveLabels = async (selectedLabels: LabelData[]): Promise<void> => {
if (!id) return;
try {
const labelIds = selectedLabels.map(label => label.id);
await api.put(`/labels/documents/${id}`, { label_ids: labelIds });
setDocumentLabels(selectedLabels);
setShowLabelDialog(false);
} catch (error) {
console.error('Failed to save labels:', error);
}
};
if (loading) {
return (
<Box display="flex" justifyContent="center" alignItems="center" minHeight="400px">
<CircularProgress />
</Box>
);
}
if (error || !document) {
return (
<Box sx={{ p: 3 }}>
<Button
startIcon={<BackIcon />}
onClick={() => navigate('/documents')}
sx={{ mb: 3 }}
>
{t('documentDetails.actions.backToDocuments')}
</Button>
<Alert severity="error">
{error || t('documentDetails.errors.notFound')}
</Alert>
</Box>
);
}
return (
<Box
sx={{
minHeight: '100vh',
backgroundColor: theme.palette.background.default,
}}
>
<Container maxWidth="xl" sx={{ py: 4 }}>
{/* Modern Header */}
<Fade in timeout={600}>
<Box sx={{ mb: 6 }}>
<Button
startIcon={<BackIcon />}
onClick={() => navigate('/documents')}
sx={{
mb: 3,
color: theme.palette.text.secondary,
'&:hover': {
backgroundColor: theme.palette.action.hover,
},
}}
>
{t('documentDetails.actions.backToDocuments')}
</Button>
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 2 }}>
<Typography
variant="h4"
sx={{
fontWeight: 700,
background: `linear-gradient(135deg, ${theme.palette.primary.main} 0%, ${theme.palette.secondary.main} 100%)`,
backgroundClip: 'text',
WebkitBackgroundClip: 'text',
color: 'transparent',
letterSpacing: '-0.02em',
}}
>
{document?.original_filename || t('navigation.documents')}
</Typography>
{/* Floating Action Menu */}
<Box sx={{ display: 'flex', gap: 1 }}>
<Tooltip title={t('documentDetails.actions.download')}>
<IconButton
onClick={handleDownload}
sx={{
backgroundColor: theme.palette.action.hover,
backdropFilter: 'blur(10px)',
color: theme.palette.primary.main,
'&:hover': {
transform: 'scale(1.05)',
backgroundColor: theme.palette.primary.light,
},
}}
>
<DownloadIcon />
</IconButton>
</Tooltip>
<Tooltip title={t('documentDetails.actions.viewDocument')}>
<IconButton
onClick={handleViewDocument}
sx={{
backgroundColor: theme.palette.action.hover,
backdropFilter: 'blur(10px)',
color: theme.palette.primary.main,
'&:hover': {
transform: 'scale(1.05)',
backgroundColor: theme.palette.primary.light,
},
}}
>
<ViewIcon />
</IconButton>
</Tooltip>
{document?.has_ocr_text && (
<Tooltip title={t('documentDetails.actions.viewOcrText')}>
<IconButton
onClick={handleViewOcr}
sx={{
backgroundColor: theme.palette.action.hover,
backdropFilter: 'blur(10px)',
color: theme.palette.secondary.main,
'&:hover': {
transform: 'scale(1.05)',
backgroundColor: theme.palette.secondary.light,
},
}}
>
<SearchIcon />
</IconButton>
</Tooltip>
)}
<Tooltip title={t('documentDetails.actions.deleteDocument')}>
<IconButton
onClick={handleDeleteClick}
disabled={deleting}
sx={{
backgroundColor: theme.palette.action.hover,
backdropFilter: 'blur(10px)',
color: theme.palette.error.main,
'&:hover': {
transform: 'scale(1.05)',
backgroundColor: theme.palette.error.light,
},
'&:disabled': {
opacity: 0.6,
},
}}
>
{deleting ? <CircularProgress size={20} /> : <DeleteIcon />}
</IconButton>
</Tooltip>
</Box>
</Box>
<Typography variant="body1" color="text.secondary" sx={{ fontSize: '1.1rem' }}>
{t('documentDetails.subtitle')}
</Typography>
</Box>
</Fade>
{/* Modern Content Layout */}
<Fade in timeout={800}>
<Grid container spacing={4}>
{/* Hero Document Preview */}
<Grid item xs={12} lg={5}>
<Card
sx={{
backgroundColor: theme.palette.background.paper,
backdropFilter: 'blur(10px)',
height: 'fit-content',
}}
>
<CardContent sx={{ p: 4 }}>
{/* Document Preview */}
<Box
sx={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
mb: 4,
p: 4,
background: `linear-gradient(135deg, ${theme.palette.primary.light} 0%, ${theme.palette.secondary.light} 100%)`,
borderRadius: 3,
minHeight: 280,
position: 'relative',
overflow: 'hidden',
'&::before': {
content: '""',
position: 'absolute',
top: 0,
left: 0,
right: 0,
bottom: 0,
background: 'radial-gradient(circle at 30% 30%, rgba(255,255,255,0.3) 0%, transparent 50%)',
pointerEvents: 'none',
},
}}
>
{thumbnailUrl ? (
<img
src={thumbnailUrl}
alt={document.original_filename}
onClick={handleViewDocument}
style={{
maxWidth: '100%',
maxHeight: '250px',
borderRadius: '12px',
objectFit: 'contain',
cursor: 'pointer',
transition: 'all 0.3s cubic-bezier(0.4, 0, 0.2, 1)',
boxShadow: theme.shadows[8],
}}
onMouseEnter={(e) => {
e.currentTarget.style.transform = 'scale(1.05) rotateY(5deg)';
e.currentTarget.style.boxShadow = theme.shadows[12];
}}
onMouseLeave={(e) => {
e.currentTarget.style.transform = 'scale(1) rotateY(0deg)';
e.currentTarget.style.boxShadow = theme.shadows[8];
}}
/>
) : (
<Box
onClick={handleViewDocument}
sx={{
cursor: 'pointer',
transition: 'all 0.3s cubic-bezier(0.4, 0, 0.2, 1)',
'&:hover': {
transform: 'scale(1.1) rotateY(10deg)',
}
}}
>
<Box sx={{ fontSize: 120, color: theme.palette.primary.main, display: 'flex' }}>
{getFileIcon(document.mime_type)}
</Box>
</Box>
)}
</Box>
{/* File Type Badge */}
<Box sx={{ display: 'flex', justifyContent: 'center', mb: 3 }}>
<Chip
label={document.mime_type}
sx={{
backgroundColor: theme.palette.primary.light,
color: theme.palette.primary.dark,
fontWeight: 600,
border: `1px solid ${theme.palette.primary.main}`,
}}
/>
</Box>
{/* Quick Stats */}
<Stack spacing={2}>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<Typography variant="body2" color="text.secondary">
{t('documentDetails.metadata.fileSize')}
</Typography>
<Typography variant="body2" sx={{ fontWeight: 600 }}>
{formatFileSize(document.file_size)}
</Typography>
</Box>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<Typography variant="body2" color="text.secondary">
{t('documentDetails.metadata.uploadDate')}
</Typography>
<Typography variant="body2" sx={{ fontWeight: 600 }}>
{formatDate(document.created_at)}
</Typography>
</Box>
{document.source_type && (
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<Typography variant="body2" color="text.secondary">
{t('documentDetails.metadata.sourceType')}
</Typography>
<Chip
label={document.source_type.replace('_', ' ').toUpperCase()}
size="small"
sx={{
backgroundColor: theme.palette.info.light,
color: theme.palette.info.dark,
fontWeight: 600,
}}
/>
</Box>
)}
{document.source_path && (
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<Typography variant="body2" color="text.secondary">
{t('documentDetails.metadata.originalPath')}
</Typography>
<Typography
variant="body2"
sx={{
fontWeight: 600,
maxWidth: '200px',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}}
title={document.source_path}
>
{document.source_path}
</Typography>
</Box>
)}
{document.original_created_at && (
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<Typography variant="body2" color="text.secondary">
{t('documentDetails.metadata.originalCreated')}
</Typography>
<Typography variant="body2" sx={{ fontWeight: 600 }}>
{formatDate(document.original_created_at)}
</Typography>
</Box>
)}
{document.original_modified_at && (
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<Typography variant="body2" color="text.secondary">
{t('documentDetails.metadata.originalModified')}
</Typography>
<Typography variant="body2" sx={{ fontWeight: 600 }}>
{formatDate(document.original_modified_at)}
</Typography>
</Box>
)}
{document.has_ocr_text && (
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<Typography variant="body2" color="text.secondary">
{t('documentDetails.metadata.ocrStatus')}
</Typography>
<Chip
label={t('documentDetails.metadata.textExtracted')}
color="success"
size="small"
icon={<TextIcon sx={{ fontSize: 16 }} />}
/>
</Box>
)}
</Stack>
{/* Action Buttons */}
<Stack direction="row" spacing={1} sx={{ mt: 4 }} justifyContent="center">
{document.mime_type?.includes('image') && (
<Tooltip title={t('documentDetails.actions.viewProcessedImage')}>
<IconButton
onClick={handleViewProcessedImage}
disabled={processedImageLoading}
sx={{
backgroundColor: theme.palette.secondary.light,
color: theme.palette.secondary.dark,
'&:hover': {
backgroundColor: theme.palette.secondary[200],
transform: 'scale(1.1)',
},
}}
>
{processedImageLoading ? (
<CircularProgress size={20} />
) : (
<ProcessedImageIcon />
)}
</IconButton>
</Tooltip>
)}
<Tooltip title={t('documentDetails.actions.retryOcr')}>
<IconButton
onClick={handleRetryOcr}
disabled={retryingOcr}
sx={{
backgroundColor: theme.palette.warning.light,
color: theme.palette.warning.dark,
'&:hover': {
backgroundColor: theme.palette.warning[200],
transform: 'scale(1.1)',
},
}}
>
{retryingOcr ? (
<CircularProgress size={20} />
) : (
<RefreshIcon />
)}
</IconButton>
</Tooltip>
<Tooltip title={t('documentDetails.actions.retryHistory')}>
<IconButton
onClick={handleShowRetryHistory}
sx={{
backgroundColor: theme.palette.info.light,
color: theme.palette.info.dark,
'&:hover': {
backgroundColor: theme.palette.info[200],
transform: 'scale(1.1)',
},
}}
>
<HistoryIcon />
</IconButton>
</Tooltip>
</Stack>
</CardContent>
</Card>
{/* File Integrity Display - Moved here */}
<Box sx={{ mt: 3 }}>
<FileIntegrityDisplay
fileHash={document.file_hash}
fileName={document.original_filename}
fileSize={document.file_size}
mimeType={document.mime_type}
createdAt={document.created_at}
updatedAt={document.updated_at}
userId={document.user_id}
username={document.username}
sourceType={document.source_type}
sourcePath={document.source_path}
filePermissions={document.file_permissions}
fileOwner={document.file_owner}
fileGroup={document.file_group}
originalCreatedAt={document.original_created_at}
originalModifiedAt={document.original_modified_at}
sourceMetadata={document.source_metadata}
/>
</Box>
</Grid>
{/* Main Content Area */}
<Grid item xs={12} lg={7}>
<Stack spacing={4}>
{/* OCR Text Section - Moved higher */}
{document.has_ocr_text && (
<Card
sx={{
backgroundColor: theme.palette.background.paper,
backdropFilter: 'blur(10px)',
}}
>
<CardContent sx={{ p: 4 }}>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 3 }}>
<Typography variant="h5" sx={{ fontWeight: 700 }}>
{t('documentDetails.ocr.title')}
</Typography>
{ocrData?.ocr_text && (
<Tooltip title={t('documentDetails.ocr.expandTooltip')}>
<IconButton
onClick={() => setExpandedOcrText(true)}
sx={{
backgroundColor: theme.palette.primary.main,
color: theme.palette.primary.contrastText,
'&:hover': {
backgroundColor: theme.palette.primary.dark,
},
borderRadius: 2,
px: 2,
}}
>
<ExpandIcon sx={{ mr: 1 }} />
<Typography variant="button" sx={{ fontSize: '0.75rem' }}>
{t('documentDetails.ocr.expand')}
</Typography>
</IconButton>
</Tooltip>
)}
</Box>
{ocrLoading ? (
<Box sx={{ display: 'flex', justifyContent: 'center', alignItems: 'center', py: 6 }}>
<CircularProgress size={32} sx={{ mr: 2 }} />
<Typography variant="h6" color="text.secondary">
{t('documentDetails.ocr.loading')}
</Typography>
</Box>
) : ocrData ? (
<>
{/* Enhanced OCR Stats */}
<Box sx={{ mb: 4, display: 'flex', gap: 2, flexWrap: 'wrap' }}>
{ocrData.ocr_confidence && (
<Box
sx={{
p: 2,
borderRadius: 2,
backgroundColor: mode === 'light' ? modernTokens.colors.primary[100] : modernTokens.colors.primary[800],
border: `1px solid ${mode === 'light' ? modernTokens.colors.primary[300] : modernTokens.colors.primary[600]}`,
textAlign: 'center',
minWidth: 120,
}}
>
<Typography variant="h5" sx={{ fontWeight: 700, color: mode === 'light' ? modernTokens.colors.primary[700] : modernTokens.colors.primary[300] }}>
{Math.round(ocrData.ocr_confidence)}%
</Typography>
<Typography variant="caption" color="text.secondary">
{t('documentDetails.ocr.confidence')}
</Typography>
</Box>
)}
{ocrData.ocr_word_count != null && (
<Box
sx={{
p: 2,
borderRadius: 2,
backgroundColor: mode === 'light' ? modernTokens.colors.secondary[100] : modernTokens.colors.secondary[800],
border: `1px solid ${mode === 'light' ? modernTokens.colors.secondary[300] : modernTokens.colors.secondary[600]}`,
textAlign: 'center',
minWidth: 120,
}}
>
<Typography variant="h5" sx={{ fontWeight: 700, color: mode === 'light' ? modernTokens.colors.secondary[700] : modernTokens.colors.secondary[300] }}>
{ocrData.ocr_word_count.toLocaleString()}
</Typography>
<Typography variant="caption" color="text.secondary">
{t('documentDetails.ocr.words')}
</Typography>
</Box>
)}
{ocrData.ocr_processing_time_ms && (
<Box
sx={{
p: 2,
borderRadius: 2,
backgroundColor: mode === 'light' ? modernTokens.colors.info[100] : modernTokens.colors.info[800],
border: `1px solid ${mode === 'light' ? modernTokens.colors.info[300] : modernTokens.colors.info[600]}`,
textAlign: 'center',
minWidth: 120,
}}
>
<Typography variant="h5" sx={{ fontWeight: 700, color: mode === 'light' ? modernTokens.colors.info[700] : modernTokens.colors.info[300] }}>
{ocrData.ocr_processing_time_ms}ms
</Typography>
<Typography variant="caption" color="text.secondary">
{t('documentDetails.ocr.processingTime')}
</Typography>
</Box>
)}
</Box>
{/* OCR Error Display */}
{ocrData.ocr_error && (
<Alert
severity="error"
sx={{
mb: 3,
borderRadius: 2,
}}
>
<Typography variant="subtitle2" sx={{ fontWeight: 600 }}>
{t('documentDetails.ocr.error')}
</Typography>
<Typography variant="body2">{ocrData.ocr_error}</Typography>
</Alert>
)}
{/* Full OCR Text Display */}
<Paper
sx={{
p: 4,
backgroundColor: theme.palette.background.default,
borderRadius: 3,
maxHeight: 400,
overflow: 'auto',
// Custom scrollbar styling
'&::-webkit-scrollbar': {
width: '8px',
},
'&::-webkit-scrollbar-track': {
backgroundColor: mode === 'light' ? modernTokens.colors.neutral[100] : modernTokens.colors.neutral[800],
borderRadius: '4px',
},
'&::-webkit-scrollbar-thumb': {
backgroundColor: mode === 'light' ? modernTokens.colors.neutral[300] : modernTokens.colors.neutral[600],
borderRadius: '4px',
'&:hover': {
backgroundColor: mode === 'light' ? modernTokens.colors.neutral[400] : modernTokens.colors.neutral[500],
},
},
// Firefox scrollbar styling
scrollbarWidth: 'thin',
scrollbarColor: mode === 'light'
? `${modernTokens.colors.neutral[300]} ${modernTokens.colors.neutral[100]}`
: `${modernTokens.colors.neutral[600]} ${modernTokens.colors.neutral[800]}`,
}}
>
{ocrData.ocr_text ? (
<Typography
variant="body1"
sx={{
fontFamily: '"Inter", monospace',
whiteSpace: 'pre-wrap',
lineHeight: 1.8,
fontSize: '0.95rem',
}}
>
{ocrData.ocr_text}
</Typography>
) : (
<Typography variant="body1" color="text.secondary" sx={{ fontStyle: 'italic', textAlign: 'center', py: 4 }}>
{t('documentDetails.ocr.noText')}
</Typography>
)}
</Paper>
{/* Processing Info */}
{ocrData.ocr_completed_at && (
<Box sx={{ mt: 3, pt: 3, borderTop: `1px solid ${theme.palette.divider}` }}>
<Typography variant="body2" color="text.secondary">
{t('documentDetails.ocr.completed', { date: new Date(ocrData.ocr_completed_at).toLocaleString() })}
</Typography>
</Box>
)}
</>
) : (
<Alert
severity="info"
sx={{
borderRadius: 2,
}}
>
{t('documentDetails.ocr.loadFailed')}
</Alert>
)}
</CardContent>
</Card>
)}
{/* Processing Timeline */}
<ProcessingTimeline
documentId={document.id}
fileName={document.original_filename}
createdAt={document.created_at}
updatedAt={document.updated_at}
userId={document.user_id}
username={document.username}
ocrStatus={document.has_ocr_text ? 'completed' : 'pending'}
ocrCompletedAt={ocrData?.ocr_completed_at}
ocrError={ocrData?.ocr_error}
/>
{/* Tags and Labels */}
<Card
sx={{
backgroundColor: theme.palette.background.paper,
backdropFilter: 'blur(10px)',
}}
>
<CardContent sx={{ p: 4 }}>
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 3 }}>
<Typography variant="h5" sx={{ fontWeight: 700 }}>
{t('documentDetails.tagsLabels.title')}
</Typography>
<Button
startIcon={<EditIcon />}
onClick={() => setShowLabelDialog(true)}
sx={{
backgroundColor: theme.palette.secondary.light,
color: theme.palette.secondary.dark,
'&:hover': {
backgroundColor: theme.palette.secondary[200],
},
}}
>
{t('documentDetails.actions.editLabels')}
</Button>
</Box>
{/* Tags */}
{document.tags && document.tags.length > 0 && (
<Box sx={{ mb: 3 }}>
<Typography variant="subtitle1" sx={{ mb: 2, fontWeight: 600 }}>
{t('documentDetails.tagsLabels.tags')}
</Typography>
<Stack direction="row" spacing={1} flexWrap="wrap" gap={1}>
{document.tags.map((tag, index) => (
<Chip
key={index}
label={tag}
sx={{
backgroundColor: theme.palette.primary.light,
color: theme.palette.primary.dark,
border: `1px solid ${theme.palette.primary.main}`,
fontWeight: 500,
}}
/>
))}
</Stack>
</Box>
)}
{/* Labels */}
<Box>
<Typography variant="subtitle1" sx={{ mb: 2, fontWeight: 600 }}>
{t('documentDetails.tagsLabels.labels')}
</Typography>
{documentLabels.length > 0 ? (
<Stack direction="row" spacing={1} flexWrap="wrap" gap={1}>
{documentLabels.map((label) => (
<Chip
key={label.id}
label={label.name}
sx={{
backgroundColor: label.background_color || `${label.color}20`,
color: label.color,
border: `1px solid ${label.color}`,
fontWeight: 500,
}}
/>
))}
</Stack>
) : (
<Typography variant="body2" color="text.secondary" sx={{ fontStyle: 'italic' }}>
{t('documentDetails.tagsLabels.noLabels')}
</Typography>
)}
</Box>
</CardContent>
</Card>
</Stack>
</Grid>
</Grid>
</Fade>
</Container>
{/* OCR Text Dialog */}
<Dialog
open={showOcrDialog}
onClose={() => setShowOcrDialog(false)}
maxWidth="md"
fullWidth
>
<DialogTitle>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<Typography variant="h6" sx={{ fontWeight: 600 }}>
{t('documentDetails.dialogs.ocrText.title')}
</Typography>
{ocrData && (
<Stack direction="row" spacing={1}>
{ocrData.ocr_confidence && (
<Chip
label={t('documentDetails.dialogs.ocrText.confidence', { percent: Math.round(ocrData.ocr_confidence) })}
color="primary"
size="small"
/>
)}
{ocrData.ocr_word_count != null && (
<Chip
label={t('documentDetails.dialogs.ocrText.words', { count: ocrData.ocr_word_count })}
color="secondary"
size="small"
/>
)}
</Stack>
)}
</Box>
</DialogTitle>
<DialogContent>
{ocrLoading ? (
<Box sx={{ display: 'flex', justifyContent: 'center', alignItems: 'center', py: 4 }}>
<CircularProgress />
<Typography variant="body2" sx={{ ml: 2 }}>
{t('documentDetails.dialogs.ocrText.loading')}
</Typography>
</Box>
) : (
<>
{ocrData && ocrData.ocr_error && (
<Alert severity="error" sx={{ mb: 2 }}>
{t('documentDetails.dialogs.ocrText.error', { message: ocrData.ocr_error })}
</Alert>
)}
<Paper
sx={{
p: 2,
backgroundColor: 'grey.50',
border: '1px solid',
borderColor: 'grey.200',
maxHeight: 400,
overflow: 'auto',
}}
>
<Typography
variant="body2"
sx={{
fontFamily: 'monospace',
whiteSpace: 'pre-wrap',
color: ocrText ? 'text.primary' : 'text.secondary',
lineHeight: 1.6,
}}
>
{ocrText || t('documentDetails.dialogs.ocrText.noText')}
</Typography>
</Paper>
{ocrData && (ocrData.ocr_processing_time_ms || ocrData.ocr_completed_at) && (
<Box sx={{ mt: 2, pt: 2, borderTop: '1px solid', borderColor: 'grey.200' }}>
<Typography variant="caption" color="text.secondary">
{ocrData.ocr_processing_time_ms && t('documentDetails.dialogs.ocrText.processingTime', { time: ocrData.ocr_processing_time_ms })}
{ocrData.ocr_processing_time_ms && ocrData.ocr_completed_at && ' • '}
{ocrData.ocr_completed_at && t('documentDetails.dialogs.ocrText.completed', { date: new Date(ocrData.ocr_completed_at).toLocaleString() })}
</Typography>
</Box>
)}
</>
)}
</DialogContent>
<DialogActions>
<Button onClick={() => setShowOcrDialog(false)}>
{t('common.actions.close')}
</Button>
</DialogActions>
</Dialog>
{/* Expanded OCR Text Dialog with Search */}
<Dialog
open={expandedOcrText}
onClose={() => {
setExpandedOcrText(false);
setOcrSearchTerm('');
}}
maxWidth="lg"
fullWidth
PaperProps={{
sx: {
height: '90vh',
backgroundColor: theme.palette.background.paper,
}
}}
>
<DialogTitle>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<Typography variant="h5" sx={{ fontWeight: 600 }}>
{t('documentDetails.dialogs.ocrExpanded.title')}
</Typography>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2 }}>
{ocrData && (
<Stack direction="row" spacing={1}>
{ocrData.ocr_confidence && (
<Chip
label={t('documentDetails.dialogs.ocrText.confidence', { percent: Math.round(ocrData.ocr_confidence) })}
color="primary"
size="small"
/>
)}
{ocrData.ocr_word_count != null && (
<Chip
label={t('documentDetails.dialogs.ocrText.words', { count: ocrData.ocr_word_count })}
color="secondary"
size="small"
/>
)}
</Stack>
)}
<IconButton
onClick={() => {
setExpandedOcrText(false);
setOcrSearchTerm('');
}}
sx={{
backgroundColor: theme.palette.action.hover,
'&:hover': {
backgroundColor: theme.palette.action.selected,
},
}}
>
<CloseIcon />
</IconButton>
</Box>
</Box>
</DialogTitle>
<DialogContent sx={{ p: 0 }}>
{/* Search Bar */}
<Box sx={{ p: 3, borderBottom: `1px solid ${theme.palette.divider}` }}>
<TextField
fullWidth
variant="outlined"
placeholder={t('documentDetails.dialogs.ocrExpanded.searchPlaceholder')}
value={ocrSearchTerm}
onChange={(e) => setOcrSearchTerm(e.target.value)}
InputProps={{
startAdornment: (
<InputAdornment position="start">
<SearchIcon color="action" />
</InputAdornment>
),
endAdornment: ocrSearchTerm && (
<InputAdornment position="end">
<IconButton
size="small"
onClick={() => setOcrSearchTerm('')}
>
<CloseIcon fontSize="small" />
</IconButton>
</InputAdornment>
),
}}
sx={{
'& .MuiOutlinedInput-root': {
borderRadius: 2,
},
}}
/>
{ocrSearchTerm && (
<Typography variant="caption" color="text.secondary" sx={{ mt: 1, display: 'block' }}>
{(() => {
const text = ocrData?.ocr_text || '';
const matches = text.toLowerCase().split(ocrSearchTerm.toLowerCase()).length - 1;
return matches > 0
? t('documentDetails.dialogs.ocrExpanded.matches', { count: matches, plural: matches === 1 ? '' : 'es' })
: t('documentDetails.dialogs.ocrExpanded.noMatches');
})()}
</Typography>
)}
</Box>
{/* OCR Text Content */}
<Box sx={{ p: 3, height: 'calc(100% - 120px)', overflow: 'auto' }}>
{ocrLoading ? (
<Box sx={{ display: 'flex', justifyContent: 'center', alignItems: 'center', py: 6 }}>
<CircularProgress />
<Typography variant="body2" sx={{ ml: 2 }}>
{t('documentDetails.dialogs.ocrExpanded.loading')}
</Typography>
</Box>
) : (
<>
{ocrData && ocrData.ocr_error && (
<Alert severity="error" sx={{ mb: 2 }}>
{t('documentDetails.dialogs.ocrExpanded.error', { message: ocrData.ocr_error })}
</Alert>
)}
<Paper
sx={{
p: 3,
backgroundColor: theme.palette.background.default,
border: `1px solid ${theme.palette.divider}`,
borderRadius: 2,
minHeight: 400,
}}
>
<Typography
variant="body1"
sx={{
fontFamily: '"Inter", monospace',
whiteSpace: 'pre-wrap',
lineHeight: 1.8,
fontSize: '1rem',
color: ocrData?.ocr_text ? 'text.primary' : 'text.secondary',
}}
dangerouslySetInnerHTML={{
__html: ocrData?.ocr_text ? (
ocrSearchTerm
? ocrData.ocr_text.replace(
new RegExp(`(${ocrSearchTerm.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')})`, 'gi'),
'<mark style="background-color: #ffeb3b; color: #000; padding: 2px 4px; border-radius: 2px;">$1</mark>'
)
: ocrData.ocr_text
) : t('documentDetails.dialogs.ocrExpanded.noText')
}}
/>
</Paper>
{ocrData && (ocrData.ocr_processing_time_ms || ocrData.ocr_completed_at) && (
<Box sx={{ mt: 3, pt: 2, borderTop: `1px solid ${theme.palette.divider}` }}>
<Typography variant="caption" color="text.secondary">
{ocrData.ocr_processing_time_ms && t('documentDetails.dialogs.ocrText.processingTime', { time: ocrData.ocr_processing_time_ms })}
{ocrData.ocr_processing_time_ms && ocrData.ocr_completed_at && ' • '}
{ocrData.ocr_completed_at && t('documentDetails.dialogs.ocrText.completed', { date: new Date(ocrData.ocr_completed_at).toLocaleString() })}
</Typography>
</Box>
)}
</>
)}
</Box>
</DialogContent>
</Dialog>
{/* Document View Dialog */}
<Dialog
open={showViewDialog}
onClose={() => setShowViewDialog(false)}
maxWidth="lg"
fullWidth
PaperProps={{
sx: { height: '90vh' }
}}
>
<DialogTitle>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<Typography variant="h6" sx={{ fontWeight: 600 }}>
{document?.original_filename}
</Typography>
<Box>
<Button
startIcon={<DownloadIcon />}
onClick={handleDownload}
size="small"
sx={{ mr: 1 }}
>
{t('common.actions.download')}
</Button>
</Box>
</Box>
</DialogTitle>
<DialogContent sx={{ p: 0, display: 'flex', flexDirection: 'column' }}>
{document && (
<DocumentViewer
documentId={document.id}
filename={document.original_filename}
mimeType={document.mime_type}
/>
)}
</DialogContent>
<DialogActions>
<Button onClick={() => setShowViewDialog(false)}>
{t('common.actions.close')}
</Button>
</DialogActions>
</Dialog>
{/* Processed Image Dialog */}
<Dialog
open={showProcessedImageDialog}
onClose={() => setShowProcessedImageDialog(false)}
maxWidth="lg"
fullWidth
>
<DialogTitle>
{t('documentDetails.dialogs.processedImage.title')}
</DialogTitle>
<DialogContent>
{processedImageUrl ? (
<Box sx={{ textAlign: 'center', py: 2 }}>
<img
src={processedImageUrl}
alt="Processed image that was fed to OCR"
style={{
maxWidth: '100%',
maxHeight: '70vh',
objectFit: 'contain',
border: '1px solid #ddd',
borderRadius: '4px'
}}
/>
<Typography variant="body2" sx={{ mt: 2, color: 'text.secondary' }}>
{t('documentDetails.dialogs.processedImage.description')}
</Typography>
</Box>
) : (
<Box sx={{ textAlign: 'center', py: 4 }}>
<Typography>{t('documentDetails.dialogs.processedImage.noImage')}</Typography>
</Box>
)}
</DialogContent>
<DialogActions>
<Button onClick={() => setShowProcessedImageDialog(false)}>
{t('common.actions.close')}
</Button>
</DialogActions>
</Dialog>
{/* Label Edit Dialog */}
<Dialog
open={showLabelDialog}
onClose={() => setShowLabelDialog(false)}
maxWidth="md"
fullWidth
>
<DialogTitle>
{t('documentDetails.dialogs.editLabels.title')}
</DialogTitle>
<DialogContent>
<Box sx={{ mt: 2 }}>
<Typography variant="body2" color="text.secondary" sx={{ mb: 2 }}>
{t('documentDetails.dialogs.editLabels.description')}
</Typography>
<LabelSelector
selectedLabels={documentLabels}
availableLabels={availableLabels}
onLabelsChange={setDocumentLabels}
onCreateLabel={handleCreateLabel}
placeholder={t('documentDetails.dialogs.editLabels.placeholder')}
size="medium"
disabled={labelsLoading}
/>
</Box>
</DialogContent>
<DialogActions>
<Button onClick={() => setShowLabelDialog(false)}>
{t('common.actions.cancel')}
</Button>
<Button
variant="contained"
onClick={() => handleSaveLabels(documentLabels)}
sx={{ borderRadius: 2 }}
>
{t('documentDetails.dialogs.editLabels.saveLabels')}
</Button>
</DialogActions>
</Dialog>
{/* Retry History Modal */}
{document && (
<RetryHistoryModal
open={retryHistoryModalOpen}
onClose={() => setRetryHistoryModalOpen(false)}
documentId={document.id}
documentName={document.original_filename}
/>
)}
{/* Delete Confirmation Dialog */}
<Dialog
open={deleteConfirmOpen}
onClose={() => setDeleteConfirmOpen(false)}
maxWidth="sm"
fullWidth
>
<DialogTitle>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<DeleteIcon color="error" />
<Typography variant="h6" sx={{ fontWeight: 600 }}>
{t('documentDetails.dialogs.delete.title')}
</Typography>
</Box>
</DialogTitle>
<DialogContent>
<Alert severity="warning" sx={{ mb: 2 }}>
{t('documentDetails.dialogs.delete.warning')}
</Alert>
<Typography variant="body1" dangerouslySetInnerHTML={{ __html: t('documentDetails.dialogs.delete.message', { filename: document?.original_filename }) }} />
<Typography variant="body2" color="text.secondary" sx={{ mt: 1 }}>
{t('documentDetails.dialogs.delete.details')}
</Typography>
</DialogContent>
<DialogActions>
<Button
onClick={() => setDeleteConfirmOpen(false)}
disabled={deleting}
>
{t('common.actions.cancel')}
</Button>
<Button
variant="contained"
color="error"
onClick={handleDeleteDocument}
disabled={deleting}
startIcon={deleting ? <CircularProgress size={16} /> : <DeleteIcon />}
sx={{ borderRadius: 2 }}
>
{deleting ? t('documentDetails.dialogs.delete.deleting') : t('documentDetails.dialogs.delete.delete')}
</Button>
</DialogActions>
</Dialog>
</Box>
);
};
export default DocumentDetailsPage;