feat(client/server): implement notifications
This commit is contained in:
parent
f7874f4541
commit
d128079ce0
|
|
@ -15,12 +15,15 @@
|
|||
"@mui/lab": "^7.0.0-beta.13",
|
||||
"@mui/material": "^7.1.1",
|
||||
"@mui/x-date-pickers": "^8.5.1",
|
||||
"@types/uuid": "^10.0.0",
|
||||
"axios": "^1.3.0",
|
||||
"date-fns": "^4.1.0",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-dropzone": "^14.2.3",
|
||||
"react-hook-form": "^7.43.0",
|
||||
"react-router-dom": "^6.8.0"
|
||||
"react-router-dom": "^6.8.0",
|
||||
"uuid": "^11.1.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@testing-library/jest-dom": "^5.16.5",
|
||||
|
|
@ -1882,6 +1885,12 @@
|
|||
"@types/jest": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/uuid": {
|
||||
"version": "10.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/uuid/-/uuid-10.0.0.tgz",
|
||||
"integrity": "sha512-7gqG38EyHgyP1S+7+xomFtL+ZNHcKv6DwNaCZmJmo1vgMugyF3TCnXVg4t1uk89mLNwnLtnY3TpOpCOyp1/xHQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/yargs": {
|
||||
"version": "17.0.33",
|
||||
"resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.33.tgz",
|
||||
|
|
@ -2647,6 +2656,16 @@
|
|||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/date-fns": {
|
||||
"version": "4.1.0",
|
||||
"resolved": "https://registry.npmjs.org/date-fns/-/date-fns-4.1.0.tgz",
|
||||
"integrity": "sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/kossnocorp"
|
||||
}
|
||||
},
|
||||
"node_modules/debug": {
|
||||
"version": "4.4.1",
|
||||
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.1.tgz",
|
||||
|
|
@ -6010,6 +6029,19 @@
|
|||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/uuid": {
|
||||
"version": "11.1.0",
|
||||
"resolved": "https://registry.npmjs.org/uuid/-/uuid-11.1.0.tgz",
|
||||
"integrity": "sha512-0/A9rDy9P7cJ+8w1c9WD9V//9Wj15Ce2MPz8Ri6032usz+NfePxx5AcN3bN+r6ZL6jEo066/yNYB3tn4pQEx+A==",
|
||||
"funding": [
|
||||
"https://github.com/sponsors/broofa",
|
||||
"https://github.com/sponsors/ctavan"
|
||||
],
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"uuid": "dist/esm/bin/uuid"
|
||||
}
|
||||
},
|
||||
"node_modules/vite": {
|
||||
"version": "4.5.14",
|
||||
"resolved": "https://registry.npmjs.org/vite/-/vite-4.5.14.tgz",
|
||||
|
|
|
|||
|
|
@ -18,12 +18,15 @@
|
|||
"@mui/lab": "^7.0.0-beta.13",
|
||||
"@mui/material": "^7.1.1",
|
||||
"@mui/x-date-pickers": "^8.5.1",
|
||||
"@types/uuid": "^10.0.0",
|
||||
"axios": "^1.3.0",
|
||||
"date-fns": "^4.1.0",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-dropzone": "^14.2.3",
|
||||
"react-hook-form": "^7.43.0",
|
||||
"react-router-dom": "^6.8.0"
|
||||
"react-router-dom": "^6.8.0",
|
||||
"uuid": "^11.1.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@testing-library/jest-dom": "^5.16.5",
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import { Routes, Route, Navigate } from 'react-router-dom';
|
|||
import { CssBaseline } from '@mui/material';
|
||||
import { useAuth } from './contexts/AuthContext';
|
||||
import { ThemeProvider } from './contexts/ThemeContext';
|
||||
import { NotificationProvider } from './contexts/NotificationContext';
|
||||
import Login from './components/Auth/Login';
|
||||
import AppLayout from './components/Layout/AppLayout';
|
||||
import Dashboard from './components/Dashboard/Dashboard';
|
||||
|
|
@ -55,19 +56,21 @@ function App(): JSX.Element {
|
|||
path="/*"
|
||||
element={
|
||||
user ? (
|
||||
<AppLayout>
|
||||
<Routes>
|
||||
<Route path="/" element={<Navigate to="/dashboard" />} />
|
||||
<Route path="/dashboard" element={<Dashboard />} />
|
||||
<Route path="/upload" element={<UploadPage />} />
|
||||
<Route path="/documents" element={<DocumentsPage />} />
|
||||
<Route path="/documents/:id" element={<DocumentDetailsPage />} />
|
||||
<Route path="/search" element={<SearchPage />} />
|
||||
<Route path="/watch" element={<WatchFolderPage />} />
|
||||
<Route path="/settings" element={<SettingsPage />} />
|
||||
<Route path="/profile" element={<div>Profile Page - Coming Soon</div>} />
|
||||
</Routes>
|
||||
</AppLayout>
|
||||
<NotificationProvider>
|
||||
<AppLayout>
|
||||
<Routes>
|
||||
<Route path="/" element={<Navigate to="/dashboard" />} />
|
||||
<Route path="/dashboard" element={<Dashboard />} />
|
||||
<Route path="/upload" element={<UploadPage />} />
|
||||
<Route path="/documents" element={<DocumentsPage />} />
|
||||
<Route path="/documents/:id" element={<DocumentDetailsPage />} />
|
||||
<Route path="/search" element={<SearchPage />} />
|
||||
<Route path="/watch" element={<WatchFolderPage />} />
|
||||
<Route path="/settings" element={<SettingsPage />} />
|
||||
<Route path="/profile" element={<div>Profile Page - Coming Soon</div>} />
|
||||
</Routes>
|
||||
</AppLayout>
|
||||
</NotificationProvider>
|
||||
) : (
|
||||
<Navigate to="/login" />
|
||||
)
|
||||
|
|
|
|||
|
|
@ -39,6 +39,7 @@ import {
|
|||
import { useNavigate } from 'react-router-dom';
|
||||
import { useAuth } from '../../contexts/AuthContext';
|
||||
import api from '../../services/api';
|
||||
import TestNotification from '../TestNotification';
|
||||
|
||||
interface Document {
|
||||
id: string;
|
||||
|
|
@ -563,6 +564,9 @@ const Dashboard: React.FC = () => {
|
|||
</Typography>
|
||||
</Box>
|
||||
|
||||
{/* Test Notifications */}
|
||||
<TestNotification />
|
||||
|
||||
{/* Stats Cards */}
|
||||
<Grid container spacing={3} sx={{ mb: 4 }}>
|
||||
<Grid item xs={12} sm={6} lg={3}>
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import React, { useCallback, useState } from 'react'
|
|||
import { useDropzone } from 'react-dropzone'
|
||||
import { DocumentArrowUpIcon } from '@heroicons/react/24/outline'
|
||||
import { Document, documentService } from '../services/api'
|
||||
import { useNotifications } from '../contexts/NotificationContext'
|
||||
|
||||
interface FileUploadProps {
|
||||
onUploadSuccess: (document: Document) => void
|
||||
|
|
@ -10,6 +11,7 @@ interface FileUploadProps {
|
|||
function FileUpload({ onUploadSuccess }: FileUploadProps) {
|
||||
const [uploading, setUploading] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const { addBatchNotification } = useNotifications()
|
||||
|
||||
const onDrop = useCallback(async (acceptedFiles: File[]) => {
|
||||
const file = acceptedFiles[0]
|
||||
|
|
@ -21,12 +23,18 @@ function FileUpload({ onUploadSuccess }: FileUploadProps) {
|
|||
try {
|
||||
const response = await documentService.upload(file)
|
||||
onUploadSuccess(response.data)
|
||||
|
||||
// Trigger success notification
|
||||
addBatchNotification('success', 'upload', [{ name: file.name, success: true }])
|
||||
} catch (err: any) {
|
||||
setError(err.response?.data?.message || 'Upload failed')
|
||||
|
||||
// Trigger error notification
|
||||
addBatchNotification('error', 'upload', [{ name: file.name, success: false }])
|
||||
} finally {
|
||||
setUploading(false)
|
||||
}
|
||||
}, [onUploadSuccess])
|
||||
}, [onUploadSuccess, addBatchNotification])
|
||||
|
||||
const { getRootProps, getInputProps, isDragActive } = useDropzone({
|
||||
onDrop,
|
||||
|
|
|
|||
|
|
@ -34,8 +34,10 @@ import {
|
|||
} from '@mui/icons-material';
|
||||
import { useNavigate, useLocation } from 'react-router-dom';
|
||||
import { useAuth } from '../../contexts/AuthContext';
|
||||
import { useNotifications } from '../../contexts/NotificationContext';
|
||||
import GlobalSearchBar from '../GlobalSearchBar';
|
||||
import ThemeToggle from '../ThemeToggle/ThemeToggle';
|
||||
import NotificationPanel from '../Notifications/NotificationPanel';
|
||||
|
||||
const drawerWidth = 280;
|
||||
|
||||
|
|
@ -67,9 +69,11 @@ const AppLayout: React.FC<AppLayoutProps> = ({ children }) => {
|
|||
const isMobile = useMediaQuery(theme.breakpoints.down('md'));
|
||||
const [mobileOpen, setMobileOpen] = useState<boolean>(false);
|
||||
const [anchorEl, setAnchorEl] = useState<null | HTMLElement>(null);
|
||||
const [notificationAnchorEl, setNotificationAnchorEl] = useState<null | HTMLElement>(null);
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const { user, logout } = useAuth();
|
||||
const { unreadCount } = useNotifications();
|
||||
|
||||
const handleDrawerToggle = (): void => {
|
||||
setMobileOpen(!mobileOpen);
|
||||
|
|
@ -89,6 +93,14 @@ const AppLayout: React.FC<AppLayoutProps> = ({ children }) => {
|
|||
navigate('/login');
|
||||
};
|
||||
|
||||
const handleNotificationClick = (event: React.MouseEvent<HTMLElement>): void => {
|
||||
setNotificationAnchorEl(event.currentTarget);
|
||||
};
|
||||
|
||||
const handleNotificationClose = (): void => {
|
||||
setNotificationAnchorEl(null);
|
||||
};
|
||||
|
||||
const drawer = (
|
||||
<Box sx={{
|
||||
height: '100%',
|
||||
|
|
@ -368,6 +380,7 @@ const AppLayout: React.FC<AppLayoutProps> = ({ children }) => {
|
|||
|
||||
{/* Notifications */}
|
||||
<IconButton
|
||||
onClick={handleNotificationClick}
|
||||
sx={{
|
||||
mr: 2,
|
||||
color: 'text.secondary',
|
||||
|
|
@ -390,7 +403,7 @@ const AppLayout: React.FC<AppLayoutProps> = ({ children }) => {
|
|||
}}
|
||||
>
|
||||
<Badge
|
||||
badgeContent={3}
|
||||
badgeContent={unreadCount}
|
||||
sx={{
|
||||
'& .MuiBadge-badge': {
|
||||
background: 'linear-gradient(135deg, #ef4444 0%, #f97316 100%)',
|
||||
|
|
@ -545,6 +558,12 @@ const AppLayout: React.FC<AppLayoutProps> = ({ children }) => {
|
|||
{children}
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{/* Notification Panel */}
|
||||
<NotificationPanel
|
||||
anchorEl={notificationAnchorEl}
|
||||
onClose={handleNotificationClose}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -0,0 +1,210 @@
|
|||
import React from 'react';
|
||||
import {
|
||||
Box,
|
||||
Paper,
|
||||
Typography,
|
||||
IconButton,
|
||||
List,
|
||||
ListItem,
|
||||
ListItemText,
|
||||
ListItemIcon,
|
||||
Divider,
|
||||
Button,
|
||||
Chip,
|
||||
Stack,
|
||||
useTheme,
|
||||
} from '@mui/material';
|
||||
import {
|
||||
CheckCircle as SuccessIcon,
|
||||
Error as ErrorIcon,
|
||||
Info as InfoIcon,
|
||||
Warning as WarningIcon,
|
||||
Close as CloseIcon,
|
||||
Delete as DeleteIcon,
|
||||
DoneAll as DoneAllIcon,
|
||||
} from '@mui/icons-material';
|
||||
import { useNotifications } from '../../contexts/NotificationContext';
|
||||
import { NotificationType } from '../../types/notification';
|
||||
import { formatDistanceToNow } from 'date-fns';
|
||||
|
||||
interface NotificationPanelProps {
|
||||
anchorEl: HTMLElement | null;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
const NotificationPanel: React.FC<NotificationPanelProps> = ({ anchorEl, onClose }) => {
|
||||
const theme = useTheme();
|
||||
const { notifications, unreadCount, markAsRead, markAllAsRead, clearNotification, clearAll } = useNotifications();
|
||||
|
||||
const getIcon = (type: NotificationType) => {
|
||||
switch (type) {
|
||||
case 'success':
|
||||
return <SuccessIcon sx={{ color: theme.palette.success.main }} />;
|
||||
case 'error':
|
||||
return <ErrorIcon sx={{ color: theme.palette.error.main }} />;
|
||||
case 'warning':
|
||||
return <WarningIcon sx={{ color: theme.palette.warning.main }} />;
|
||||
case 'info':
|
||||
default:
|
||||
return <InfoIcon sx={{ color: theme.palette.info.main }} />;
|
||||
}
|
||||
};
|
||||
|
||||
if (!anchorEl) return null;
|
||||
|
||||
const rect = anchorEl.getBoundingClientRect();
|
||||
|
||||
return (
|
||||
<Paper
|
||||
elevation={8}
|
||||
sx={{
|
||||
position: 'fixed',
|
||||
top: rect.bottom + 8,
|
||||
right: 16,
|
||||
width: 400,
|
||||
maxHeight: '70vh',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
borderRadius: 3,
|
||||
overflow: 'hidden',
|
||||
background: theme.palette.mode === 'light'
|
||||
? 'linear-gradient(135deg, rgba(255,255,255,0.95) 0%, rgba(248,250,252,0.95) 100%)'
|
||||
: 'linear-gradient(135deg, rgba(30,30,30,0.95) 0%, rgba(20,20,20,0.95) 100%)',
|
||||
backdropFilter: 'blur(20px)',
|
||||
border: theme.palette.mode === 'light'
|
||||
? '1px solid rgba(0,0,0,0.05)'
|
||||
: '1px solid rgba(255,255,255,0.05)',
|
||||
}}
|
||||
>
|
||||
{/* Header */}
|
||||
<Box
|
||||
sx={{
|
||||
p: 2,
|
||||
borderBottom: `1px solid ${theme.palette.divider}`,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
}}
|
||||
>
|
||||
<Stack direction="row" spacing={2} alignItems="center">
|
||||
<Typography variant="h6" fontWeight={600}>
|
||||
Notifications
|
||||
</Typography>
|
||||
{unreadCount > 0 && (
|
||||
<Chip
|
||||
label={unreadCount}
|
||||
size="small"
|
||||
sx={{
|
||||
background: 'linear-gradient(135deg, #ef4444 0%, #f97316 100%)',
|
||||
color: 'white',
|
||||
fontWeight: 600,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Stack>
|
||||
<Stack direction="row" spacing={0.5}>
|
||||
{notifications.length > 0 && (
|
||||
<>
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={markAllAsRead}
|
||||
title="Mark all as read"
|
||||
sx={{ opacity: 0.7, '&:hover': { opacity: 1 } }}
|
||||
>
|
||||
<DoneAllIcon fontSize="small" />
|
||||
</IconButton>
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={clearAll}
|
||||
title="Clear all"
|
||||
sx={{ opacity: 0.7, '&:hover': { opacity: 1 } }}
|
||||
>
|
||||
<DeleteIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</>
|
||||
)}
|
||||
<IconButton size="small" onClick={onClose}>
|
||||
<CloseIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</Stack>
|
||||
</Box>
|
||||
|
||||
{/* Notifications List */}
|
||||
<Box sx={{ flex: 1, overflow: 'auto' }}>
|
||||
{notifications.length === 0 ? (
|
||||
<Box
|
||||
sx={{
|
||||
p: 4,
|
||||
textAlign: 'center',
|
||||
color: 'text.secondary',
|
||||
}}
|
||||
>
|
||||
<Typography variant="body2">No notifications</Typography>
|
||||
</Box>
|
||||
) : (
|
||||
<List sx={{ p: 0 }}>
|
||||
{notifications.map((notification, index) => (
|
||||
<React.Fragment key={notification.id}>
|
||||
<ListItem
|
||||
sx={{
|
||||
py: 1.5,
|
||||
px: 2,
|
||||
background: !notification.read
|
||||
? theme.palette.mode === 'light'
|
||||
? 'rgba(99,102,241,0.05)'
|
||||
: 'rgba(99,102,241,0.1)'
|
||||
: 'transparent',
|
||||
'&:hover': {
|
||||
background: theme.palette.mode === 'light'
|
||||
? 'rgba(0,0,0,0.02)'
|
||||
: 'rgba(255,255,255,0.02)',
|
||||
},
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
onClick={() => markAsRead(notification.id)}
|
||||
secondaryAction={
|
||||
<IconButton
|
||||
edge="end"
|
||||
size="small"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
clearNotification(notification.id);
|
||||
}}
|
||||
sx={{ opacity: 0.5, '&:hover': { opacity: 1 } }}
|
||||
>
|
||||
<CloseIcon fontSize="small" />
|
||||
</IconButton>
|
||||
}
|
||||
>
|
||||
<ListItemIcon sx={{ minWidth: 40 }}>
|
||||
{getIcon(notification.type)}
|
||||
</ListItemIcon>
|
||||
<ListItemText
|
||||
primary={
|
||||
<Typography variant="body2" fontWeight={!notification.read ? 600 : 400}>
|
||||
{notification.title}
|
||||
</Typography>
|
||||
}
|
||||
secondary={
|
||||
<Box>
|
||||
<Typography variant="body2" color="text.secondary" sx={{ mb: 0.5 }}>
|
||||
{notification.message}
|
||||
</Typography>
|
||||
<Typography variant="caption" color="text.disabled">
|
||||
{formatDistanceToNow(notification.timestamp, { addSuffix: true })}
|
||||
</Typography>
|
||||
</Box>
|
||||
}
|
||||
/>
|
||||
</ListItem>
|
||||
{index < notifications.length - 1 && <Divider component="li" />}
|
||||
</React.Fragment>
|
||||
))}
|
||||
</List>
|
||||
)}
|
||||
</Box>
|
||||
</Paper>
|
||||
);
|
||||
};
|
||||
|
||||
export default NotificationPanel;
|
||||
|
|
@ -0,0 +1,58 @@
|
|||
import React from 'react';
|
||||
import { Button, Stack } from '@mui/material';
|
||||
import { useNotifications } from '../contexts/NotificationContext';
|
||||
|
||||
const TestNotification: React.FC = () => {
|
||||
const { addNotification, addBatchNotification } = useNotifications();
|
||||
|
||||
const handleTestSingle = () => {
|
||||
addNotification({
|
||||
type: 'success',
|
||||
title: 'Test Success',
|
||||
message: 'This is a test notification!',
|
||||
});
|
||||
};
|
||||
|
||||
const handleTestError = () => {
|
||||
addNotification({
|
||||
type: 'error',
|
||||
title: 'Test Error',
|
||||
message: 'This is a test error notification!',
|
||||
});
|
||||
};
|
||||
|
||||
const handleTestBatch = () => {
|
||||
addBatchNotification('success', 'upload', [
|
||||
{ name: 'document1.pdf', success: true },
|
||||
{ name: 'document2.pdf', success: true },
|
||||
{ name: 'document3.pdf', success: true },
|
||||
]);
|
||||
};
|
||||
|
||||
const handleTestMixedBatch = () => {
|
||||
addBatchNotification('warning', 'upload', [
|
||||
{ name: 'document1.pdf', success: true },
|
||||
{ name: 'document2.pdf', success: false },
|
||||
{ name: 'document3.pdf', success: true },
|
||||
]);
|
||||
};
|
||||
|
||||
return (
|
||||
<Stack direction="row" spacing={2} sx={{ m: 2 }}>
|
||||
<Button variant="outlined" onClick={handleTestSingle}>
|
||||
Test Single Success
|
||||
</Button>
|
||||
<Button variant="outlined" color="error" onClick={handleTestError}>
|
||||
Test Error
|
||||
</Button>
|
||||
<Button variant="outlined" color="success" onClick={handleTestBatch}>
|
||||
Test Batch Success
|
||||
</Button>
|
||||
<Button variant="outlined" color="warning" onClick={handleTestMixedBatch}>
|
||||
Test Mixed Batch
|
||||
</Button>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
export default TestNotification;
|
||||
|
|
@ -28,6 +28,7 @@ import {
|
|||
} from '@mui/icons-material';
|
||||
import { useDropzone, FileRejection, DropzoneOptions } from 'react-dropzone';
|
||||
import api from '../../services/api';
|
||||
import { useNotifications } from '../../contexts/NotificationContext';
|
||||
|
||||
interface UploadedDocument {
|
||||
id: string;
|
||||
|
|
@ -54,6 +55,7 @@ type FileStatus = 'pending' | 'uploading' | 'success' | 'error';
|
|||
|
||||
const UploadZone: React.FC<UploadZoneProps> = ({ onUploadComplete }) => {
|
||||
const theme = useTheme();
|
||||
const { addBatchNotification } = useNotifications();
|
||||
const [files, setFiles] = useState<FileItem[]>([]);
|
||||
const [uploading, setUploading] = useState<boolean>(false);
|
||||
const [error, setError] = useState<string>('');
|
||||
|
|
@ -155,11 +157,31 @@ const UploadZone: React.FC<UploadZoneProps> = ({ onUploadComplete }) => {
|
|||
setError('');
|
||||
|
||||
const pendingFiles = files.filter(f => f.status === 'pending' || f.status === 'error');
|
||||
const results: { name: string; success: boolean }[] = [];
|
||||
|
||||
try {
|
||||
await Promise.all(pendingFiles.map(uploadFile));
|
||||
await Promise.allSettled(pendingFiles.map(async (file) => {
|
||||
try {
|
||||
await uploadFile(file);
|
||||
results.push({ name: file.file.name, success: true });
|
||||
} catch (error) {
|
||||
results.push({ name: file.file.name, success: false });
|
||||
}
|
||||
}));
|
||||
|
||||
// Trigger notification based on results
|
||||
const hasFailures = results.some(r => !r.success);
|
||||
const hasSuccesses = results.some(r => r.success);
|
||||
|
||||
if (!hasFailures) {
|
||||
addBatchNotification('success', 'upload', results);
|
||||
} else if (!hasSuccesses) {
|
||||
addBatchNotification('error', 'upload', results);
|
||||
} else {
|
||||
addBatchNotification('warning', 'upload', results);
|
||||
}
|
||||
} catch (error) {
|
||||
setError('Some uploads failed. Please try again.');
|
||||
setError('Upload failed. Please try again.');
|
||||
} finally {
|
||||
setUploading(false);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,202 @@
|
|||
import React, { createContext, useContext, useState, useCallback, useEffect, useRef } from 'react';
|
||||
import { Notification, NotificationType, NotificationBatch } from '../types/notification';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
|
||||
interface NotificationContextType {
|
||||
notifications: Notification[];
|
||||
unreadCount: number;
|
||||
addNotification: (notification: Omit<Notification, 'id' | 'timestamp' | 'read'>) => void;
|
||||
markAsRead: (id: string) => void;
|
||||
markAllAsRead: () => void;
|
||||
clearNotification: (id: string) => void;
|
||||
clearAll: () => void;
|
||||
addBatchNotification: (
|
||||
type: NotificationType,
|
||||
operation: 'upload' | 'ocr' | 'watch',
|
||||
files: Array<{ name: string; success: boolean }>
|
||||
) => void;
|
||||
}
|
||||
|
||||
const NotificationContext = createContext<NotificationContextType | undefined>(undefined);
|
||||
|
||||
const BATCH_WINDOW_MS = 2000; // 2 seconds to batch notifications
|
||||
const MAX_NOTIFICATIONS = 50;
|
||||
|
||||
export const NotificationProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
|
||||
const [notifications, setNotifications] = useState<Notification[]>([]);
|
||||
const batchesRef = useRef<Map<string, NotificationBatch>>(new Map());
|
||||
const batchTimersRef = useRef<Map<string, NodeJS.Timeout>>(new Map());
|
||||
|
||||
const unreadCount = notifications.filter(n => !n.read).length;
|
||||
|
||||
const addNotification = useCallback((notification: Omit<Notification, 'id' | 'timestamp' | 'read'>) => {
|
||||
const newNotification: Notification = {
|
||||
...notification,
|
||||
id: uuidv4(),
|
||||
timestamp: new Date(),
|
||||
read: false,
|
||||
};
|
||||
|
||||
setNotifications(prev => {
|
||||
const updated = [newNotification, ...prev];
|
||||
// Keep only the most recent notifications
|
||||
return updated.slice(0, MAX_NOTIFICATIONS);
|
||||
});
|
||||
}, []);
|
||||
|
||||
const addBatchNotification = useCallback((
|
||||
type: NotificationType,
|
||||
operation: 'upload' | 'ocr' | 'watch',
|
||||
files: Array<{ name: string; success: boolean }>
|
||||
) => {
|
||||
const batchKey = `${operation}-${type}`;
|
||||
const existingBatch = batchesRef.current.get(batchKey);
|
||||
|
||||
if (existingBatch) {
|
||||
// Update existing batch
|
||||
existingBatch.count += files.length;
|
||||
existingBatch.successCount += files.filter(f => f.success).length;
|
||||
existingBatch.failureCount += files.filter(f => !f.success).length;
|
||||
} else {
|
||||
// Create new batch
|
||||
const batch: NotificationBatch = {
|
||||
batchId: uuidv4(),
|
||||
type,
|
||||
operation,
|
||||
count: files.length,
|
||||
successCount: files.filter(f => f.success).length,
|
||||
failureCount: files.filter(f => !f.success).length,
|
||||
startTime: new Date(),
|
||||
};
|
||||
batchesRef.current.set(batchKey, batch);
|
||||
}
|
||||
|
||||
// Clear existing timer
|
||||
const existingTimer = batchTimersRef.current.get(batchKey);
|
||||
if (existingTimer) {
|
||||
clearTimeout(existingTimer);
|
||||
}
|
||||
|
||||
// Set new timer to finalize batch
|
||||
const timer = setTimeout(() => {
|
||||
const batch = batchesRef.current.get(batchKey);
|
||||
if (batch) {
|
||||
batch.endTime = new Date();
|
||||
|
||||
// Create notification based on batch
|
||||
let title = '';
|
||||
let message = '';
|
||||
|
||||
if (batch.count === 1) {
|
||||
// Single file - show specific notification
|
||||
const fileName = files[0]?.name || 'file';
|
||||
if (operation === 'upload') {
|
||||
title = batch.successCount > 0 ? 'File Uploaded' : 'Upload Failed';
|
||||
message = batch.successCount > 0
|
||||
? `${fileName} uploaded successfully`
|
||||
: `Failed to upload ${fileName}`;
|
||||
} else if (operation === 'ocr') {
|
||||
title = batch.successCount > 0 ? 'OCR Complete' : 'OCR Failed';
|
||||
message = batch.successCount > 0
|
||||
? `Text extracted from ${fileName}`
|
||||
: `Failed to extract text from ${fileName}`;
|
||||
} else if (operation === 'watch') {
|
||||
title = 'File Detected';
|
||||
message = `${fileName} added from watch folder`;
|
||||
}
|
||||
} else {
|
||||
// Multiple files - show batch notification
|
||||
if (operation === 'upload') {
|
||||
title = 'Batch Upload Complete';
|
||||
if (batch.failureCount === 0) {
|
||||
message = `${batch.successCount} files uploaded successfully`;
|
||||
} else if (batch.successCount === 0) {
|
||||
message = `Failed to upload ${batch.failureCount} files`;
|
||||
} else {
|
||||
message = `${batch.successCount} files uploaded, ${batch.failureCount} failed`;
|
||||
}
|
||||
} else if (operation === 'ocr') {
|
||||
title = 'Batch OCR Complete';
|
||||
if (batch.failureCount === 0) {
|
||||
message = `Text extracted from ${batch.successCount} documents`;
|
||||
} else if (batch.successCount === 0) {
|
||||
message = `Failed to process ${batch.failureCount} documents`;
|
||||
} else {
|
||||
message = `${batch.successCount} documents processed, ${batch.failureCount} failed`;
|
||||
}
|
||||
} else if (operation === 'watch') {
|
||||
title = 'Files Detected';
|
||||
message = `${batch.count} files added from watch folder`;
|
||||
}
|
||||
}
|
||||
|
||||
addNotification({
|
||||
type: batch.failureCount > 0 && batch.successCount === 0 ? 'error' :
|
||||
batch.failureCount > 0 ? 'warning' : 'success',
|
||||
title,
|
||||
message,
|
||||
metadata: {
|
||||
batchId: batch.batchId,
|
||||
fileCount: batch.count,
|
||||
},
|
||||
});
|
||||
|
||||
// Clean up
|
||||
batchesRef.current.delete(batchKey);
|
||||
batchTimersRef.current.delete(batchKey);
|
||||
}
|
||||
}, BATCH_WINDOW_MS);
|
||||
|
||||
batchTimersRef.current.set(batchKey, timer);
|
||||
}, [addNotification]);
|
||||
|
||||
const markAsRead = useCallback((id: string) => {
|
||||
setNotifications(prev =>
|
||||
prev.map(n => n.id === id ? { ...n, read: true } : n)
|
||||
);
|
||||
}, []);
|
||||
|
||||
const markAllAsRead = useCallback(() => {
|
||||
setNotifications(prev => prev.map(n => ({ ...n, read: true })));
|
||||
}, []);
|
||||
|
||||
const clearNotification = useCallback((id: string) => {
|
||||
setNotifications(prev => prev.filter(n => n.id !== id));
|
||||
}, []);
|
||||
|
||||
const clearAll = useCallback(() => {
|
||||
setNotifications([]);
|
||||
}, []);
|
||||
|
||||
// Cleanup timers on unmount
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
batchTimersRef.current.forEach(timer => clearTimeout(timer));
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<NotificationContext.Provider
|
||||
value={{
|
||||
notifications,
|
||||
unreadCount,
|
||||
addNotification,
|
||||
markAsRead,
|
||||
markAllAsRead,
|
||||
clearNotification,
|
||||
clearAll,
|
||||
addBatchNotification,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</NotificationContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export const useNotifications = () => {
|
||||
const context = useContext(NotificationContext);
|
||||
if (!context) {
|
||||
throw new Error('useNotifications must be used within NotificationProvider');
|
||||
}
|
||||
return context;
|
||||
};
|
||||
|
|
@ -0,0 +1,27 @@
|
|||
export type NotificationType = 'success' | 'error' | 'info' | 'warning';
|
||||
|
||||
export interface Notification {
|
||||
id: string;
|
||||
type: NotificationType;
|
||||
title: string;
|
||||
message: string;
|
||||
timestamp: Date;
|
||||
read: boolean;
|
||||
actionUrl?: string;
|
||||
metadata?: {
|
||||
documentId?: number;
|
||||
batchId?: string;
|
||||
fileCount?: number;
|
||||
};
|
||||
}
|
||||
|
||||
export interface NotificationBatch {
|
||||
batchId: string;
|
||||
type: NotificationType;
|
||||
operation: 'upload' | 'ocr' | 'watch';
|
||||
count: number;
|
||||
successCount: number;
|
||||
failureCount: number;
|
||||
startTime: Date;
|
||||
endTime?: Date;
|
||||
}
|
||||
Loading…
Reference in New Issue