Readur/frontend/src/components/BulkRetryModal.tsx

467 lines
17 KiB
TypeScript

import React, { useState, useEffect } from 'react';
import {
Dialog,
DialogTitle,
DialogContent,
DialogActions,
Button,
FormControl,
FormLabel,
RadioGroup,
FormControlLabel,
Radio,
TextField,
Chip,
Box,
Typography,
Alert,
LinearProgress,
Accordion,
AccordionSummary,
AccordionDetails,
Checkbox,
Slider,
Stack,
Card,
CardContent,
Divider,
} from '@mui/material';
import {
ExpandMore as ExpandMoreIcon,
Schedule as ScheduleIcon,
Assessment as AssessmentIcon,
Refresh as RefreshIcon,
} from '@mui/icons-material';
import { documentService, BulkOcrRetryRequest, OcrRetryFilter, BulkOcrRetryResponse, ErrorHelper, ErrorCodes } from '../services/api';
interface BulkRetryModalProps {
open: boolean;
onClose: () => void;
onSuccess: (result: BulkOcrRetryResponse) => void;
selectedDocumentIds?: string[];
}
const COMMON_MIME_TYPES = [
{ value: 'application/pdf', label: 'PDF' },
{ value: 'image/png', label: 'PNG' },
{ value: 'image/jpeg', label: 'JPEG' },
{ value: 'image/tiff', label: 'TIFF' },
{ value: 'text/plain', label: 'Text' },
];
const COMMON_FAILURE_REASONS = [
{ value: 'pdf_font_encoding', label: 'Font Encoding Issues' },
{ value: 'ocr_timeout', label: 'Processing Timeout' },
{ value: 'pdf_corruption', label: 'File Corruption' },
{ value: 'low_ocr_confidence', label: 'Low Confidence' },
{ value: 'no_extractable_text', label: 'No Text Found' },
{ value: 'ocr_memory_limit', label: 'Memory Limit' },
];
const FILE_SIZE_PRESETS = [
{ label: '< 1MB', value: 1024 * 1024 },
{ label: '< 5MB', value: 5 * 1024 * 1024 },
{ label: '< 10MB', value: 10 * 1024 * 1024 },
{ label: '< 50MB', value: 50 * 1024 * 1024 },
];
export const BulkRetryModal: React.FC<BulkRetryModalProps> = ({
open,
onClose,
onSuccess,
selectedDocumentIds = [],
}) => {
const [mode, setMode] = useState<'all' | 'specific' | 'filter'>('all');
const [filter, setFilter] = useState<OcrRetryFilter>({});
const [priorityOverride, setPriorityOverride] = useState<number>(10);
const [usePriorityOverride, setUsePriorityOverride] = useState(false);
const [previewOnly, setPreviewOnly] = useState(true);
const [loading, setLoading] = useState(false);
const [previewResult, setPreviewResult] = useState<BulkOcrRetryResponse | null>(null);
const [error, setError] = useState<string | null>(null);
// Initialize mode based on selected documents
useEffect(() => {
if (selectedDocumentIds.length > 0) {
setMode('specific');
}
}, [selectedDocumentIds]);
const handleModeChange = (event: React.ChangeEvent<HTMLInputElement>) => {
setMode(event.target.value as 'all' | 'specific' | 'filter');
setPreviewResult(null);
setError(null);
};
const handleFilterChange = (key: keyof OcrRetryFilter, value: any) => {
setFilter(prev => ({
...prev,
[key]: value,
}));
setPreviewResult(null);
};
const handleMimeTypeToggle = (mimeType: string) => {
const current = filter.mime_types || [];
if (current.includes(mimeType)) {
handleFilterChange('mime_types', current.filter(t => t !== mimeType));
} else {
handleFilterChange('mime_types', [...current, mimeType]);
}
};
const handleFailureReasonToggle = (reason: string) => {
const current = filter.failure_reasons || [];
if (current.includes(reason)) {
handleFilterChange('failure_reasons', current.filter(r => r !== reason));
} else {
handleFilterChange('failure_reasons', [...current, reason]);
}
};
const buildRequest = (preview: boolean): BulkOcrRetryRequest => {
const request: BulkOcrRetryRequest = {
mode,
preview_only: preview,
};
if (mode === 'specific') {
request.document_ids = selectedDocumentIds;
} else if (mode === 'filter') {
request.filter = filter;
}
if (usePriorityOverride) {
request.priority_override = priorityOverride;
}
return request;
};
const handlePreview = async () => {
setLoading(true);
setError(null);
try {
const request = buildRequest(true);
const response = await documentService.bulkRetryOcr(request);
setPreviewResult(response.data);
} catch (err: any) {
const errorInfo = ErrorHelper.formatErrorForDisplay(err, true);
let errorMessage = 'Failed to preview retry operation';
// Handle specific bulk retry preview errors
if (ErrorHelper.isErrorCode(err, ErrorCodes.USER_SESSION_EXPIRED) ||
ErrorHelper.isErrorCode(err, ErrorCodes.USER_TOKEN_EXPIRED)) {
errorMessage = 'Your session has expired. Please refresh the page and log in again.';
} else if (ErrorHelper.isErrorCode(err, ErrorCodes.USER_PERMISSION_DENIED)) {
errorMessage = 'You do not have permission to preview retry operations.';
} else if (ErrorHelper.isErrorCode(err, ErrorCodes.DOCUMENT_NOT_FOUND)) {
errorMessage = 'No documents found matching the specified criteria.';
} else if (errorInfo.category === 'server') {
errorMessage = 'Server error. Please try again later.';
} else if (errorInfo.category === 'network') {
errorMessage = 'Network error. Please check your connection and try again.';
} else {
errorMessage = errorInfo.message || 'Failed to preview retry operation';
}
setError(errorMessage);
setPreviewResult(null);
} finally {
setLoading(false);
}
};
const handleExecute = async () => {
setLoading(true);
setError(null);
try {
const request = buildRequest(false);
const response = await documentService.bulkRetryOcr(request);
onSuccess(response.data);
onClose();
} catch (err: any) {
const errorInfo = ErrorHelper.formatErrorForDisplay(err, true);
let errorMessage = 'Failed to execute retry operation';
// Handle specific bulk retry execution errors
if (ErrorHelper.isErrorCode(err, ErrorCodes.USER_SESSION_EXPIRED) ||
ErrorHelper.isErrorCode(err, ErrorCodes.USER_TOKEN_EXPIRED)) {
errorMessage = 'Your session has expired. Please refresh the page and log in again.';
} else if (ErrorHelper.isErrorCode(err, ErrorCodes.USER_PERMISSION_DENIED)) {
errorMessage = 'You do not have permission to execute retry operations.';
} else if (ErrorHelper.isErrorCode(err, ErrorCodes.DOCUMENT_NOT_FOUND)) {
errorMessage = 'No documents found matching the specified criteria.';
} else if (ErrorHelper.isErrorCode(err, ErrorCodes.DOCUMENT_OCR_FAILED)) {
errorMessage = 'Some documents cannot be retried due to processing issues.';
} else if (errorInfo.category === 'server') {
errorMessage = 'Server error. Please try again later or contact support.';
} else if (errorInfo.category === 'network') {
errorMessage = 'Network error. Please check your connection and try again.';
} else {
errorMessage = errorInfo.message || 'Failed to execute retry operation';
}
setError(errorMessage);
} finally {
setLoading(false);
}
};
const formatFileSize = (bytes: number) => {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
return `${(bytes / (1024 * 1024 * 1024)).toFixed(1)} GB`;
};
const formatDuration = (minutes: number) => {
if (minutes < 1) return `${Math.round(minutes * 60)} seconds`;
if (minutes < 60) return `${Math.round(minutes)} minutes`;
return `${Math.round(minutes / 60)} hours`;
};
return (
<Dialog open={open} onClose={onClose} maxWidth="md" fullWidth>
<DialogTitle>
<Box display="flex" alignItems="center" gap={1}>
<RefreshIcon />
Bulk OCR Retry
</Box>
</DialogTitle>
<DialogContent>
<Stack spacing={3}>
{error && (
<Alert severity="error">{error}</Alert>
)}
{/* Selection Mode */}
<FormControl component="fieldset">
<FormLabel component="legend">Retry Mode</FormLabel>
<RadioGroup value={mode} onChange={handleModeChange}>
<FormControlLabel
value="all"
control={<Radio />}
label="Retry all failed OCR documents"
/>
<FormControlLabel
value="specific"
control={<Radio />}
label={`Retry selected documents (${selectedDocumentIds.length} selected)`}
disabled={selectedDocumentIds.length === 0}
/>
<FormControlLabel
value="filter"
control={<Radio />}
label="Retry documents matching criteria"
/>
</RadioGroup>
</FormControl>
{/* Filter Options */}
{mode === 'filter' && (
<Accordion>
<AccordionSummary expandIcon={<ExpandMoreIcon />}>
<Typography variant="h6">Filter Criteria</Typography>
</AccordionSummary>
<AccordionDetails>
<Stack spacing={3}>
{/* MIME Types */}
<Box>
<Typography variant="subtitle1" gutterBottom>
File Types
</Typography>
<Box display="flex" flexWrap="wrap" gap={1}>
{COMMON_MIME_TYPES.map(({ value, label }) => (
<Chip
key={value}
label={label}
variant={filter.mime_types?.includes(value) ? 'filled' : 'outlined'}
onClick={() => handleMimeTypeToggle(value)}
clickable
/>
))}
</Box>
</Box>
{/* Failure Reasons */}
<Box>
<Typography variant="subtitle1" gutterBottom>
Failure Reasons
</Typography>
<Box display="flex" flexWrap="wrap" gap={1}>
{COMMON_FAILURE_REASONS.map(({ value, label }) => (
<Chip
key={value}
label={label}
variant={filter.failure_reasons?.includes(value) ? 'filled' : 'outlined'}
onClick={() => handleFailureReasonToggle(value)}
clickable
color="secondary"
/>
))}
</Box>
</Box>
{/* File Size */}
<Box>
<Typography variant="subtitle1" gutterBottom>
Maximum File Size
</Typography>
<Box display="flex" flexWrap="wrap" gap={1} mb={2}>
{FILE_SIZE_PRESETS.map(({ label, value }) => (
<Chip
key={value}
label={label}
variant={filter.max_file_size === value ? 'filled' : 'outlined'}
onClick={() => handleFilterChange('max_file_size',
filter.max_file_size === value ? undefined : value)}
clickable
color="primary"
/>
))}
</Box>
{filter.max_file_size && (
<Typography variant="body2" color="text.secondary">
Max file size: {formatFileSize(filter.max_file_size)}
</Typography>
)}
</Box>
{/* Limit */}
<TextField
label="Maximum Documents to Retry"
type="number"
value={filter.limit || ''}
onChange={(e) => handleFilterChange('limit',
e.target.value ? parseInt(e.target.value) : undefined)}
InputProps={{
inputProps: { min: 1, max: 1000 }
}}
helperText="Leave empty for no limit"
/>
</Stack>
</AccordionDetails>
</Accordion>
)}
{/* Priority Override */}
<Accordion>
<AccordionSummary expandIcon={<ExpandMoreIcon />}>
<Typography variant="h6">Advanced Options</Typography>
</AccordionSummary>
<AccordionDetails>
<Stack spacing={2}>
<FormControlLabel
control={
<Checkbox
checked={usePriorityOverride}
onChange={(e) => setUsePriorityOverride(e.target.checked)}
/>
}
label="Override processing priority"
/>
{usePriorityOverride && (
<Box>
<Typography gutterBottom>
Priority: {priorityOverride} (Higher = More Urgent)
</Typography>
<Slider
value={priorityOverride}
onChange={(_, value) => setPriorityOverride(value as number)}
min={1}
max={20}
marks={[
{ value: 1, label: 'Low' },
{ value: 10, label: 'Normal' },
{ value: 20, label: 'High' },
]}
valueLabelDisplay="auto"
/>
</Box>
)}
</Stack>
</AccordionDetails>
</Accordion>
{/* Preview Results */}
{previewResult && (
<Card>
<CardContent>
<Typography variant="h6" gutterBottom>
<AssessmentIcon sx={{ mr: 1, verticalAlign: 'middle' }} />
Preview Results
</Typography>
<Stack spacing={2}>
<Box display="flex" justifyContent="space-between">
<Typography>Documents matched:</Typography>
<Typography fontWeight="bold">{previewResult.matched_count}</Typography>
</Box>
<Box display="flex" justifyContent="space-between">
<Typography>Estimated processing time:</Typography>
<Typography fontWeight="bold">
<ScheduleIcon sx={{ mr: 0.5, verticalAlign: 'middle', fontSize: 'small' }} />
{formatDuration(previewResult.estimated_total_time_minutes)}
</Typography>
</Box>
{previewResult.documents && previewResult.documents.length > 0 && (
<Box>
<Typography variant="subtitle2" gutterBottom>
Sample Documents:
</Typography>
<Box maxHeight={200} overflow="auto">
{(previewResult.documents || []).slice(0, 10).map((doc) => (
<Box key={doc.id} py={0.5}>
<Typography variant="body2">
{doc.filename} ({formatFileSize(doc.file_size)})
{doc.ocr_failure_reason && (
<Chip
size="small"
label={doc.ocr_failure_reason}
sx={{ ml: 1, fontSize: '0.7rem' }}
/>
)}
</Typography>
</Box>
))}
{previewResult.documents && previewResult.documents.length > 10 && (
<Typography variant="body2" color="text.secondary" mt={1}>
... and {previewResult.documents.length - 10} more documents
</Typography>
)}
</Box>
</Box>
)}
</Stack>
</CardContent>
</Card>
)}
{loading && <LinearProgress />}
</Stack>
</DialogContent>
<DialogActions>
<Button onClick={onClose} disabled={loading}>
Cancel
</Button>
<Button
onClick={handlePreview}
disabled={loading}
variant="outlined"
>
Preview
</Button>
<Button
onClick={handleExecute}
disabled={loading || !previewResult || previewResult.matched_count === 0}
variant="contained"
color="primary"
>
{loading ? 'Processing...' : `Retry ${previewResult?.matched_count || 0} Documents`}
</Button>
</DialogActions>
</Dialog>
);
};