import React, { useState, useEffect } from 'react'; import { useNavigate } from 'react-router-dom'; 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 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 ); setDocuments(response.data.documents || []); setPagination(response.data.pagination || { total: 0, limit: 20, offset: 0, has_more: false }); } catch (err) { setError('Failed to load documents'); 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 ? `OCR ${Math.round(doc.ocr_confidence)}%` : 'OCR Done' }, 'processing': { color: 'warning' as const, label: 'Processing...' }, 'failed': { color: 'error' as const, label: 'OCR Failed' }, 'pending': { color: 'default' as const, label: '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 */} Documents Manage and explore your document library {/* Toolbar */} {/* Search */} setSearchQuery(e.target.value)} InputProps={{ startAdornment: ( ), }} sx={{ minWidth: 300, flexGrow: 1 }} /> {/* View Toggle */} {/* Selection Mode Toggle */} {/* OCR Filter */} OCR Status {/* Sort Button */} {/* Selection Toolbar */} {selectionMode && ( {selectedDocuments.size > 999 ? `${Math.floor(selectedDocuments.size/1000)}K` : selectedDocuments.size} of {sortedDocuments.length > 999 ? `${Math.floor(sortedDocuments.length/1000)}K` : sortedDocuments.length} documents selected )} {/* Sort Menu */} handleSortChange('created_at', 'desc')}> Newest First handleSortChange('created_at', 'asc')}> Oldest First handleSortChange('original_filename', 'asc')}> Name A-Z handleSortChange('original_filename', 'desc')}> Name Z-A handleSortChange('file_size', 'desc')}> Largest First handleSortChange('file_size', 'asc')}> Smallest First {/* Document Menu */} { if (selectedDoc) handleDownload(selectedDoc); handleDocMenuClose(); }}> Download { if (selectedDoc) navigate(`/documents/${selectedDoc.id}`); handleDocMenuClose(); }}> View Details { if (selectedDoc) handleEditDocumentLabels(selectedDoc); handleDocMenuClose(); }}> Edit Labels { if (selectedDoc) handleRetryOcr(selectedDoc); }} disabled={retryingDocument === selectedDoc?.id}> {retryingDocument === selectedDoc?.id ? ( ) : ( )} {retryingDocument === selectedDoc?.id ? 'Retrying OCR...' : 'Retry OCR'} { if (selectedDoc) handleShowRetryHistory(selectedDoc.id); }}> Retry History { if (selectedDoc) handleDeleteClick(selectedDoc); }}> Delete {/* Documents Grid/List */} {sortedDocuments.length === 0 ? ( No documents found {searchQuery ? 'Try adjusting your search terms' : 'Upload your first document to get started'} ) : ( {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> Edit Document Labels {/* Delete Confirmation Dialog */} Delete Document Are you sure you want to delete "{documentToDelete?.original_filename}"? This action cannot be undone. The document file and all associated data will be permanently removed. {/* Bulk Delete Confirmation Dialog */} Delete Multiple Documents Are you sure you want to delete {selectedDocuments.size} selected document{selectedDocuments.size !== 1 ? 's' : ''}? This action cannot be undone. All selected documents and their associated data will be permanently removed. {selectedDocuments.size > 0 && ( Documents to be deleted: {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 && ( ... and {selectedDocuments.size - 10} more )} )} {/* Results count and pagination */} Showing {pagination.offset + 1}-{Math.min(pagination.offset + pagination.limit, pagination.total)} of {pagination.total} documents {ocrFilter && ` with OCR status: ${ocrFilter}`} {searchQuery && ` matching "${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;