Readur/frontend/src/pages/DocumentsPage.tsx

1081 lines
37 KiB
TypeScript

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<Document[]>([]);
const [pagination, setPagination] = useState<PaginationInfo>({ total: 0, limit: 20, offset: 0, has_more: false });
const [loading, setLoading] = useState<boolean>(true);
const [error, setError] = useState<string | null>(null);
const [viewMode, setViewMode] = useState<ViewMode>('grid');
const [searchQuery, setSearchQuery] = useState<string>('');
const [sortBy, setSortBy] = useState<SortField>('created_at');
const [sortOrder, setSortOrder] = useState<SortOrder>('desc');
const [ocrFilter, setOcrFilter] = useState<string>('');
// Labels state
const [availableLabels, setAvailableLabels] = useState<LabelData[]>([]);
const [labelsLoading, setLabelsLoading] = useState<boolean>(false);
const [labelEditDialogOpen, setLabelEditDialogOpen] = useState<boolean>(false);
const [editingDocumentId, setEditingDocumentId] = useState<string | null>(null);
const [editingDocumentLabels, setEditingDocumentLabels] = useState<LabelData[]>([]);
// Menu states
const [sortMenuAnchor, setSortMenuAnchor] = useState<null | HTMLElement>(null);
const [docMenuAnchor, setDocMenuAnchor] = useState<null | HTMLElement>(null);
const [selectedDoc, setSelectedDoc] = useState<Document | null>(null);
// Delete confirmation dialog state
const [deleteDialogOpen, setDeleteDialogOpen] = useState<boolean>(false);
const [documentToDelete, setDocumentToDelete] = useState<Document | null>(null);
const [deleteLoading, setDeleteLoading] = useState<boolean>(false);
// Mass selection state
const [selectionMode, setSelectionMode] = useState<boolean>(false);
const [selectedDocuments, setSelectedDocuments] = useState<Set<string>>(new Set());
const [bulkDeleteDialogOpen, setBulkDeleteDialogOpen] = useState<boolean>(false);
const [bulkDeleteLoading, setBulkDeleteLoading] = useState<boolean>(false);
// Retry functionality state
const [retryingDocument, setRetryingDocument] = useState<string | null>(null);
const [retryHistoryModalOpen, setRetryHistoryModalOpen] = useState<boolean>(false);
const [selectedDocumentForHistory, setSelectedDocumentForHistory] = useState<string | null>(null);
useEffect(() => {
fetchDocuments();
fetchLabels();
}, [pagination?.limit, pagination?.offset, ocrFilter]);
const fetchDocuments = async (): Promise<void> => {
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<void> => {
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<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 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<void> => {
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 <PdfIcon color="error" />;
if (mimeType.includes('image')) return <ImageIcon color="primary" />;
if (mimeType.includes('text')) return <TextIcon color="info" />;
return <DocIcon color="secondary" />;
};
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<HTMLElement>, newView: ViewMode | null): void => {
if (newView) {
setViewMode(newView);
}
};
const handleSortMenuClick = (event: React.MouseEvent<HTMLElement>): void => {
setSortMenuAnchor(event.currentTarget);
};
const handleDocMenuClick = (event: React.MouseEvent<HTMLElement>, 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<void> => {
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<void> => {
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<unknown>, page: number): void => {
const newOffset = (page - 1) * pagination.limit;
setPagination(prev => ({ ...prev, offset: newOffset }));
};
const handleOcrFilterChange = (event: React.ChangeEvent<HTMLInputElement>): 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<void> => {
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 (
<Chip
label={config.label}
size="small"
color={config.color}
variant="outlined"
/>
);
};
if (loading) {
return (
<Box display="flex" justifyContent="center" alignItems="center" minHeight="400px">
<CircularProgress />
</Box>
);
}
if (error) {
return (
<Alert severity="error" sx={{ m: 2 }}>
{error}
</Alert>
);
}
return (
<Box sx={{ p: 3 }}>
{/* Header */}
<Box sx={{ mb: 4 }}>
<Typography
variant="h4"
sx={{
fontWeight: 800,
background: 'linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%)',
backgroundClip: 'text',
WebkitBackgroundClip: 'text',
color: 'transparent',
mb: 1,
}}
>
{t('documents.title')}
</Typography>
<Typography variant="body1" color="text.secondary">
{t('documents.subtitle')}
</Typography>
</Box>
{/* Toolbar */}
<Box sx={{
mb: 3,
display: 'flex',
gap: 2,
alignItems: 'center',
flexWrap: 'wrap',
}}>
{/* Search */}
<TextField
placeholder={t('documents.search.placeholder')}
variant="outlined"
size="small"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
InputProps={{
startAdornment: (
<InputAdornment position="start">
<SearchIcon color="action" />
</InputAdornment>
),
}}
sx={{ minWidth: 300, flexGrow: 1 }}
/>
{/* View Toggle */}
<ToggleButtonGroup
value={viewMode}
exclusive
onChange={handleViewModeChange}
size="small"
>
<ToggleButton value="grid">
<GridViewIcon />
</ToggleButton>
<ToggleButton value="list">
<ListViewIcon />
</ToggleButton>
</ToggleButtonGroup>
{/* Selection Mode Toggle */}
<Button
variant={selectionMode ? "contained" : "outlined"}
startIcon={selectionMode ? <CloseIcon /> : <CheckBoxOutlineBlankIcon />}
onClick={handleToggleSelectionMode}
size="small"
color={selectionMode ? "secondary" : "primary"}
>
{selectionMode ? t('documents.selection.cancel') : t('documents.selection.select')}
</Button>
{/* OCR Filter */}
<FormControl size="small" sx={{ minWidth: 120 }}>
<InputLabel>{t('documents.filters.ocrStatus')}</InputLabel>
<Select
value={ocrFilter}
label={t('documents.filters.ocrStatus')}
onChange={handleOcrFilterChange}
>
<MenuItem value="">{t('documents.filters.all')}</MenuItem>
<MenuItem value="completed">{t('documents.filters.completed')}</MenuItem>
<MenuItem value="processing">{t('documents.filters.processing')}</MenuItem>
<MenuItem value="failed">{t('documents.filters.failed')}</MenuItem>
<MenuItem value="pending">{t('documents.filters.pending')}</MenuItem>
</Select>
</FormControl>
{/* Sort Button */}
<Button
variant="outlined"
startIcon={<SortIcon />}
onClick={handleSortMenuClick}
size="small"
>
{t('documents.sort.label')}
</Button>
</Box>
{/* Selection Toolbar */}
{selectionMode && (
<Box sx={{
mb: 2,
p: 2,
bgcolor: 'primary.light',
borderRadius: 1,
display: 'flex',
alignItems: 'center',
gap: 2,
color: 'primary.contrastText'
}}>
<Typography variant="body2" sx={{ flexGrow: 1, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{t('documents.selection.count', {
count: selectedDocuments.size,
total: sortedDocuments.length
})}
</Typography>
<Button
variant="text"
startIcon={selectedDocuments.size === sortedDocuments.length ? <CheckBoxIcon /> : <CheckBoxOutlineBlankIcon />}
onClick={handleSelectAll}
size="small"
sx={{ color: 'primary.contrastText' }}
>
{selectedDocuments.size === sortedDocuments.length ? t('documents.selection.deselectAll') : t('documents.selection.selectAll')}
</Button>
<Button
variant="contained"
startIcon={<DeleteIcon />}
onClick={handleBulkDelete}
disabled={selectedDocuments.size === 0}
size="small"
color="error"
>
{t('documents.selection.deleteSelected', { count: selectedDocuments.size })}
</Button>
</Box>
)}
{/* Sort Menu */}
<Menu
anchorEl={sortMenuAnchor}
open={Boolean(sortMenuAnchor)}
onClose={handleSortMenuClose}
>
<MenuItem onClick={() => handleSortChange('created_at', 'desc')}>
<ListItemIcon><DateIcon fontSize="small" /></ListItemIcon>
<ListItemText>{t('documents.sort.newestFirst')}</ListItemText>
</MenuItem>
<MenuItem onClick={() => handleSortChange('created_at', 'asc')}>
<ListItemIcon><DateIcon fontSize="small" /></ListItemIcon>
<ListItemText>{t('documents.sort.oldestFirst')}</ListItemText>
</MenuItem>
<MenuItem onClick={() => handleSortChange('original_filename', 'asc')}>
<ListItemIcon><TextIcon fontSize="small" /></ListItemIcon>
<ListItemText>{t('documents.sort.nameAZ')}</ListItemText>
</MenuItem>
<MenuItem onClick={() => handleSortChange('original_filename', 'desc')}>
<ListItemIcon><TextIcon fontSize="small" /></ListItemIcon>
<ListItemText>{t('documents.sort.nameZA')}</ListItemText>
</MenuItem>
<MenuItem onClick={() => handleSortChange('file_size', 'desc')}>
<ListItemIcon><SizeIcon fontSize="small" /></ListItemIcon>
<ListItemText>{t('documents.sort.largestFirst')}</ListItemText>
</MenuItem>
<MenuItem onClick={() => handleSortChange('file_size', 'asc')}>
<ListItemIcon><SizeIcon fontSize="small" /></ListItemIcon>
<ListItemText>{t('documents.sort.smallestFirst')}</ListItemText>
</MenuItem>
</Menu>
{/* Document Menu */}
<Menu
anchorEl={docMenuAnchor}
open={Boolean(docMenuAnchor)}
onClose={handleDocMenuClose}
>
<MenuItem onClick={() => {
if (selectedDoc) handleDownload(selectedDoc);
handleDocMenuClose();
}}>
<ListItemIcon><DownloadIcon fontSize="small" /></ListItemIcon>
<ListItemText>{t('common.actions.download')}</ListItemText>
</MenuItem>
<MenuItem onClick={() => {
if (selectedDoc) navigate(`/documents/${selectedDoc.id}`);
handleDocMenuClose();
}}>
<ListItemIcon><ViewIcon fontSize="small" /></ListItemIcon>
<ListItemText>{t('common.actions.viewDetails')}</ListItemText>
</MenuItem>
<MenuItem onClick={() => {
if (selectedDoc) handleEditDocumentLabels(selectedDoc);
handleDocMenuClose();
}}>
<ListItemIcon><EditIcon fontSize="small" /></ListItemIcon>
<ListItemText>{t('documents.actions.editLabels')}</ListItemText>
</MenuItem>
<Divider />
<MenuItem onClick={() => {
if (selectedDoc) handleRetryOcr(selectedDoc);
}} disabled={retryingDocument === selectedDoc?.id}>
<ListItemIcon>
{retryingDocument === selectedDoc?.id ? (
<CircularProgress size={16} />
) : (
<RefreshIcon fontSize="small" />
)}
</ListItemIcon>
<ListItemText>
{retryingDocument === selectedDoc?.id ? t('documents.actions.retryingOcr') : t('documents.actions.retryOcr')}
</ListItemText>
</MenuItem>
<MenuItem onClick={() => {
if (selectedDoc) handleShowRetryHistory(selectedDoc.id);
}}>
<ListItemIcon><HistoryIcon fontSize="small" /></ListItemIcon>
<ListItemText>{t('documents.actions.retryHistory')}</ListItemText>
</MenuItem>
<Divider />
<MenuItem onClick={() => {
if (selectedDoc) handleDeleteClick(selectedDoc);
}}>
<ListItemIcon><DeleteIcon fontSize="small" color="error" /></ListItemIcon>
<ListItemText>{t('common.actions.delete')}</ListItemText>
</MenuItem>
</Menu>
{/* Documents Grid/List */}
{sortedDocuments.length === 0 ? (
<Box
sx={{
textAlign: 'center',
py: 8,
background: 'linear-gradient(135deg, rgba(99, 102, 241, 0.05) 0%, rgba(139, 92, 246, 0.05) 100%)',
borderRadius: 2,
border: '1px dashed',
borderColor: 'primary.main',
}}
>
<Typography variant="h6" color="text.secondary" gutterBottom>
{t('documents.empty.title')}
</Typography>
<Typography variant="body2" color="text.secondary">
{searchQuery ? t('documents.empty.searchSubtitle') : t('documents.empty.uploadSubtitle')}
</Typography>
</Box>
) : (
<Grid container spacing={viewMode === 'grid' ? 3 : 1}>
{sortedDocuments.map((doc) => (
<Grid
item
xs={12}
sm={viewMode === 'grid' ? 6 : 12}
md={viewMode === 'grid' ? 4 : 12}
lg={viewMode === 'grid' ? 3 : 12}
key={doc.id}
>
<Card
sx={{
height: '100%',
display: 'flex',
flexDirection: viewMode === 'list' ? 'row' : 'column',
transition: 'all 0.2s ease-in-out',
cursor: 'pointer',
position: 'relative',
'&:hover': {
transform: 'translateY(-4px)',
boxShadow: (theme) => 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 && (
<Box
sx={{
position: 'absolute',
top: 8,
right: 8,
zIndex: 1,
bgcolor: 'background.paper',
borderRadius: '50%',
boxShadow: 1,
}}
onClick={(e) => {
e.stopPropagation();
handleDocumentSelect(doc.id, !selectedDocuments.has(doc.id));
}}
>
<Checkbox
checked={selectedDocuments.has(doc.id)}
size="small"
color="primary"
/>
</Box>
)}
{viewMode === 'grid' && (
<Box
sx={{
height: 120,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
background: 'linear-gradient(135deg, rgba(99, 102, 241, 0.1) 0%, rgba(139, 92, 246, 0.1) 100%)',
}}
>
<DocumentThumbnail
documentId={doc.id}
mimeType={doc.mime_type}
size="large"
/>
</Box>
)}
<CardContent sx={{ flexGrow: 1, pb: 1 }}>
<Box sx={{ display: 'flex', alignItems: 'flex-start', gap: 1 }}>
{viewMode === 'list' && (
<Box sx={{ mr: 1, mt: 0.5 }}>
<DocumentThumbnail
documentId={doc.id}
mimeType={doc.mime_type}
size="small"
/>
</Box>
)}
<Box sx={{ flexGrow: 1, minWidth: 0 }}>
<Typography
variant="h6"
sx={{
fontSize: '1rem',
fontWeight: 600,
mb: 1,
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}}
title={doc.original_filename}
>
{doc.original_filename}
</Typography>
<Stack direction="row" spacing={1} sx={{ mb: 1, flexWrap: 'wrap', gap: 0.5 }}>
<Chip
label={formatFileSize(doc.file_size)}
size="small"
variant="outlined"
/>
{getOcrStatusChip(doc)}
</Stack>
{doc.tags.length > 0 && (
<Stack direction="row" spacing={0.5} sx={{ mb: 1, flexWrap: 'wrap' }}>
{doc.tags.slice(0, 3).map((tag, index) => (
<Chip
key={index}
label={tag}
size="small"
color="primary"
variant="outlined"
sx={{
fontSize: '0.7rem',
height: '20px',
maxWidth: '120px',
'& .MuiChip-label': {
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap'
}
}}
/>
))}
{doc.tags.length > 3 && (
<Chip
label={`+${doc.tags.length - 3}`}
size="small"
variant="outlined"
sx={{ fontSize: '0.7rem', height: '20px' }}
/>
)}
</Stack>
)}
{doc.labels && doc.labels.length > 0 && (
<Stack direction="row" spacing={0.5} sx={{ mb: 1, flexWrap: 'wrap' }}>
{doc.labels.slice(0, 3).map((label) => (
<Label
key={label.id}
label={label}
size="small"
variant="filled"
/>
))}
{doc.labels.length > 3 && (
<Chip
label={`+${doc.labels.length - 3}`}
size="small"
variant="outlined"
sx={{ fontSize: '0.7rem', height: '20px' }}
/>
)}
</Stack>
)}
<Typography variant="caption" color="text.secondary">
{formatDate(doc.created_at)}
</Typography>
</Box>
{!selectionMode && (
<IconButton
size="small"
onClick={(e) => {
e.stopPropagation();
handleDocMenuClick(e, doc);
}}
>
<MoreIcon />
</IconButton>
)}
</Box>
</CardContent>
{viewMode === 'grid' && (
<CardActions sx={{ pt: 0 }}>
<Button
size="small"
startIcon={<DownloadIcon />}
onClick={(e) => {
e.stopPropagation();
handleDownload(doc);
}}
fullWidth
>
{t('common.actions.download')}
</Button>
</CardActions>
)}
</Card>
</Grid>
))}
</Grid>
)}
{/* Label Edit Dialog */}
<Dialog open={labelEditDialogOpen} onClose={() => setLabelEditDialogOpen(false)} maxWidth="sm" fullWidth>
<DialogTitle>{t('documents.dialogs.editLabels.title')}</DialogTitle>
<DialogContent>
<Box sx={{ pt: 2 }}>
<LabelSelector
selectedLabels={editingDocumentLabels}
availableLabels={availableLabels}
onLabelsChange={setEditingDocumentLabels}
onCreateLabel={handleCreateLabel}
placeholder={t('documents.dialogs.editLabels.placeholder')}
size="medium"
disabled={labelsLoading}
/>
</Box>
</DialogContent>
<DialogActions>
<Button onClick={() => setLabelEditDialogOpen(false)}>{t('common.actions.cancel')}</Button>
<Button onClick={handleSaveDocumentLabels} variant="contained">{t('common.actions.save')}</Button>
</DialogActions>
</Dialog>
{/* Delete Confirmation Dialog */}
<Dialog open={deleteDialogOpen} onClose={handleDeleteCancel} maxWidth="sm">
<DialogTitle>{t('documents.dialogs.delete.title')}</DialogTitle>
<DialogContent>
<Typography>
{t('documents.dialogs.delete.message', { filename: documentToDelete?.original_filename })}
</Typography>
<Typography variant="body2" color="text.secondary" sx={{ mt: 1 }}>
{t('documents.dialogs.delete.warning')}
</Typography>
</DialogContent>
<DialogActions>
<Button onClick={handleDeleteCancel} disabled={deleteLoading}>
{t('common.actions.cancel')}
</Button>
<Button
onClick={handleDeleteConfirm}
color="error"
variant="contained"
disabled={deleteLoading}
startIcon={deleteLoading ? <CircularProgress size={16} color="inherit" /> : <DeleteIcon />}
>
{deleteLoading ? t('documents.dialogs.delete.deleting') : t('documents.dialogs.delete.delete')}
</Button>
</DialogActions>
</Dialog>
{/* Bulk Delete Confirmation Dialog */}
<Dialog open={bulkDeleteDialogOpen} onClose={handleBulkDeleteCancel} maxWidth="sm">
<DialogTitle>{t('documents.dialogs.bulkDelete.title')}</DialogTitle>
<DialogContent>
<Typography gutterBottom>
{t('documents.dialogs.bulkDelete.message', {
count: selectedDocuments.size,
plural: selectedDocuments.size !== 1 ? 's' : ''
})}
</Typography>
<Typography variant="body2" color="text.secondary" sx={{ mt: 1 }}>
{t('documents.dialogs.bulkDelete.warning')}
</Typography>
{selectedDocuments.size > 0 && (
<Box sx={{ mt: 2, maxHeight: 200, overflow: 'auto' }}>
<Typography variant="subtitle2" gutterBottom>
{t('documents.dialogs.bulkDelete.listTitle')}
</Typography>
{Array.from(selectedDocuments).slice(0, 10).map(docId => {
const doc = documents.find(d => d.id === docId);
return doc ? (
<Typography key={docId} variant="body2" sx={{ pl: 1 }}>
{doc.original_filename}
</Typography>
) : null;
})}
{selectedDocuments.size > 10 && (
<Typography variant="body2" sx={{ pl: 1, fontStyle: 'italic' }}>
{t('documents.dialogs.bulkDelete.moreCount', { count: selectedDocuments.size - 10 })}
</Typography>
)}
</Box>
)}
</DialogContent>
<DialogActions>
<Button onClick={handleBulkDeleteCancel} disabled={bulkDeleteLoading}>
{t('common.actions.cancel')}
</Button>
<Button
onClick={handleBulkDeleteConfirm}
color="error"
variant="contained"
disabled={bulkDeleteLoading}
startIcon={bulkDeleteLoading ? <CircularProgress size={16} color="inherit" /> : <DeleteIcon />}
>
{bulkDeleteLoading ? t('documents.dialogs.delete.deleting') : t('documents.dialogs.bulkDelete.deleteButton', {
count: selectedDocuments.size,
plural: selectedDocuments.size !== 1 ? 's' : ''
})}
</Button>
</DialogActions>
</Dialog>
{/* Results count and pagination */}
<Box sx={{ mt: 3 }}>
<Box sx={{ textAlign: 'center', mb: 2 }}>
<Typography variant="body2" color="text.secondary">
{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 })}
</Typography>
</Box>
{pagination.total > pagination.limit && (
<Box sx={{ display: 'flex', justifyContent: 'center' }}>
<Pagination
count={Math.ceil(pagination.total / pagination.limit)}
page={Math.floor(pagination.offset / pagination.limit) + 1}
onChange={handlePageChange}
color="primary"
size="large"
showFirstButton
showLastButton
/>
</Box>
)}
</Box>
{/* Retry History Modal */}
<RetryHistoryModal
open={retryHistoryModalOpen}
onClose={() => setRetryHistoryModalOpen(false)}
documentId={selectedDocumentForHistory || ''}
documentName={selectedDocumentForHistory ?
documents.find(d => d.id === selectedDocumentForHistory)?.original_filename : undefined}
/>
</Box>
);
};
export default DocumentsPage;