Readur/frontend/src/components/Dashboard/Dashboard.tsx

715 lines
23 KiB
TypeScript

import React, { useState, useEffect } from 'react';
import {
Box,
Card,
CardContent,
Typography,
LinearProgress,
Chip,
Avatar,
List,
ListItem,
ListItemAvatar,
ListItemText,
ListItemSecondaryAction,
IconButton,
Fab,
Paper,
useTheme,
alpha,
} from '@mui/material';
import Grid from '@mui/material/GridLegacy';
import {
CloudUpload as UploadIcon,
Article as DocumentIcon,
Search as SearchIcon,
TrendingUp as TrendingUpIcon,
CloudDone as StorageIcon,
AutoAwesome as OcrIcon,
FindInPage as SearchableIcon,
Add as AddIcon,
GetApp as DownloadIcon,
Visibility as ViewIcon,
Delete as DeleteIcon,
InsertDriveFile as FileIcon,
PictureAsPdf as PdfIcon,
Image as ImageIcon,
TextSnippet as TextIcon,
} from '@mui/icons-material';
import { useNavigate } from 'react-router-dom';
import { useAuth } from '../../contexts/AuthContext';
import api from '../../services/api';
interface Document {
id: string;
original_filename?: string;
filename?: string;
file_size?: number;
mime_type?: string;
created_at?: string;
ocr_text?: string;
has_ocr_text?: boolean;
}
interface DashboardStats {
totalDocuments: number;
totalSize: number;
ocrProcessed: number;
searchablePages: number;
}
interface StatsCardProps {
title: string;
value: string | number;
subtitle: string;
icon: React.ComponentType<any>;
color: string;
trend?: string;
}
interface RecentDocumentsProps {
documents: Document[];
}
interface QuickAction {
title: string;
description: string;
icon: React.ComponentType<any>;
color: string;
path: string;
}
// Stats Card Component
const StatsCard: React.FC<StatsCardProps> = ({ title, value, subtitle, icon: Icon, color, trend }) => {
const theme = useTheme();
return (
<Card
elevation={0}
sx={{
background: `linear-gradient(135deg, ${color} 0%, ${alpha(color, 0.85)} 100%)`,
color: 'white',
position: 'relative',
overflow: 'hidden',
borderRadius: 3,
transition: 'all 0.3s cubic-bezier(0.4, 0, 0.2, 1)',
cursor: 'pointer',
border: '1px solid rgba(255,255,255,0.1)',
backdropFilter: 'blur(20px)',
'&:hover': {
transform: 'translateY(-4px)',
boxShadow: `0 20px 40px ${alpha(color, 0.3)}`,
},
'&::before': {
content: '""',
position: 'absolute',
top: 0,
right: 0,
width: '120px',
height: '120px',
background: 'linear-gradient(135deg, rgba(255,255,255,0.15) 0%, rgba(255,255,255,0.05) 100%)',
borderRadius: '50%',
transform: 'translate(40px, -40px)',
},
'&::after': {
content: '""',
position: 'absolute',
top: 0,
left: 0,
right: 0,
bottom: 0,
background: 'linear-gradient(135deg, rgba(255,255,255,0.1) 0%, rgba(255,255,255,0.05) 100%)',
backdropFilter: 'blur(10px)',
},
}}
>
<CardContent sx={{ position: 'relative', zIndex: 1, p: 3 }}>
<Box sx={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between' }}>
<Box sx={{ flex: 1 }}>
<Typography variant="h3" sx={{
fontWeight: 800,
mb: 1.5,
letterSpacing: '-0.025em',
fontSize: { xs: '1.75rem', sm: '2.125rem' },
}}>
{value}
</Typography>
<Typography variant="h6" sx={{
opacity: 0.85,
mb: 0.5,
fontWeight: 600,
letterSpacing: '0.025em',
}}>
{title}
</Typography>
<Typography variant="body2" sx={{
opacity: 0.75,
fontWeight: 500,
fontSize: '0.875rem',
}}>
{subtitle}
</Typography>
{trend && (
<Box sx={{ display: 'flex', alignItems: 'center', mt: 1.5 }}>
<Box sx={{
p: 0.5,
borderRadius: 1,
background: 'rgba(255,255,255,0.2)',
backdropFilter: 'blur(10px)',
display: 'flex',
alignItems: 'center',
mr: 1,
}}>
<TrendingUpIcon sx={{ fontSize: 14 }} />
</Box>
<Typography variant="caption" sx={{
opacity: 0.8,
fontWeight: 600,
fontSize: '0.75rem',
letterSpacing: '0.025em',
}}>
{trend}
</Typography>
</Box>
)}
</Box>
<Box sx={{
width: 64,
height: 64,
borderRadius: 3,
background: 'linear-gradient(135deg, rgba(255,255,255,0.25) 0%, rgba(255,255,255,0.15) 100%)',
backdropFilter: 'blur(20px)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
border: '1px solid rgba(255,255,255,0.2)',
boxShadow: '0 8px 32px rgba(0,0,0,0.1)',
}}>
<Icon sx={{
fontSize: 32,
filter: 'drop-shadow(0 2px 4px rgba(0,0,0,0.1))',
}} />
</Box>
</Box>
</CardContent>
</Card>
);
};
// Recent Documents Component
const RecentDocuments: React.FC<RecentDocumentsProps> = ({ documents = [] }) => {
const navigate = useNavigate();
const theme = useTheme();
// Ensure documents is always an array
const safeDocuments = Array.isArray(documents) ? documents : [];
const getFileIcon = (mimeType?: string): React.ComponentType<any> => {
if (mimeType?.includes('pdf')) return PdfIcon;
if (mimeType?.includes('image')) return ImageIcon;
if (mimeType?.includes('text')) return TextIcon;
return FileIcon;
};
const formatFileSize = (bytes?: number): string => {
if (!bytes) return '0 B';
const k = 1024;
const sizes = ['B', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i];
};
const formatDate = (dateString?: string): string => {
if (!dateString) return 'Unknown';
return new Date(dateString).toLocaleDateString('en-US', {
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
});
};
return (
<Card elevation={0} sx={{
background: theme.palette.mode === 'light'
? 'linear-gradient(180deg, rgba(255,255,255,0.95) 0%, rgba(248,250,252,0.95) 100%)'
: 'linear-gradient(180deg, rgba(40,40,40,0.95) 0%, rgba(25,25,25,0.95) 100%)',
backdropFilter: 'blur(20px)',
border: theme.palette.mode === 'light'
? '1px solid rgba(226,232,240,0.5)'
: '1px solid rgba(255,255,255,0.1)',
borderRadius: 3,
}}>
<CardContent sx={{ p: 3 }}>
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 3 }}>
<Typography variant="h6" sx={{
fontWeight: 700,
letterSpacing: '-0.025em',
background: theme.palette.mode === 'light'
? 'linear-gradient(135deg, #1e293b 0%, #6366f1 100%)'
: 'linear-gradient(135deg, #f8fafc 0%, #a855f7 100%)',
backgroundClip: 'text',
WebkitBackgroundClip: 'text',
WebkitTextFillColor: 'transparent',
}}>
Recent Documents
</Typography>
<Chip
label="View All"
onClick={() => navigate('/documents')}
sx={{
cursor: 'pointer',
background: 'linear-gradient(135deg, rgba(99,102,241,0.1) 0%, rgba(139,92,246,0.1) 100%)',
border: '1px solid rgba(99,102,241,0.3)',
fontWeight: 600,
transition: 'all 0.2s ease-in-out',
'&:hover': {
background: 'linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%)',
color: 'white',
transform: 'translateY(-2px)',
boxShadow: '0 8px 24px rgba(99,102,241,0.2)',
},
}}
/>
</Box>
{safeDocuments.length === 0 ? (
<Box sx={{ textAlign: 'center', py: 4 }}>
<Box sx={{
width: 64,
height: 64,
borderRadius: 3,
background: 'linear-gradient(135deg, rgba(99,102,241,0.1) 0%, rgba(139,92,246,0.1) 100%)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
mx: 'auto',
mb: 2,
}}>
<DocumentIcon sx={{ fontSize: 32, color: '#6366f1' }} />
</Box>
<Typography variant="body1" sx={{
color: 'text.secondary',
fontWeight: 500,
mb: 1,
}}>
No documents yet
</Typography>
<Typography variant="body2" color="text.secondary">
Upload your first document to get started
</Typography>
</Box>
) : (
<List sx={{ p: 0 }}>
{safeDocuments.slice(0, 5).map((doc, index) => {
const FileIconComponent = getFileIcon(doc.mime_type);
return (
<ListItem
key={doc.id || index}
sx={{
px: 0,
py: 1.5,
borderBottom: index < Math.min(safeDocuments.length, 5) - 1 ? 1 : 0,
borderColor: 'divider',
}}
>
<ListItemAvatar>
<Avatar
sx={{
bgcolor: 'primary.main',
color: 'primary.contrastText',
}}
>
<FileIconComponent />
</Avatar>
</ListItemAvatar>
<ListItemText
sx={{
pr: 8, // Add padding-right to prevent overlap with secondary action
}}
primary={
<Typography
variant="subtitle2"
sx={{
fontWeight: 500,
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
maxWidth: '100%',
}}
>
{doc.original_filename || doc.filename || 'Unknown Document'}
</Typography>
}
secondary={
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mt: 0.5 }}>
<Typography variant="caption" color="text.secondary">
{formatFileSize(doc.file_size)}
</Typography>
<Typography variant="caption" color="text.secondary">
</Typography>
<Typography variant="caption" color="text.secondary">
{formatDate(doc.created_at)}
</Typography>
</Box>
}
/>
<ListItemSecondaryAction>
<Box sx={{ display: 'flex', gap: 0.5 }}>
<IconButton size="small" onClick={() => navigate(`/documents/${doc.id}`)}>
<ViewIcon fontSize="small" />
</IconButton>
<IconButton
size="small"
onClick={() => {
const downloadUrl = `/api/documents/${doc.id}/download`;
window.open(downloadUrl, '_blank');
}}
>
<DownloadIcon fontSize="small" />
</IconButton>
</Box>
</ListItemSecondaryAction>
</ListItem>
);
})}
</List>
)}
</CardContent>
</Card>
);
};
// Quick Actions Component
const QuickActions: React.FC = () => {
const navigate = useNavigate();
const theme = useTheme();
const actions: QuickAction[] = [
{
title: 'Upload Documents',
description: 'Add new files for OCR processing',
icon: UploadIcon,
color: '#6366f1',
path: '/upload',
},
{
title: 'Search Library',
description: 'Find documents by content or metadata',
icon: SearchIcon,
color: '#10b981',
path: '/search',
},
{
title: 'Browse Documents',
description: 'View and manage your document library',
icon: SearchableIcon,
color: '#f59e0b',
path: '/documents',
},
];
return (
<Card elevation={0} sx={{
background: theme.palette.mode === 'light'
? 'linear-gradient(180deg, rgba(255,255,255,0.95) 0%, rgba(248,250,252,0.95) 100%)'
: 'linear-gradient(180deg, rgba(40,40,40,0.95) 0%, rgba(25,25,25,0.95) 100%)',
backdropFilter: 'blur(20px)',
border: theme.palette.mode === 'light'
? '1px solid rgba(226,232,240,0.5)'
: '1px solid rgba(255,255,255,0.1)',
borderRadius: 3,
}}>
<CardContent sx={{ p: 3 }}>
<Typography variant="h6" sx={{
fontWeight: 700,
letterSpacing: '-0.025em',
background: theme.palette.mode === 'light'
? 'linear-gradient(135deg, #1e293b 0%, #6366f1 100%)'
: 'linear-gradient(135deg, #f8fafc 0%, #a855f7 100%)',
backgroundClip: 'text',
WebkitBackgroundClip: 'text',
WebkitTextFillColor: 'transparent',
mb: 3,
}}>
Quick Actions
</Typography>
<Grid container spacing={2}>
{actions.map((action) => (
<Grid item xs={12} key={action.title}>
<Paper
elevation={0}
sx={{
p: 2.5,
cursor: 'pointer',
border: theme.palette.mode === 'light'
? '1px solid rgba(226,232,240,0.5)'
: '1px solid rgba(255,255,255,0.1)',
borderRadius: 3,
background: theme.palette.mode === 'light'
? 'linear-gradient(135deg, rgba(255,255,255,0.8) 0%, rgba(248,250,252,0.6) 100%)'
: 'linear-gradient(135deg, rgba(50,50,50,0.8) 0%, rgba(30,30,30,0.6) 100%)',
backdropFilter: 'blur(10px)',
transition: 'all 0.3s cubic-bezier(0.4, 0, 0.2, 1)',
'&:hover': {
borderColor: action.color,
background: `linear-gradient(135deg, ${alpha(action.color, 0.08)} 0%, ${alpha(action.color, 0.04)} 100%)`,
transform: 'translateY(-4px)',
boxShadow: `0 12px 32px ${alpha(action.color, 0.15)}`,
},
}}
onClick={() => navigate(action.path)}
>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2.5 }}>
<Box sx={{
width: 48,
height: 48,
borderRadius: 3,
background: `linear-gradient(135deg, ${action.color} 0%, ${alpha(action.color, 0.8)} 100%)`,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
color: 'white',
boxShadow: `0 8px 24px ${alpha(action.color, 0.3)}`,
}}>
<action.icon sx={{ fontSize: 24 }} />
</Box>
<Box sx={{ flex: 1 }}>
<Typography variant="subtitle2" sx={{
fontWeight: 700,
letterSpacing: '0.025em',
mb: 0.5,
}}>
{action.title}
</Typography>
<Typography variant="body2" sx={{
color: 'text.secondary',
fontWeight: 500,
fontSize: '0.875rem',
}}>
{action.description}
</Typography>
</Box>
</Box>
</Paper>
</Grid>
))}
</Grid>
</CardContent>
</Card>
);
};
const Dashboard: React.FC = () => {
const theme = useTheme();
const navigate = useNavigate();
const { user } = useAuth();
const [documents, setDocuments] = useState<Document[]>([]);
const [stats, setStats] = useState<DashboardStats>({
totalDocuments: 0,
totalSize: 0,
ocrProcessed: 0,
searchablePages: 0,
});
const [loading, setLoading] = useState<boolean>(true);
const [metrics, setMetrics] = useState<any>(null);
useEffect(() => {
const fetchDashboardData = async (): Promise<void> => {
try {
// Fetch documents with better error handling
let docs: Document[] = [];
try {
const docsResponse = await api.get<Document[]>('/documents');
docs = Array.isArray(docsResponse.data) ? docsResponse.data : [];
} catch (docError) {
console.error('Failed to fetch documents:', docError);
// Continue with empty documents array
}
setDocuments(docs);
// Fetch metrics with better error handling
let metricsData: any = null;
try {
const metricsResponse = await api.get<any>('/metrics');
metricsData = metricsResponse.data;
setMetrics(metricsData);
} catch (metricsError) {
console.error('Failed to fetch metrics:', metricsError);
// Continue with null metrics - will fall back to client calculation
}
// Use backend metrics if available, otherwise fall back to client calculation
if (metricsData?.documents) {
setStats({
totalDocuments: metricsData.documents.total_documents || 0,
totalSize: metricsData.documents.total_storage_bytes || 0,
ocrProcessed: metricsData.documents.documents_with_ocr || 0,
searchablePages: metricsData.documents.documents_with_ocr || 0,
});
} else {
// Fallback to client-side calculation
const totalSize = docs.reduce((sum, doc) => sum + (doc.file_size || 0), 0);
const ocrProcessed = docs.filter(doc => doc.has_ocr_text || doc.ocr_text).length;
setStats({
totalDocuments: docs.length,
totalSize,
ocrProcessed,
searchablePages: docs.length,
});
}
} catch (error) {
console.error('Unexpected error in dashboard data fetch:', error);
// Set default empty state
setDocuments([]);
setStats({
totalDocuments: 0,
totalSize: 0,
ocrProcessed: 0,
searchablePages: 0,
});
} finally {
setLoading(false);
}
};
fetchDashboardData();
}, []);
const formatBytes = (bytes: number): string => {
if (!bytes) return '0 B';
const k = 1024;
const sizes = ['B', 'KB', 'MB', 'GB', 'TB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i];
};
return (
<Box>
{/* Welcome Header */}
<Box sx={{ mb: 4 }}>
<Typography variant="h4" sx={{
fontWeight: 800,
mb: 1,
letterSpacing: '-0.025em',
background: theme.palette.mode === 'light'
? 'linear-gradient(135deg, #1e293b 0%, #6366f1 100%)'
: 'linear-gradient(135deg, #f8fafc 0%, #a855f7 100%)',
backgroundClip: 'text',
WebkitBackgroundClip: 'text',
WebkitTextFillColor: 'transparent',
}}>
Welcome back, {user?.username}! 👋
</Typography>
<Typography variant="h6" sx={{
color: 'text.secondary',
fontWeight: 500,
letterSpacing: '0.025em',
}}>
Here's what's happening with your documents today.
</Typography>
</Box>
{/* Stats Cards */}
<Grid container spacing={3} sx={{ mb: 4 }}>
<Grid item xs={12} sm={6} lg={3}>
<StatsCard
title="Total Documents"
value={loading ? '...' : stats.totalDocuments}
subtitle="Files in your library"
icon={DocumentIcon}
color="#6366f1"
trend="+12% this month"
/>
</Grid>
<Grid item xs={12} sm={6} lg={3}>
<StatsCard
title="Storage Used"
value={loading ? '...' : formatBytes(stats.totalSize)}
subtitle="Total file size"
icon={StorageIcon}
color="#10b981"
trend="+2.4 GB this week"
/>
</Grid>
<Grid item xs={12} sm={6} lg={3}>
<StatsCard
title="OCR Processed"
value={loading ? '...' : stats.ocrProcessed}
subtitle="Text extracted documents"
icon={OcrIcon}
color="#f59e0b"
trend={stats.totalDocuments > 0 ? `${Math.round((stats.ocrProcessed / stats.totalDocuments) * 100)}% completion` : '0% completion'}
/>
</Grid>
<Grid item xs={12} sm={6} lg={3}>
<StatsCard
title="Searchable"
value={loading ? '...' : stats.searchablePages}
subtitle="Ready for search"
icon={SearchableIcon}
color="#8b5cf6"
trend="100% indexed"
/>
</Grid>
</Grid>
{/* Main Content */}
<Grid container spacing={3}>
<Grid item xs={12} lg={8}>
<RecentDocuments documents={documents} />
</Grid>
<Grid item xs={12} lg={4}>
<QuickActions />
</Grid>
</Grid>
{/* Floating Action Button */}
<Fab
color="primary"
sx={{
position: 'fixed',
bottom: 32,
right: 32,
width: 64,
height: 64,
background: 'linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%)',
border: '1px solid rgba(255,255,255,0.2)',
backdropFilter: 'blur(20px)',
boxShadow: '0 16px 40px rgba(99,102,241,0.3)',
transition: 'all 0.3s cubic-bezier(0.4, 0, 0.2, 1)',
'&:hover': {
background: 'linear-gradient(135deg, #4f46e5 0%, #7c3aed 100%)',
transform: 'translateY(-4px) scale(1.05)',
boxShadow: '0 20px 50px rgba(99,102,241,0.4)',
},
'&::before': {
content: '""',
position: 'absolute',
top: 0,
left: 0,
right: 0,
bottom: 0,
borderRadius: '50%',
background: 'linear-gradient(135deg, rgba(255,255,255,0.2) 0%, rgba(255,255,255,0.1) 100%)',
backdropFilter: 'blur(10px)',
},
}}
onClick={() => navigate('/upload')}
>
<AddIcon sx={{
fontSize: 28,
position: 'relative',
zIndex: 1,
filter: 'drop-shadow(0 2px 4px rgba(0,0,0,0.1))',
}} />
</Fab>
</Box>
);
};
export default Dashboard;