Readur/frontend/src/pages/SettingsPage.tsx

1178 lines
46 KiB
TypeScript

import React, { useState, useEffect, useCallback } from 'react';
import {
Box,
Container,
Typography,
Paper,
Tabs,
Tab,
FormControl,
FormControlLabel,
InputLabel,
Select,
MenuItem,
Button,
Snackbar,
Alert,
TextField,
Table,
TableBody,
TableCell,
TableContainer,
TableHead,
TableRow,
IconButton,
Dialog,
DialogTitle,
DialogContent,
DialogActions,
Grid,
Card,
CardContent,
Divider,
Switch,
SelectChangeEvent,
Chip,
LinearProgress,
CircularProgress,
} from '@mui/material';
import { Edit as EditIcon, Delete as DeleteIcon, Add as AddIcon,
CloudSync as CloudSyncIcon, Folder as FolderIcon,
Assessment as AssessmentIcon, PlayArrow as PlayArrowIcon,
Pause as PauseIcon, Stop as StopIcon } from '@mui/icons-material';
import { useAuth } from '../contexts/AuthContext';
import api, { queueService } from '../services/api';
interface User {
id: string;
username: string;
email: string;
created_at: string;
}
interface Settings {
ocrLanguage: string;
concurrentOcrJobs: number;
ocrTimeoutSeconds: number;
maxFileSizeMb: number;
allowedFileTypes: string[];
autoRotateImages: boolean;
enableImagePreprocessing: boolean;
searchResultsPerPage: number;
searchSnippetLength: number;
fuzzySearchThreshold: number;
retentionDays: number | null;
enableAutoCleanup: boolean;
enableCompression: boolean;
memoryLimitMb: number;
cpuPriority: string;
enableBackgroundOcr: boolean;
ocrPageSegmentationMode: number;
ocrEngineMode: number;
ocrMinConfidence: number;
ocrDpi: number;
ocrEnhanceContrast: boolean;
ocrRemoveNoise: boolean;
ocrDetectOrientation: boolean;
ocrWhitelistChars: string;
ocrBlacklistChars: string;
ocrBrightnessBoost: number;
ocrContrastMultiplier: number;
ocrNoiseReductionLevel: number;
ocrSharpeningStrength: number;
ocrMorphologicalOperations: boolean;
ocrAdaptiveThresholdWindowSize: number;
ocrHistogramEqualization: boolean;
ocrUpscaleFactor: number;
ocrMaxImageWidth: number;
ocrMaxImageHeight: number;
saveProcessedImages: boolean;
ocrQualityThresholdBrightness: number;
ocrQualityThresholdContrast: number;
ocrQualityThresholdNoise: number;
ocrQualityThresholdSharpness: number;
ocrSkipEnhancement: boolean;
}
interface SnackbarState {
open: boolean;
message: string;
severity: 'success' | 'error' | 'warning' | 'info';
}
interface UserDialogState {
open: boolean;
mode: 'create' | 'edit';
user: User | null;
}
interface UserFormData {
username: string;
email: string;
password: string;
}
interface OcrLanguage {
code: string;
name: string;
}
interface WebDAVFolderInfo {
path: string;
total_files: number;
supported_files: number;
estimated_time_hours: number;
total_size_mb: number;
}
interface WebDAVCrawlEstimate {
folders: WebDAVFolderInfo[];
total_files: number;
total_supported_files: number;
total_estimated_time_hours: number;
total_size_mb: number;
}
interface WebDAVConnectionResult {
success: boolean;
message: string;
server_version?: string;
server_type?: string;
}
// Debounce utility function
function useDebounce<T extends (...args: any[]) => any>(func: T, delay: number): T {
const timeoutRef = React.useRef<NodeJS.Timeout | null>(null);
const debouncedFunc = useCallback((...args: Parameters<T>) => {
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
}
timeoutRef.current = setTimeout(() => func(...args), delay);
}, [func, delay]) as T;
// Cleanup on unmount
useEffect(() => {
return () => {
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
}
};
}, []);
return debouncedFunc;
}
const SettingsPage: React.FC = () => {
const { user: currentUser } = useAuth();
const [tabValue, setTabValue] = useState<number>(0);
const [settings, setSettings] = useState<Settings>({
ocrLanguage: 'eng',
concurrentOcrJobs: 4,
ocrTimeoutSeconds: 300,
maxFileSizeMb: 50,
allowedFileTypes: ['pdf', 'png', 'jpg', 'jpeg', 'tiff', 'bmp', 'txt'],
autoRotateImages: true,
enableImagePreprocessing: true,
searchResultsPerPage: 25,
searchSnippetLength: 200,
fuzzySearchThreshold: 0.8,
retentionDays: null,
enableAutoCleanup: false,
enableCompression: false,
memoryLimitMb: 512,
cpuPriority: 'normal',
enableBackgroundOcr: true,
ocrPageSegmentationMode: 3,
ocrEngineMode: 3,
ocrMinConfidence: 30.0,
ocrDpi: 300,
ocrEnhanceContrast: true,
ocrRemoveNoise: true,
ocrDetectOrientation: true,
ocrWhitelistChars: '',
ocrBlacklistChars: '',
ocrBrightnessBoost: 0.0,
ocrContrastMultiplier: 1.0,
ocrNoiseReductionLevel: 1,
ocrSharpeningStrength: 0.0,
ocrMorphologicalOperations: true,
ocrAdaptiveThresholdWindowSize: 15,
ocrHistogramEqualization: false,
ocrUpscaleFactor: 1.0,
ocrMaxImageWidth: 10000,
ocrMaxImageHeight: 10000,
saveProcessedImages: false,
ocrQualityThresholdBrightness: 40.0,
ocrQualityThresholdContrast: 0.15,
ocrQualityThresholdNoise: 0.3,
ocrQualityThresholdSharpness: 0.15,
ocrSkipEnhancement: false,
});
const [users, setUsers] = useState<User[]>([]);
const [loading, setLoading] = useState<boolean>(false);
const [snackbar, setSnackbar] = useState<SnackbarState>({
open: false,
message: '',
severity: 'success'
});
const [userDialog, setUserDialog] = useState<UserDialogState>({
open: false,
mode: 'create',
user: null
});
const [userForm, setUserForm] = useState<UserFormData>({
username: '',
email: '',
password: ''
});
// OCR Admin Controls State
const [ocrStatus, setOcrStatus] = useState<{ is_paused: boolean; status: 'paused' | 'running' } | null>(null);
const [ocrActionLoading, setOcrActionLoading] = useState(false);
const ocrLanguages: OcrLanguage[] = [
{ code: 'eng', name: 'English' },
{ code: 'spa', name: 'Spanish' },
{ code: 'fra', name: 'French' },
{ code: 'deu', name: 'German' },
{ code: 'ita', name: 'Italian' },
{ code: 'por', name: 'Portuguese' },
{ code: 'rus', name: 'Russian' },
{ code: 'jpn', name: 'Japanese' },
{ code: 'chi_sim', name: 'Chinese (Simplified)' },
{ code: 'chi_tra', name: 'Chinese (Traditional)' },
{ code: 'kor', name: 'Korean' },
{ code: 'ara', name: 'Arabic' },
{ code: 'hin', name: 'Hindi' },
{ code: 'nld', name: 'Dutch' },
{ code: 'pol', name: 'Polish' },
];
useEffect(() => {
fetchSettings();
fetchUsers();
fetchOcrStatus();
}, []);
const fetchSettings = async (): Promise<void> => {
try {
const response = await api.get('/settings');
setSettings({
ocrLanguage: response.data.ocr_language || 'eng',
concurrentOcrJobs: response.data.concurrent_ocr_jobs || 4,
ocrTimeoutSeconds: response.data.ocr_timeout_seconds || 300,
maxFileSizeMb: response.data.max_file_size_mb || 50,
allowedFileTypes: response.data.allowed_file_types || ['pdf', 'png', 'jpg', 'jpeg', 'tiff', 'bmp', 'txt'],
autoRotateImages: response.data.auto_rotate_images !== undefined ? response.data.auto_rotate_images : true,
enableImagePreprocessing: response.data.enable_image_preprocessing !== undefined ? response.data.enable_image_preprocessing : true,
searchResultsPerPage: response.data.search_results_per_page || 25,
searchSnippetLength: response.data.search_snippet_length || 200,
fuzzySearchThreshold: response.data.fuzzy_search_threshold || 0.8,
retentionDays: response.data.retention_days,
enableAutoCleanup: response.data.enable_auto_cleanup || false,
enableCompression: response.data.enable_compression || false,
memoryLimitMb: response.data.memory_limit_mb || 512,
cpuPriority: response.data.cpu_priority || 'normal',
enableBackgroundOcr: response.data.enable_background_ocr !== undefined ? response.data.enable_background_ocr : true,
ocrPageSegmentationMode: response.data.ocr_page_segmentation_mode || 3,
ocrEngineMode: response.data.ocr_engine_mode || 3,
ocrMinConfidence: response.data.ocr_min_confidence || 30.0,
ocrDpi: response.data.ocr_dpi || 300,
ocrEnhanceContrast: response.data.ocr_enhance_contrast !== undefined ? response.data.ocr_enhance_contrast : true,
ocrRemoveNoise: response.data.ocr_remove_noise !== undefined ? response.data.ocr_remove_noise : true,
ocrDetectOrientation: response.data.ocr_detect_orientation !== undefined ? response.data.ocr_detect_orientation : true,
ocrWhitelistChars: response.data.ocr_whitelist_chars || '',
ocrBlacklistChars: response.data.ocr_blacklist_chars || '',
ocrBrightnessBoost: response.data.ocr_brightness_boost || 0.0,
ocrContrastMultiplier: response.data.ocr_contrast_multiplier || 1.0,
ocrNoiseReductionLevel: response.data.ocr_noise_reduction_level || 1,
ocrSharpeningStrength: response.data.ocr_sharpening_strength || 0.0,
ocrMorphologicalOperations: response.data.ocr_morphological_operations !== undefined ? response.data.ocr_morphological_operations : true,
ocrAdaptiveThresholdWindowSize: response.data.ocr_adaptive_threshold_window_size || 15,
ocrHistogramEqualization: response.data.ocr_histogram_equalization || false,
ocrUpscaleFactor: response.data.ocr_upscale_factor || 1.0,
ocrMaxImageWidth: response.data.ocr_max_image_width || 10000,
ocrMaxImageHeight: response.data.ocr_max_image_height || 10000,
saveProcessedImages: response.data.save_processed_images || false,
ocrQualityThresholdBrightness: response.data.ocr_quality_threshold_brightness || 40.0,
ocrQualityThresholdContrast: response.data.ocr_quality_threshold_contrast || 0.15,
ocrQualityThresholdNoise: response.data.ocr_quality_threshold_noise || 0.3,
ocrQualityThresholdSharpness: response.data.ocr_quality_threshold_sharpness || 0.15,
ocrSkipEnhancement: response.data.ocr_skip_enhancement || false,
});
} catch (error: any) {
console.error('Error fetching settings:', error);
if (error.response?.status !== 404) {
showSnackbar('Failed to load settings', 'error');
}
}
};
const fetchUsers = async (): Promise<void> => {
try {
const response = await api.get<User[]>('/users');
setUsers(response.data);
} catch (error: any) {
console.error('Error fetching users:', error);
if (error.response?.status !== 404) {
showSnackbar('Failed to load users', 'error');
}
}
};
const handleSettingsChange = async (key: keyof Settings, value: any): Promise<void> => {
try {
// Convert camelCase to snake_case for API
const snakeCase = (str: string): string => str.replace(/[A-Z]/g, letter => `_${letter.toLowerCase()}`);
const apiKey = snakeCase(key);
// Build the update payload with only the changed field
const updatePayload = { [apiKey]: value };
await api.put('/settings', updatePayload);
// Only update state after successful API call
setSettings(prevSettings => ({ ...prevSettings, [key]: value }));
// Only show success message for non-text inputs to reduce noise
if (typeof value !== 'string') {
showSnackbar('Settings updated successfully', 'success');
}
} catch (error) {
console.error('Error updating settings:', error);
showSnackbar('Failed to update settings', 'error');
}
};
const handleUserSubmit = async (): Promise<void> => {
setLoading(true);
try {
if (userDialog.mode === 'create') {
await api.post('/users', userForm);
showSnackbar('User created successfully', 'success');
} else {
const { password, ...updateData } = userForm;
const payload: any = updateData;
if (password) {
payload.password = password;
}
await api.put(`/users/${userDialog.user?.id}`, payload);
showSnackbar('User updated successfully', 'success');
}
fetchUsers();
handleCloseUserDialog();
} catch (error: any) {
console.error('Error saving user:', error);
showSnackbar(error.response?.data?.message || 'Failed to save user', 'error');
} finally {
setLoading(false);
}
};
const handleDeleteUser = async (userId: string): Promise<void> => {
if (userId === currentUser?.id) {
showSnackbar('You cannot delete your own account', 'error');
return;
}
if (window.confirm('Are you sure you want to delete this user?')) {
setLoading(true);
try {
await api.delete(`/users/${userId}`);
showSnackbar('User deleted successfully', 'success');
fetchUsers();
} catch (error) {
console.error('Error deleting user:', error);
showSnackbar('Failed to delete user', 'error');
} finally {
setLoading(false);
}
}
};
const handleOpenUserDialog = (mode: 'create' | 'edit', user: User | null = null): void => {
setUserDialog({ open: true, mode, user });
if (mode === 'edit' && user) {
setUserForm({ username: user.username, email: user.email, password: '' });
} else {
setUserForm({ username: '', email: '', password: '' });
}
};
const handleCloseUserDialog = (): void => {
setUserDialog({ open: false, mode: 'create', user: null });
setUserForm({ username: '', email: '', password: '' });
};
const showSnackbar = (message: string, severity: SnackbarState['severity']): void => {
setSnackbar({ open: true, message, severity });
};
const handleTabChange = (event: React.SyntheticEvent, newValue: number): void => {
setTabValue(newValue);
};
const handleOcrLanguageChange = (event: SelectChangeEvent<string>): void => {
handleSettingsChange('ocrLanguage', event.target.value);
};
const handleCpuPriorityChange = (event: SelectChangeEvent<string>): void => {
handleSettingsChange('cpuPriority', event.target.value);
};
const handleResultsPerPageChange = (event: SelectChangeEvent<number>): void => {
handleSettingsChange('searchResultsPerPage', event.target.value);
};
const fetchOcrStatus = async (): Promise<void> => {
try {
const response = await queueService.getOcrStatus();
setOcrStatus(response.data);
} catch (error: any) {
console.error('Error fetching OCR status:', error);
// Don't show error for OCR status since it might not be available for non-admin users
}
};
const handlePauseOcr = async (): Promise<void> => {
setOcrActionLoading(true);
try {
await queueService.pauseOcr();
showSnackbar('OCR processing paused successfully', 'success');
fetchOcrStatus(); // Refresh status
} catch (error: any) {
console.error('Error pausing OCR:', error);
if (error.response?.status === 403) {
showSnackbar('Admin access required to pause OCR processing', 'error');
} else {
showSnackbar('Failed to pause OCR processing', 'error');
}
} finally {
setOcrActionLoading(false);
}
};
const handleResumeOcr = async (): Promise<void> => {
setOcrActionLoading(true);
try {
await queueService.resumeOcr();
showSnackbar('OCR processing resumed successfully', 'success');
fetchOcrStatus(); // Refresh status
} catch (error: any) {
console.error('Error resuming OCR:', error);
if (error.response?.status === 403) {
showSnackbar('Admin access required to resume OCR processing', 'error');
} else {
showSnackbar('Failed to resume OCR processing', 'error');
}
} finally {
setOcrActionLoading(false);
}
};
return (
<Container maxWidth="lg" sx={{ mt: 4, mb: 4 }}>
<Typography variant="h4" sx={{ mb: 4 }}>
Settings
</Typography>
<Paper sx={{ width: '100%' }}>
<Tabs value={tabValue} onChange={handleTabChange} aria-label="settings tabs">
<Tab label="General" />
<Tab label="OCR Settings" />
<Tab label="User Management" />
</Tabs>
<Box sx={{ p: 3 }}>
{tabValue === 0 && (
<Box>
<Typography variant="h6" sx={{ mb: 3 }}>
General Settings
</Typography>
<Card sx={{ mb: 3 }}>
<CardContent>
<Typography variant="subtitle1" sx={{ mb: 2 }}>
OCR Configuration
</Typography>
<Divider sx={{ mb: 2 }} />
<Grid container spacing={2}>
<Grid item xs={12} md={6}>
<FormControl fullWidth>
<InputLabel>OCR Language</InputLabel>
<Select
value={settings.ocrLanguage}
label="OCR Language"
onChange={handleOcrLanguageChange}
disabled={loading}
>
{ocrLanguages.map((lang) => (
<MenuItem key={lang.code} value={lang.code}>
{lang.name}
</MenuItem>
))}
</Select>
</FormControl>
</Grid>
<Grid item xs={12} md={6}>
<TextField
fullWidth
type="number"
label="Concurrent OCR Jobs"
value={settings.concurrentOcrJobs}
onChange={(e) => handleSettingsChange('concurrentOcrJobs', parseInt(e.target.value))}
disabled={loading}
inputProps={{ min: 1, max: 16 }}
helperText="Number of OCR jobs that can run simultaneously"
/>
</Grid>
<Grid item xs={12} md={6}>
<TextField
fullWidth
type="number"
label="OCR Timeout (seconds)"
value={settings.ocrTimeoutSeconds}
onChange={(e) => handleSettingsChange('ocrTimeoutSeconds', parseInt(e.target.value))}
disabled={loading}
inputProps={{ min: 30, max: 3600 }}
helperText="Maximum time for OCR processing per file"
/>
</Grid>
<Grid item xs={12} md={6}>
<FormControl fullWidth>
<InputLabel>CPU Priority</InputLabel>
<Select
value={settings.cpuPriority}
label="CPU Priority"
onChange={handleCpuPriorityChange}
disabled={loading}
>
<MenuItem value="low">Low</MenuItem>
<MenuItem value="normal">Normal</MenuItem>
<MenuItem value="high">High</MenuItem>
</Select>
</FormControl>
</Grid>
</Grid>
</CardContent>
</Card>
{/* Admin OCR Controls */}
<Card sx={{ mb: 3 }}>
<CardContent>
<Typography variant="subtitle1" sx={{ mb: 2 }}>
<StopIcon sx={{ mr: 1, verticalAlign: 'middle' }} />
OCR Processing Controls (Admin Only)
</Typography>
<Divider sx={{ mb: 2 }} />
<Typography variant="body2" sx={{ mb: 2, color: 'text.secondary' }}>
Control OCR processing to manage CPU usage and allow users to use the application without performance impact.
</Typography>
<Grid container spacing={2} alignItems="center">
<Grid item xs={12} md={6}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2 }}>
<Button
variant={ocrStatus?.is_paused ? "outlined" : "contained"}
color={ocrStatus?.is_paused ? "success" : "warning"}
startIcon={ocrActionLoading ? <CircularProgress size={16} /> :
(ocrStatus?.is_paused ? <PlayArrowIcon /> : <PauseIcon />)}
onClick={ocrStatus?.is_paused ? handleResumeOcr : handlePauseOcr}
disabled={ocrActionLoading || loading}
size="large"
>
{ocrActionLoading ? 'Processing...' :
ocrStatus?.is_paused ? 'Resume OCR Processing' : 'Pause OCR Processing'}
</Button>
</Box>
</Grid>
<Grid item xs={12} md={6}>
{ocrStatus && (
<Box>
<Chip
label={`OCR Status: ${ocrStatus.status.toUpperCase()}`}
color={ocrStatus.is_paused ? "warning" : "success"}
variant="outlined"
icon={ocrStatus.is_paused ? <PauseIcon /> : <PlayArrowIcon />}
size="medium"
/>
<Typography variant="caption" sx={{ display: 'block', mt: 1, color: 'text.secondary' }}>
{ocrStatus.is_paused
? 'OCR processing is paused. No new jobs will be processed.'
: 'OCR processing is active. Documents will be processed automatically.'}
</Typography>
</Box>
)}
</Grid>
</Grid>
{ocrStatus?.is_paused && (
<Alert severity="warning" sx={{ mt: 2 }}>
<Typography variant="body2">
<strong>OCR Processing Paused</strong><br />
New documents will not be processed for OCR text extraction until processing is resumed.
Users can still upload and view documents, but search functionality may be limited.
</Typography>
</Alert>
)}
</CardContent>
</Card>
<Card sx={{ mb: 3 }}>
<CardContent>
<Typography variant="subtitle1" sx={{ mb: 2 }}>
File Processing
</Typography>
<Divider sx={{ mb: 2 }} />
<Grid container spacing={2}>
<Grid item xs={12} md={6}>
<TextField
fullWidth
type="number"
label="Max File Size (MB)"
value={settings.maxFileSizeMb}
onChange={(e) => handleSettingsChange('maxFileSizeMb', parseInt(e.target.value))}
disabled={loading}
inputProps={{ min: 1, max: 500 }}
helperText="Maximum allowed file size for uploads"
/>
</Grid>
<Grid item xs={12} md={6}>
<TextField
fullWidth
type="number"
label="Memory Limit (MB)"
value={settings.memoryLimitMb}
onChange={(e) => handleSettingsChange('memoryLimitMb', parseInt(e.target.value))}
disabled={loading}
inputProps={{ min: 128, max: 4096 }}
helperText="Memory limit per OCR job"
/>
</Grid>
<Grid item xs={12}>
<FormControl sx={{ mb: 2 }}>
<FormControlLabel
control={
<Switch
checked={settings.autoRotateImages}
onChange={(e) => handleSettingsChange('autoRotateImages', e.target.checked)}
disabled={loading}
/>
}
label="Auto-rotate Images"
/>
<Typography variant="body2" color="text.secondary">
Automatically detect and correct image orientation
</Typography>
</FormControl>
</Grid>
<Grid item xs={12}>
<FormControl sx={{ mb: 2 }}>
<FormControlLabel
control={
<Switch
checked={settings.enableImagePreprocessing}
onChange={(e) => handleSettingsChange('enableImagePreprocessing', e.target.checked)}
disabled={loading}
/>
}
label="Enable Image Preprocessing"
/>
<Typography variant="body2" color="text.secondary">
Enhance images for better OCR accuracy (deskew, denoise, contrast)
</Typography>
</FormControl>
</Grid>
<Grid item xs={12}>
<FormControl sx={{ mb: 2 }}>
<FormControlLabel
control={
<Switch
checked={settings.enableBackgroundOcr}
onChange={(e) => handleSettingsChange('enableBackgroundOcr', e.target.checked)}
disabled={loading}
/>
}
label="Enable Background OCR"
/>
<Typography variant="body2" color="text.secondary">
Process OCR in the background after file upload
</Typography>
</FormControl>
</Grid>
</Grid>
</CardContent>
</Card>
<Card sx={{ mb: 3 }}>
<CardContent>
<Typography variant="subtitle1" sx={{ mb: 2 }}>
Search Configuration
</Typography>
<Divider sx={{ mb: 2 }} />
<Grid container spacing={2}>
<Grid item xs={12} md={6}>
<FormControl fullWidth>
<InputLabel>Results Per Page</InputLabel>
<Select
value={settings.searchResultsPerPage}
label="Results Per Page"
onChange={handleResultsPerPageChange}
disabled={loading}
>
<MenuItem value={10}>10</MenuItem>
<MenuItem value={25}>25</MenuItem>
<MenuItem value={50}>50</MenuItem>
<MenuItem value={100}>100</MenuItem>
</Select>
</FormControl>
</Grid>
<Grid item xs={12} md={6}>
<TextField
fullWidth
type="number"
label="Snippet Length"
value={settings.searchSnippetLength}
onChange={(e) => handleSettingsChange('searchSnippetLength', parseInt(e.target.value))}
disabled={loading}
inputProps={{ min: 50, max: 500 }}
helperText="Characters to show in search result previews"
/>
</Grid>
<Grid item xs={12} md={6}>
<TextField
fullWidth
type="number"
label="Fuzzy Search Threshold"
value={settings.fuzzySearchThreshold}
onChange={(e) => handleSettingsChange('fuzzySearchThreshold', parseFloat(e.target.value))}
disabled={loading}
inputProps={{ min: 0, max: 1, step: 0.1 }}
helperText="Tolerance for spelling mistakes (0.0-1.0)"
/>
</Grid>
</Grid>
</CardContent>
</Card>
<Card sx={{ mb: 3 }}>
<CardContent>
<Typography variant="subtitle1" sx={{ mb: 2 }}>
Storage Management
</Typography>
<Divider sx={{ mb: 2 }} />
<Grid container spacing={2}>
<Grid item xs={12} md={6}>
<TextField
fullWidth
type="number"
label="Retention Days"
value={settings.retentionDays || ''}
onChange={(e) => handleSettingsChange('retentionDays', e.target.value ? parseInt(e.target.value) : null)}
disabled={loading}
inputProps={{ min: 1 }}
helperText="Auto-delete documents after X days (leave empty to disable)"
/>
</Grid>
<Grid item xs={12}>
<FormControl sx={{ mb: 2 }}>
<FormControlLabel
control={
<Switch
checked={settings.enableAutoCleanup}
onChange={(e) => handleSettingsChange('enableAutoCleanup', e.target.checked)}
disabled={loading}
/>
}
label="Enable Auto Cleanup"
/>
<Typography variant="body2" color="text.secondary">
Automatically remove orphaned files and clean up storage
</Typography>
</FormControl>
</Grid>
<Grid item xs={12}>
<FormControl sx={{ mb: 2 }}>
<FormControlLabel
control={
<Switch
checked={settings.enableCompression}
onChange={(e) => handleSettingsChange('enableCompression', e.target.checked)}
disabled={loading}
/>
}
label="Enable Compression"
/>
<Typography variant="body2" color="text.secondary">
Compress stored documents to save disk space
</Typography>
</FormControl>
</Grid>
</Grid>
</CardContent>
</Card>
</Box>
)}
{tabValue === 1 && (
<Box>
<Typography variant="h6" sx={{ mb: 3 }}>
OCR Image Processing Settings
</Typography>
<Card sx={{ mb: 3 }}>
<CardContent>
<Typography variant="subtitle1" sx={{ mb: 2 }}>
Enhancement Controls
</Typography>
<Divider sx={{ mb: 2 }} />
<FormControlLabel
control={
<Switch
checked={settings.ocrSkipEnhancement}
onChange={(e) => handleSettingsChange('ocrSkipEnhancement', e.target.checked)}
/>
}
label="Skip All Image Enhancement (Use Original Images Only)"
sx={{ mb: 2 }}
/>
<Grid container spacing={2}>
<Grid item xs={12} md={6}>
<TextField
fullWidth
label="Brightness Boost"
type="number"
value={settings.ocrBrightnessBoost}
onChange={(e) => handleSettingsChange('ocrBrightnessBoost', parseFloat(e.target.value) || 0)}
helperText="Manual brightness adjustment (0 = auto, >0 = boost amount)"
inputProps={{ step: 0.1, min: 0, max: 100 }}
/>
</Grid>
<Grid item xs={12} md={6}>
<TextField
fullWidth
label="Contrast Multiplier"
type="number"
value={settings.ocrContrastMultiplier}
onChange={(e) => handleSettingsChange('ocrContrastMultiplier', parseFloat(e.target.value) || 1)}
helperText="Manual contrast adjustment (1.0 = auto, >1.0 = increase)"
inputProps={{ step: 0.1, min: 0.1, max: 5 }}
/>
</Grid>
<Grid item xs={12} md={6}>
<FormControl fullWidth>
<InputLabel>Noise Reduction Level</InputLabel>
<Select
value={settings.ocrNoiseReductionLevel}
label="Noise Reduction Level"
onChange={(e) => handleSettingsChange('ocrNoiseReductionLevel', e.target.value as number)}
>
<MenuItem value={0}>None</MenuItem>
<MenuItem value={1}>Light</MenuItem>
<MenuItem value={2}>Moderate</MenuItem>
<MenuItem value={3}>Heavy</MenuItem>
</Select>
</FormControl>
</Grid>
<Grid item xs={12} md={6}>
<TextField
fullWidth
label="Sharpening Strength"
type="number"
value={settings.ocrSharpeningStrength}
onChange={(e) => handleSettingsChange('ocrSharpeningStrength', parseFloat(e.target.value) || 0)}
helperText="Image sharpening amount (0 = auto, >0 = manual)"
inputProps={{ step: 0.1, min: 0, max: 2 }}
/>
</Grid>
</Grid>
</CardContent>
</Card>
<Card sx={{ mb: 3 }}>
<CardContent>
<Typography variant="subtitle1" sx={{ mb: 2 }}>
Quality Thresholds (when to apply enhancements)
</Typography>
<Divider sx={{ mb: 2 }} />
<Grid container spacing={2}>
<Grid item xs={12} md={6}>
<TextField
fullWidth
label="Brightness Threshold"
type="number"
value={settings.ocrQualityThresholdBrightness}
onChange={(e) => handleSettingsChange('ocrQualityThresholdBrightness', parseFloat(e.target.value) || 40)}
helperText="Enhance if brightness below this value (0-255)"
inputProps={{ step: 1, min: 0, max: 255 }}
/>
</Grid>
<Grid item xs={12} md={6}>
<TextField
fullWidth
label="Contrast Threshold"
type="number"
value={settings.ocrQualityThresholdContrast}
onChange={(e) => handleSettingsChange('ocrQualityThresholdContrast', parseFloat(e.target.value) || 0.15)}
helperText="Enhance if contrast below this value (0-1)"
inputProps={{ step: 0.01, min: 0, max: 1 }}
/>
</Grid>
<Grid item xs={12} md={6}>
<TextField
fullWidth
label="Noise Threshold"
type="number"
value={settings.ocrQualityThresholdNoise}
onChange={(e) => handleSettingsChange('ocrQualityThresholdNoise', parseFloat(e.target.value) || 0.3)}
helperText="Enhance if noise above this value (0-1)"
inputProps={{ step: 0.01, min: 0, max: 1 }}
/>
</Grid>
<Grid item xs={12} md={6}>
<TextField
fullWidth
label="Sharpness Threshold"
type="number"
value={settings.ocrQualityThresholdSharpness}
onChange={(e) => handleSettingsChange('ocrQualityThresholdSharpness', parseFloat(e.target.value) || 0.15)}
helperText="Enhance if sharpness below this value (0-1)"
inputProps={{ step: 0.01, min: 0, max: 1 }}
/>
</Grid>
</Grid>
</CardContent>
</Card>
<Card sx={{ mb: 3 }}>
<CardContent>
<Typography variant="subtitle1" sx={{ mb: 2 }}>
Advanced Processing Options
</Typography>
<Divider sx={{ mb: 2 }} />
<Grid container spacing={2}>
<Grid item xs={12} md={6}>
<FormControlLabel
control={
<Switch
checked={settings.ocrMorphologicalOperations}
onChange={(e) => handleSettingsChange('ocrMorphologicalOperations', e.target.checked)}
/>
}
label="Morphological Operations (text cleanup)"
/>
</Grid>
<Grid item xs={12} md={6}>
<FormControlLabel
control={
<Switch
checked={settings.ocrHistogramEqualization}
onChange={(e) => handleSettingsChange('ocrHistogramEqualization', e.target.checked)}
/>
}
label="Histogram Equalization"
/>
</Grid>
<Grid item xs={12} md={6}>
<FormControlLabel
control={
<Switch
checked={settings.saveProcessedImages}
onChange={(e) => handleSettingsChange('saveProcessedImages', e.target.checked)}
/>
}
label="Save Processed Images for Review"
/>
</Grid>
<Grid item xs={12} md={6}>
<TextField
fullWidth
label="Adaptive Threshold Window Size"
type="number"
value={settings.ocrAdaptiveThresholdWindowSize}
onChange={(e) => handleSettingsChange('ocrAdaptiveThresholdWindowSize', parseInt(e.target.value) || 15)}
helperText="Window size for contrast enhancement (odd number)"
inputProps={{ step: 2, min: 3, max: 101 }}
/>
</Grid>
</Grid>
</CardContent>
</Card>
<Card sx={{ mb: 3 }}>
<CardContent>
<Typography variant="subtitle1" sx={{ mb: 2 }}>
Image Size and Scaling
</Typography>
<Divider sx={{ mb: 2 }} />
<Grid container spacing={2}>
<Grid item xs={12} md={6}>
<TextField
fullWidth
label="Max Image Width"
type="number"
value={settings.ocrMaxImageWidth}
onChange={(e) => handleSettingsChange('ocrMaxImageWidth', parseInt(e.target.value) || 10000)}
helperText="Maximum image width in pixels"
inputProps={{ step: 100, min: 100, max: 50000 }}
/>
</Grid>
<Grid item xs={12} md={6}>
<TextField
fullWidth
label="Max Image Height"
type="number"
value={settings.ocrMaxImageHeight}
onChange={(e) => handleSettingsChange('ocrMaxImageHeight', parseInt(e.target.value) || 10000)}
helperText="Maximum image height in pixels"
inputProps={{ step: 100, min: 100, max: 50000 }}
/>
</Grid>
<Grid item xs={12} md={6}>
<TextField
fullWidth
label="Upscale Factor"
type="number"
value={settings.ocrUpscaleFactor}
onChange={(e) => handleSettingsChange('ocrUpscaleFactor', parseFloat(e.target.value) || 1.0)}
helperText="Image scaling factor (1.0 = no scaling)"
inputProps={{ step: 0.1, min: 0.1, max: 5 }}
/>
</Grid>
</Grid>
</CardContent>
</Card>
</Box>
)}
{tabValue === 2 && (
<Box>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 3 }}>
<Typography variant="h6">
User Management
</Typography>
<Button
variant="contained"
startIcon={<AddIcon />}
onClick={() => handleOpenUserDialog('create')}
disabled={loading}
>
Add User
</Button>
</Box>
<TableContainer component={Paper}>
<Table>
<TableHead>
<TableRow>
<TableCell>Username</TableCell>
<TableCell>Email</TableCell>
<TableCell>Created At</TableCell>
<TableCell align="right">Actions</TableCell>
</TableRow>
</TableHead>
<TableBody>
{users.map((user) => (
<TableRow key={user.id}>
<TableCell>{user.username}</TableCell>
<TableCell>{user.email}</TableCell>
<TableCell>{new Date(user.created_at).toLocaleDateString()}</TableCell>
<TableCell align="right">
<IconButton
onClick={() => handleOpenUserDialog('edit', user)}
disabled={loading}
>
<EditIcon />
</IconButton>
<IconButton
onClick={() => handleDeleteUser(user.id)}
disabled={loading || user.id === currentUser?.id}
>
<DeleteIcon />
</IconButton>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</TableContainer>
</Box>
)}
</Box>
</Paper>
<Dialog open={userDialog.open} onClose={handleCloseUserDialog} maxWidth="sm" fullWidth>
<DialogTitle>
{userDialog.mode === 'create' ? 'Create New User' : 'Edit User'}
</DialogTitle>
<DialogContent>
<Grid container spacing={2} sx={{ mt: 1 }}>
<Grid item xs={12}>
<TextField
fullWidth
label="Username"
value={userForm.username}
onChange={(e) => setUserForm({ ...userForm, username: e.target.value })}
required
/>
</Grid>
<Grid item xs={12}>
<TextField
fullWidth
label="Email"
type="email"
value={userForm.email}
onChange={(e) => setUserForm({ ...userForm, email: e.target.value })}
required
/>
</Grid>
<Grid item xs={12}>
<TextField
fullWidth
label={userDialog.mode === 'create' ? 'Password' : 'New Password (leave empty to keep current)'}
type="password"
value={userForm.password}
onChange={(e) => setUserForm({ ...userForm, password: e.target.value })}
required={userDialog.mode === 'create'}
/>
</Grid>
</Grid>
</DialogContent>
<DialogActions>
<Button onClick={handleCloseUserDialog} disabled={loading}>
Cancel
</Button>
<Button onClick={handleUserSubmit} variant="contained" disabled={loading}>
{userDialog.mode === 'create' ? 'Create' : 'Update'}
</Button>
</DialogActions>
</Dialog>
<Snackbar
open={snackbar.open}
autoHideDuration={6000}
onClose={() => setSnackbar({ ...snackbar, open: false })}
>
<Alert
onClose={() => setSnackbar({ ...snackbar, open: false })}
severity={snackbar.severity}
sx={{ width: '100%' }}
>
{snackbar.message}
</Alert>
</Snackbar>
</Container>
);
};
export default SettingsPage;