import React, { useState, useEffect } from 'react'; import { useNavigate } from 'react-router-dom'; import { useTranslation } from 'react-i18next'; import { Box, Typography, Card, CardContent, CardActions, Button, Chip, IconButton, ToggleButton, ToggleButtonGroup, TextField, InputAdornment, Stack, Menu, MenuItem, ListItemIcon, ListItemText, Divider, CircularProgress, Alert, Pagination, FormControl, InputLabel, Select, Dialog, DialogTitle, DialogContent, DialogActions, Checkbox, Fab, Tooltip, } from '@mui/material'; import Grid from '@mui/material/GridLegacy'; import { GridView as GridViewIcon, ViewList as ListViewIcon, Search as SearchIcon, FilterList as FilterIcon, Sort as SortIcon, Download as DownloadIcon, PictureAsPdf as PdfIcon, Image as ImageIcon, Description as DocIcon, TextSnippet as TextIcon, MoreVert as MoreIcon, CalendarToday as DateIcon, Storage as SizeIcon, Visibility as ViewIcon, ChevronLeft as ChevronLeftIcon, ChevronRight as ChevronRightIcon, Edit as EditIcon, Delete as DeleteIcon, CheckBoxOutlineBlank as CheckBoxOutlineBlankIcon, CheckBox as CheckBoxIcon, SelectAll as SelectAllIcon, Close as CloseIcon, Refresh as RefreshIcon, History as HistoryIcon, } from '@mui/icons-material'; import { documentService } from '../services/api'; import DocumentThumbnail from '../components/DocumentThumbnail'; import Label, { type LabelData } from '../components/Labels/Label'; import LabelSelector from '../components/Labels/LabelSelector'; import { useApi } from '../hooks/useApi'; import { RetryHistoryModal } from '../components/RetryHistoryModal'; interface Document { id: string; original_filename: string; filename?: string; file_size: number; mime_type: string; created_at: string; has_ocr_text?: boolean; ocr_status?: string; ocr_confidence?: number; tags: string[]; labels?: LabelData[]; } interface PaginationInfo { total: number; limit: number; offset: number; has_more: boolean; } interface DocumentsResponse { documents: Document[]; pagination: PaginationInfo; } type ViewMode = 'grid' | 'list'; type SortField = 'created_at' | 'original_filename' | 'file_size'; type SortOrder = 'asc' | 'desc'; const DocumentsPage: React.FC = () => { const { t } = useTranslation(); const navigate = useNavigate(); const api = useApi(); const [documents, setDocuments] = useState([]); const [pagination, setPagination] = useState({ total: 0, limit: 20, offset: 0, has_more: false }); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); const [viewMode, setViewMode] = useState('grid'); const [searchQuery, setSearchQuery] = useState(''); const [sortBy, setSortBy] = useState('created_at'); const [sortOrder, setSortOrder] = useState('desc'); const [ocrFilter, setOcrFilter] = useState(''); // Labels state const [availableLabels, setAvailableLabels] = useState([]); const [labelsLoading, setLabelsLoading] = useState(false); const [labelEditDialogOpen, setLabelEditDialogOpen] = useState(false); const [editingDocumentId, setEditingDocumentId] = useState(null); const [editingDocumentLabels, setEditingDocumentLabels] = useState([]); // Menu states const [sortMenuAnchor, setSortMenuAnchor] = useState(null); const [docMenuAnchor, setDocMenuAnchor] = useState(null); const [selectedDoc, setSelectedDoc] = useState(null); // Delete confirmation dialog state const [deleteDialogOpen, setDeleteDialogOpen] = useState(false); const [documentToDelete, setDocumentToDelete] = useState(null); const [deleteLoading, setDeleteLoading] = useState(false); // Mass selection state const [selectionMode, setSelectionMode] = useState(false); const [selectedDocuments, setSelectedDocuments] = useState>(new Set()); const [bulkDeleteDialogOpen, setBulkDeleteDialogOpen] = useState(false); const [bulkDeleteLoading, setBulkDeleteLoading] = useState(false); // Retry functionality state const [retryingDocument, setRetryingDocument] = useState(null); const [retryHistoryModalOpen, setRetryHistoryModalOpen] = useState(false); const [selectedDocumentForHistory, setSelectedDocumentForHistory] = useState(null); useEffect(() => { fetchDocuments(); fetchLabels(); }, [pagination?.limit, pagination?.offset, ocrFilter]); const fetchDocuments = async (): Promise => { if (!pagination) return; try { setLoading(true); const response = await documentService.listWithPagination( pagination.limit, pagination.offset, ocrFilter || undefined ); // Backend returns wrapped object with documents and pagination setDocuments(response.data.documents || []); setPagination(response.data.pagination || { total: 0, limit: 20, offset: 0, has_more: false }); } catch (err) { setError(t('common.status.error')); console.error(err); } finally { setLoading(false); } }; const fetchLabels = async (): Promise => { try { setLabelsLoading(true); const response = await api.get('/labels?include_counts=false'); if (response.status === 200 && Array.isArray(response.data)) { setAvailableLabels(response.data); } else { console.error('Failed to fetch labels:', response); } } catch (error) { console.error('Failed to fetch labels:', error); } finally { setLabelsLoading(false); } }; const handleCreateLabel = async (labelData: Omit) => { 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 handleEditDocumentLabels = (doc: Document) => { setEditingDocumentId(doc.id); setEditingDocumentLabels(doc.labels || []); setLabelEditDialogOpen(true); }; const handleSaveDocumentLabels = async () => { if (!editingDocumentId) return; try { const labelIds = editingDocumentLabels.map(label => label.id); await api.put(`/labels/documents/${editingDocumentId}`, { label_ids: labelIds }); // Update the document in the local state setDocuments(prev => prev.map(doc => doc.id === editingDocumentId ? { ...doc, labels: editingDocumentLabels } : doc )); setLabelEditDialogOpen(false); setEditingDocumentId(null); setEditingDocumentLabels([]); } catch (error) { console.error('Failed to update document labels:', error); } }; const handleDownload = async (doc: Document): Promise => { try { const response = await documentService.download(doc.id); const url = window.URL.createObjectURL(new Blob([response.data])); const link = document.createElement('a'); link.href = url; link.setAttribute('download', doc.original_filename); document.body.appendChild(link); link.click(); link.remove(); window.URL.revokeObjectURL(url); } catch (err) { console.error('Download failed:', err); } }; const getFileIcon = (mimeType: string): React.ReactElement => { if (mimeType.includes('pdf')) return ; if (mimeType.includes('image')) return ; if (mimeType.includes('text')) return ; return ; }; 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).toLocaleDateString('en-US', { year: 'numeric', month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit', }); }; const filteredDocuments = (documents || []).filter(doc => doc.original_filename.toLowerCase().includes(searchQuery.toLowerCase()) || doc.tags.some(tag => tag.toLowerCase().includes(searchQuery.toLowerCase())) ); const sortedDocuments = [...filteredDocuments].sort((a, b) => { let aValue: any = a[sortBy]; let bValue: any = b[sortBy]; if (sortBy === 'created_at') { aValue = new Date(aValue); bValue = new Date(bValue); } if (sortOrder === 'asc') { return aValue > bValue ? 1 : -1; } else { return aValue < bValue ? 1 : -1; } }); const handleViewModeChange = (event: React.MouseEvent, newView: ViewMode | null): void => { if (newView) { setViewMode(newView); } }; const handleSortMenuClick = (event: React.MouseEvent): void => { setSortMenuAnchor(event.currentTarget); }; const handleDocMenuClick = (event: React.MouseEvent, doc: Document): void => { setSelectedDoc(doc); setDocMenuAnchor(event.currentTarget); }; const handleSortMenuClose = (): void => { setSortMenuAnchor(null); }; const handleDocMenuClose = (): void => { setDocMenuAnchor(null); }; const handleSortChange = (field: SortField, order: SortOrder): void => { setSortBy(field); setSortOrder(order); handleSortMenuClose(); }; const handleDeleteClick = (doc: Document): void => { setDocumentToDelete(doc); setDeleteDialogOpen(true); handleDocMenuClose(); }; const handleDeleteConfirm = async (): Promise => { if (!documentToDelete) return; try { setDeleteLoading(true); await documentService.delete(documentToDelete.id); setDocuments(prev => prev.filter(doc => doc.id !== documentToDelete.id)); setPagination(prev => ({ ...prev, total: prev.total - 1 })); setDeleteDialogOpen(false); setDocumentToDelete(null); } catch (error) { console.error('Failed to delete document:', error); setError('Failed to delete document'); } finally { setDeleteLoading(false); } }; const handleDeleteCancel = (): void => { setDeleteDialogOpen(false); setDocumentToDelete(null); }; // Retry functionality handlers const handleRetryOcr = async (doc: Document): Promise => { try { setRetryingDocument(doc.id); await documentService.bulkRetryOcr({ mode: 'specific', document_ids: [doc.id], priority_override: 15, }); // Refresh the document list to get updated status await fetchDocuments(); setError(null); } catch (error) { console.error('Failed to retry OCR:', error); setError('Failed to retry OCR processing'); } finally { setRetryingDocument(null); handleDocMenuClose(); } }; const handleShowRetryHistory = (docId: string): void => { setSelectedDocumentForHistory(docId); setRetryHistoryModalOpen(true); handleDocMenuClose(); }; const handlePageChange = (event: React.ChangeEvent, page: number): void => { const newOffset = (page - 1) * pagination.limit; setPagination(prev => ({ ...prev, offset: newOffset })); }; const handleOcrFilterChange = (event: React.ChangeEvent): void => { setOcrFilter(event.target.value); setPagination(prev => ({ ...prev, offset: 0 })); // Reset to first page when filtering }; // Mass selection handlers const handleToggleSelectionMode = (): void => { setSelectionMode(!selectionMode); setSelectedDocuments(new Set()); }; const handleDocumentSelect = (documentId: string, isSelected: boolean): void => { const newSelection = new Set(selectedDocuments); if (isSelected) { newSelection.add(documentId); } else { newSelection.delete(documentId); } setSelectedDocuments(newSelection); }; const handleSelectAll = (): void => { if (selectedDocuments.size === sortedDocuments.length) { setSelectedDocuments(new Set()); } else { setSelectedDocuments(new Set(sortedDocuments.map(doc => doc.id))); } }; const handleBulkDelete = (): void => { if (selectedDocuments.size === 0) return; setBulkDeleteDialogOpen(true); }; const handleBulkDeleteConfirm = async (): Promise => { if (selectedDocuments.size === 0) return; try { setBulkDeleteLoading(true); const documentIds = Array.from(selectedDocuments); await documentService.bulkDelete(documentIds); setDocuments(prev => prev.filter(doc => !selectedDocuments.has(doc.id))); setPagination(prev => ({ ...prev, total: prev.total - selectedDocuments.size })); setSelectedDocuments(new Set()); setSelectionMode(false); setBulkDeleteDialogOpen(false); } catch (error) { console.error('Failed to delete documents:', error); setError('Failed to delete selected documents'); } finally { setBulkDeleteLoading(false); } }; const handleBulkDeleteCancel = (): void => { setBulkDeleteDialogOpen(false); }; const getOcrStatusChip = (doc: Document) => { if (!doc.ocr_status) return null; const statusConfig = { 'completed': { color: 'success' as const, label: doc.ocr_confidence ? t('documents.ocrStatus.confidence', { percent: Math.round(doc.ocr_confidence) }) : t('documents.ocrStatus.done') }, 'processing': { color: 'warning' as const, label: t('documents.ocrStatus.processing') }, 'failed': { color: 'error' as const, label: t('documents.ocrStatus.failed') }, 'pending': { color: 'default' as const, label: t('documents.ocrStatus.pending') }, }; const config = statusConfig[doc.ocr_status as keyof typeof statusConfig]; if (!config) return null; return ( ); }; if (loading) { return ( ); } if (error) { return ( {error} ); } return ( {/* Header */} {t('documents.title')} {t('documents.subtitle')} {/* Toolbar */} {/* Search */} setSearchQuery(e.target.value)} InputProps={{ startAdornment: ( ), }} sx={{ minWidth: 300, flexGrow: 1 }} /> {/* View Toggle */} {/* Selection Mode Toggle */} {/* OCR Filter */} {t('documents.filters.ocrStatus')} {/* Sort Button */} {/* Selection Toolbar */} {selectionMode && ( {t('documents.selection.count', { count: selectedDocuments.size, total: sortedDocuments.length })} )} {/* Sort Menu */} handleSortChange('created_at', 'desc')}> {t('documents.sort.newestFirst')} handleSortChange('created_at', 'asc')}> {t('documents.sort.oldestFirst')} handleSortChange('original_filename', 'asc')}> {t('documents.sort.nameAZ')} handleSortChange('original_filename', 'desc')}> {t('documents.sort.nameZA')} handleSortChange('file_size', 'desc')}> {t('documents.sort.largestFirst')} handleSortChange('file_size', 'asc')}> {t('documents.sort.smallestFirst')} {/* Document Menu */} { if (selectedDoc) handleDownload(selectedDoc); handleDocMenuClose(); }}> {t('common.actions.download')} { if (selectedDoc) navigate(`/documents/${selectedDoc.id}`); handleDocMenuClose(); }}> {t('common.actions.viewDetails')} { if (selectedDoc) handleEditDocumentLabels(selectedDoc); handleDocMenuClose(); }}> {t('documents.actions.editLabels')} { if (selectedDoc) handleRetryOcr(selectedDoc); }} disabled={retryingDocument === selectedDoc?.id}> {retryingDocument === selectedDoc?.id ? ( ) : ( )} {retryingDocument === selectedDoc?.id ? t('documents.actions.retryingOcr') : t('documents.actions.retryOcr')} { if (selectedDoc) handleShowRetryHistory(selectedDoc.id); }}> {t('documents.actions.retryHistory')} { if (selectedDoc) handleDeleteClick(selectedDoc); }}> {t('common.actions.delete')} {/* Documents Grid/List */} {sortedDocuments.length === 0 ? ( {t('documents.empty.title')} {searchQuery ? t('documents.empty.searchSubtitle') : t('documents.empty.uploadSubtitle')} ) : ( {sortedDocuments.map((doc) => ( theme.shadows[4], }, ...(selectionMode && selectedDocuments.has(doc.id) && { boxShadow: (theme) => `0 0 0 2px ${theme.palette.primary.main}`, bgcolor: 'primary.light', }), }} onClick={(e) => { if (selectionMode) { e.stopPropagation(); handleDocumentSelect(doc.id, !selectedDocuments.has(doc.id)); } else { navigate(`/documents/${doc.id}`); } }} > {/* Selection checkbox */} {selectionMode && ( { e.stopPropagation(); handleDocumentSelect(doc.id, !selectedDocuments.has(doc.id)); }} > )} {viewMode === 'grid' && ( )} {viewMode === 'list' && ( )} {doc.original_filename} {getOcrStatusChip(doc)} {doc.tags.length > 0 && ( {doc.tags.slice(0, 3).map((tag, index) => ( ))} {doc.tags.length > 3 && ( )} )} {doc.labels && doc.labels.length > 0 && ( {doc.labels.slice(0, 3).map((label) => ( )} {formatDate(doc.created_at)} {!selectionMode && ( { e.stopPropagation(); handleDocMenuClick(e, doc); }} > )} {viewMode === 'grid' && ( )} ))} )} {/* Label Edit Dialog */} setLabelEditDialogOpen(false)} maxWidth="sm" fullWidth> {t('documents.dialogs.editLabels.title')} {/* Delete Confirmation Dialog */} {t('documents.dialogs.delete.title')} {t('documents.dialogs.delete.message', { filename: documentToDelete?.original_filename })} {t('documents.dialogs.delete.warning')} {/* Bulk Delete Confirmation Dialog */} {t('documents.dialogs.bulkDelete.title')} {t('documents.dialogs.bulkDelete.message', { count: selectedDocuments.size, plural: selectedDocuments.size !== 1 ? 's' : '' })} {t('documents.dialogs.bulkDelete.warning')} {selectedDocuments.size > 0 && ( {t('documents.dialogs.bulkDelete.listTitle')} {Array.from(selectedDocuments).slice(0, 10).map(docId => { const doc = documents.find(d => d.id === docId); return doc ? ( • {doc.original_filename} ) : null; })} {selectedDocuments.size > 10 && ( {t('documents.dialogs.bulkDelete.moreCount', { count: selectedDocuments.size - 10 })} )} )} {/* Results count and pagination */} {t('documents.pagination.showing', { start: pagination.offset + 1, end: Math.min(pagination.offset + pagination.limit, pagination.total), total: pagination.total })} {ocrFilter && t('documents.pagination.withOcrStatus', { status: ocrFilter })} {searchQuery && t('documents.pagination.matching', { query: searchQuery })} {pagination.total > pagination.limit && ( )} {/* Retry History Modal */} setRetryHistoryModalOpen(false)} documentId={selectedDocumentForHistory || ''} documentName={selectedDocumentForHistory ? documents.find(d => d.id === selectedDocumentForHistory)?.original_filename : undefined} /> ); }; export default DocumentsPage;