1081 lines
37 KiB
TypeScript
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; |