diff --git a/frontend/package-lock.json b/frontend/package-lock.json
index 8c6c180..a55ae09 100644
--- a/frontend/package-lock.json
+++ b/frontend/package-lock.json
@@ -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",
diff --git a/frontend/package.json b/frontend/package.json
index 22e3aff..bc58ff4 100644
--- a/frontend/package.json
+++ b/frontend/package.json
@@ -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",
diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx
index f59cceb..9d6de7d 100644
--- a/frontend/src/App.tsx
+++ b/frontend/src/App.tsx
@@ -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 ? (
-
-
- } />
- } />
- } />
- } />
- } />
- } />
- } />
- } />
- Profile Page - Coming Soon} />
-
-
+
+
+
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+ Profile Page - Coming Soon} />
+
+
+
) : (
)
diff --git a/frontend/src/components/Dashboard/Dashboard.tsx b/frontend/src/components/Dashboard/Dashboard.tsx
index 437fdfc..4b5d174 100644
--- a/frontend/src/components/Dashboard/Dashboard.tsx
+++ b/frontend/src/components/Dashboard/Dashboard.tsx
@@ -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 = () => {
+ {/* Test Notifications */}
+
+
{/* Stats Cards */}
diff --git a/frontend/src/components/FileUpload.tsx b/frontend/src/components/FileUpload.tsx
index 45cb3c1..02a80f0 100644
--- a/frontend/src/components/FileUpload.tsx
+++ b/frontend/src/components/FileUpload.tsx
@@ -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(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,
diff --git a/frontend/src/components/Layout/AppLayout.tsx b/frontend/src/components/Layout/AppLayout.tsx
index 464f29a..c39a4a7 100644
--- a/frontend/src/components/Layout/AppLayout.tsx
+++ b/frontend/src/components/Layout/AppLayout.tsx
@@ -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 = ({ children }) => {
const isMobile = useMediaQuery(theme.breakpoints.down('md'));
const [mobileOpen, setMobileOpen] = useState(false);
const [anchorEl, setAnchorEl] = useState(null);
+ const [notificationAnchorEl, setNotificationAnchorEl] = useState(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 = ({ children }) => {
navigate('/login');
};
+ const handleNotificationClick = (event: React.MouseEvent): void => {
+ setNotificationAnchorEl(event.currentTarget);
+ };
+
+ const handleNotificationClose = (): void => {
+ setNotificationAnchorEl(null);
+ };
+
const drawer = (
= ({ children }) => {
{/* Notifications */}
= ({ children }) => {
}}
>
= ({ children }) => {
{children}
+
+ {/* Notification Panel */}
+
);
};
diff --git a/frontend/src/components/Notifications/NotificationPanel.tsx b/frontend/src/components/Notifications/NotificationPanel.tsx
new file mode 100644
index 0000000..5b8958b
--- /dev/null
+++ b/frontend/src/components/Notifications/NotificationPanel.tsx
@@ -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 = ({ anchorEl, onClose }) => {
+ const theme = useTheme();
+ const { notifications, unreadCount, markAsRead, markAllAsRead, clearNotification, clearAll } = useNotifications();
+
+ const getIcon = (type: NotificationType) => {
+ switch (type) {
+ case 'success':
+ return ;
+ case 'error':
+ return ;
+ case 'warning':
+ return ;
+ case 'info':
+ default:
+ return ;
+ }
+ };
+
+ if (!anchorEl) return null;
+
+ const rect = anchorEl.getBoundingClientRect();
+
+ return (
+
+ {/* Header */}
+
+
+
+ Notifications
+
+ {unreadCount > 0 && (
+
+ )}
+
+
+ {notifications.length > 0 && (
+ <>
+
+
+
+
+
+
+ >
+ )}
+
+
+
+
+
+
+ {/* Notifications List */}
+
+ {notifications.length === 0 ? (
+
+ No notifications
+
+ ) : (
+
+ {notifications.map((notification, index) => (
+
+ markAsRead(notification.id)}
+ secondaryAction={
+ {
+ e.stopPropagation();
+ clearNotification(notification.id);
+ }}
+ sx={{ opacity: 0.5, '&:hover': { opacity: 1 } }}
+ >
+
+
+ }
+ >
+
+ {getIcon(notification.type)}
+
+
+ {notification.title}
+
+ }
+ secondary={
+
+
+ {notification.message}
+
+
+ {formatDistanceToNow(notification.timestamp, { addSuffix: true })}
+
+
+ }
+ />
+
+ {index < notifications.length - 1 && }
+
+ ))}
+
+ )}
+
+
+ );
+};
+
+export default NotificationPanel;
\ No newline at end of file
diff --git a/frontend/src/components/TestNotification.tsx b/frontend/src/components/TestNotification.tsx
new file mode 100644
index 0000000..5f41d64
--- /dev/null
+++ b/frontend/src/components/TestNotification.tsx
@@ -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 (
+
+
+
+
+
+
+ );
+};
+
+export default TestNotification;
\ No newline at end of file
diff --git a/frontend/src/components/Upload/UploadZone.tsx b/frontend/src/components/Upload/UploadZone.tsx
index f16beb1..e82b646 100644
--- a/frontend/src/components/Upload/UploadZone.tsx
+++ b/frontend/src/components/Upload/UploadZone.tsx
@@ -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 = ({ onUploadComplete }) => {
const theme = useTheme();
+ const { addBatchNotification } = useNotifications();
const [files, setFiles] = useState([]);
const [uploading, setUploading] = useState(false);
const [error, setError] = useState('');
@@ -155,11 +157,31 @@ const UploadZone: React.FC = ({ 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);
}
diff --git a/frontend/src/contexts/NotificationContext.tsx b/frontend/src/contexts/NotificationContext.tsx
new file mode 100644
index 0000000..f5b41e4
--- /dev/null
+++ b/frontend/src/contexts/NotificationContext.tsx
@@ -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) => 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(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([]);
+ const batchesRef = useRef