From d128079ce0a9d91ec7f53f3e1ba8611d0f079347 Mon Sep 17 00:00:00 2001 From: perf3ct Date: Sat, 14 Jun 2025 00:06:23 +0000 Subject: [PATCH] feat(client/server): implement notifications --- frontend/package-lock.json | 34 ++- frontend/package.json | 5 +- frontend/src/App.tsx | 29 +-- .../src/components/Dashboard/Dashboard.tsx | 4 + frontend/src/components/FileUpload.tsx | 10 +- frontend/src/components/Layout/AppLayout.tsx | 21 +- .../Notifications/NotificationPanel.tsx | 210 ++++++++++++++++++ frontend/src/components/TestNotification.tsx | 58 +++++ frontend/src/components/Upload/UploadZone.tsx | 26 ++- frontend/src/contexts/NotificationContext.tsx | 202 +++++++++++++++++ frontend/src/types/notification.ts | 27 +++ 11 files changed, 607 insertions(+), 19 deletions(-) create mode 100644 frontend/src/components/Notifications/NotificationPanel.tsx create mode 100644 frontend/src/components/TestNotification.tsx create mode 100644 frontend/src/contexts/NotificationContext.tsx create mode 100644 frontend/src/types/notification.ts 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>(new Map()); + const batchTimersRef = useRef>(new Map()); + + const unreadCount = notifications.filter(n => !n.read).length; + + const addNotification = useCallback((notification: Omit) => { + 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 ( + + {children} + + ); +}; + +export const useNotifications = () => { + const context = useContext(NotificationContext); + if (!context) { + throw new Error('useNotifications must be used within NotificationProvider'); + } + return context; +}; \ No newline at end of file diff --git a/frontend/src/types/notification.ts b/frontend/src/types/notification.ts new file mode 100644 index 0000000..98c4ef0 --- /dev/null +++ b/frontend/src/types/notification.ts @@ -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; +} \ No newline at end of file