diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 3c7043d..5fc95be 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,6 +1,7 @@ import React, { useState, useEffect, useCallback } from 'react'; import { BrowserRouter as Router, Routes, Route, Navigate } from 'react-router-dom'; import { AuthProvider, useAuth } from './contexts/AuthContext'; +import { ToastProvider, useToast } from './contexts/ToastContext'; import LoginForm from './components/LoginForm'; import ProtectedRoute from './components/ProtectedRoute'; import DocumentUpload from './components/DocumentUpload'; @@ -10,19 +11,26 @@ import Analytics from './components/Analytics'; import AlertBanner from './components/AlertBanner'; import AdminMonitoringDashboard from './components/AdminMonitoringDashboard'; import LogoutButton from './components/LogoutButton'; +import ErrorBoundary from './components/ErrorBoundary'; +import ConfirmModal from './components/ConfirmModal'; +import TabButton from './components/TabButton'; +import { DocumentSkeletonList } from './components/Skeleton'; +import EmptyState from './components/EmptyState'; import { documentService, GCSErrorHandler, GCSError } from './services/documentService'; import { adminService, AlertEvent } from './services/adminService'; // import { debugAuth, testAPIAuth } from './utils/authDebug'; -import { - Home, - Upload, - FileText, - BarChart3, +import { + Home, + Upload, + FileText, + BarChart3, Plus, Search, TrendingUp, - Activity + Activity, + AlertTriangle, + RefreshCw } from 'lucide-react'; import { cn } from './utils/cn'; import bluepointLogo from './assets/bluepoint-logo.png'; @@ -30,11 +38,15 @@ import bluepointLogo from './assets/bluepoint-logo.png'; // Dashboard component const Dashboard: React.FC = () => { const { user, token } = useAuth(); + const toast = useToast(); const [documents, setDocuments] = useState([]); const [loading, setLoading] = useState(false); const [viewingDocument, setViewingDocument] = useState(null); const [searchTerm, setSearchTerm] = useState(''); const [activeTab, setActiveTab] = useState<'overview' | 'documents' | 'upload' | 'analytics' | 'monitoring'>('overview'); + const [prevTab, setPrevTab] = useState('overview'); + const [tabTransition, setTabTransition] = useState(true); + const [confirmModal, setConfirmModal] = useState<{ open: boolean; documentId: string }>({ open: false, documentId: '' }); // Check if user is admin const isAdmin = adminService.isAdmin(user?.email); @@ -47,6 +59,18 @@ const Dashboard: React.FC = () => { } }, [isAdmin]); + // Tab transition effect + useEffect(() => { + if (activeTab !== prevTab) { + setTabTransition(false); + const timer = setTimeout(() => { + setTabTransition(true); + setPrevTab(activeTab); + }, 50); + return () => clearTimeout(timer); + } + }, [activeTab, prevTab]); + const handleAcknowledge = async (id: string) => { setActiveAlerts(prev => prev.filter(a => a.id !== id)); try { @@ -298,32 +322,28 @@ const Dashboard: React.FC = () => { // Handle GCS-specific errors if (GCSErrorHandler.isGCSError(error)) { const gcsError = error as GCSError; - alert(`Download failed: ${GCSErrorHandler.getErrorMessage(gcsError)}`); + toast.error(`Download failed: ${GCSErrorHandler.getErrorMessage(gcsError)}`); } else { - alert('Failed to download document. Please try again.'); + toast.error('Failed to download document. Please try again.'); } } }; const handleDeleteDocument = async (documentId: string) => { - // Show confirmation dialog - const confirmed = window.confirm('Are you sure you want to delete this document? This action cannot be undone.'); - if (!confirmed) { - return; - } + setConfirmModal({ open: true, documentId }); + }; + + const handleConfirmDelete = async () => { + const documentId = confirmModal.documentId; + setConfirmModal({ open: false, documentId: '' }); try { - // Call the backend API to delete the document await documentService.deleteDocument(documentId); - - // Remove from local state setDocuments(prev => prev.filter(doc => doc.id !== documentId)); - - // Show success message - alert('Document deleted successfully'); + toast.success('Document deleted successfully'); } catch (error) { console.error('Failed to delete document:', error); - alert('Failed to delete document. Please try again.'); + toast.error('Failed to delete document. Please try again.'); } }; @@ -456,68 +476,13 @@ const Dashboard: React.FC = () => {
@@ -525,7 +490,7 @@ const Dashboard: React.FC = () => {
{/* Content */} -
+
{activeTab === 'overview' && (
{/* Stats Cards */} @@ -594,7 +559,7 @@ const Dashboard: React.FC = () => {
-
⚠️
+
@@ -660,7 +625,7 @@ const Dashboard: React.FC = () => { disabled={loading} className="inline-flex items-center px-4 py-2 border border-gray-300 text-sm font-medium rounded-md shadow-soft text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500 disabled:opacity-50 transition-colors duration-200" > -
🔄
+ Refresh
+ + setConfirmModal({ open: false, documentId: '' })} + />
); }; @@ -784,21 +767,25 @@ const UnauthorizedPage: React.FC = () => { const App: React.FC = () => { return ( - - - } /> - } /> - - - - } - /> - } /> - - + + + + } /> + } /> + + + + + + } + /> + } /> + + + ); }; diff --git a/frontend/src/components/AlertBanner.tsx b/frontend/src/components/AlertBanner.tsx index 44d6eab..b374eca 100644 --- a/frontend/src/components/AlertBanner.tsx +++ b/frontend/src/components/AlertBanner.tsx @@ -18,7 +18,7 @@ const AlertBanner: React.FC = ({ alerts, onAcknowledge }) => { if (criticalAlerts.length === 0) return null; return ( -
+
{criticalAlerts.map((alert) => (
diff --git a/frontend/src/components/Analytics.tsx b/frontend/src/components/Analytics.tsx index 86f2d1b..fc31528 100644 --- a/frontend/src/components/Analytics.tsx +++ b/frontend/src/components/Analytics.tsx @@ -1,6 +1,8 @@ import React, { useState, useEffect } from 'react'; +import { BarChart3, AlertCircle } from 'lucide-react'; import { documentService } from '../services/documentService'; import { cn } from '../utils/cn'; +import EmptyState from './EmptyState'; interface AnalyticsData { sessionStats: Array<{ @@ -115,23 +117,21 @@ const Analytics: React.FC = () => { if (loading) { return (
-
+
); } if (error) { return ( -
+
- - - +
-

Error loading analytics

-

{error}

+

Error loading analytics

+

{error}

@@ -156,7 +156,7 @@ const Analytics: React.FC = () => { @@ -172,7 +172,7 @@ const Analytics: React.FC = () => {
Overall Status
@@ -278,6 +278,13 @@ const Analytics: React.FC = () => { {analyticsData && (

Session Statistics

+ {(analyticsData.sessionStats ?? []).length === 0 ? ( + } + title="No session data" + description="Session statistics will appear here once documents are processed." + /> + ) : (
@@ -297,8 +304,8 @@ const Analytics: React.FC = () => { {new Date(stat.date).toLocaleDateString()} - - + + @@ -310,6 +317,7 @@ const Analytics: React.FC = () => {
{stat.total_sessions}{stat.successful_sessions}{stat.failed_sessions}{stat.successful_sessions}{stat.failed_sessions} {formatTime(stat.avg_processing_time)}
+ )}
)} @@ -317,6 +325,13 @@ const Analytics: React.FC = () => { {analyticsData && (

Agent Performance

+ {(analyticsData.agentStats ?? []).length === 0 ? ( + } + title="No agent data" + description="Agent performance metrics will appear here after processing runs." + /> + ) : (
{(analyticsData.agentStats ?? []).map((agent, index) => (
@@ -330,7 +345,7 @@ const Analytics: React.FC = () => {
Success Rate: - + {parseInt(agent.total_executions) > 0 ? ((parseInt(agent.successful_executions) / parseInt(agent.total_executions)) * 100).toFixed(1) : 0}% @@ -348,6 +363,7 @@ const Analytics: React.FC = () => {
))}
+ )}
)} @@ -355,6 +371,13 @@ const Analytics: React.FC = () => { {analyticsData && (

Quality Metrics

+ {(analyticsData.qualityStats ?? []).length === 0 ? ( + } + title="No quality data" + description="Quality metrics will appear here after documents are analyzed." + /> + ) : (
{(analyticsData.qualityStats ?? []).map((metric, index) => (
@@ -378,6 +401,7 @@ const Analytics: React.FC = () => {
))}
+ )}
)}
diff --git a/frontend/src/components/ConfirmModal.tsx b/frontend/src/components/ConfirmModal.tsx new file mode 100644 index 0000000..03f70c7 --- /dev/null +++ b/frontend/src/components/ConfirmModal.tsx @@ -0,0 +1,91 @@ +import React from 'react'; +import { AlertTriangle, X } from 'lucide-react'; +import { cn } from '../utils/cn'; + +interface ConfirmModalProps { + open: boolean; + title: string; + message: string; + confirmLabel?: string; + cancelLabel?: string; + variant?: 'danger' | 'warning' | 'default'; + onConfirm: () => void; + onCancel: () => void; +} + +const variantStyles = { + danger: { + icon: 'text-error-500 bg-error-50', + button: 'bg-error-600 hover:bg-error-700 focus:ring-error-500', + }, + warning: { + icon: 'text-warning-500 bg-warning-50', + button: 'bg-warning-600 hover:bg-warning-700 focus:ring-warning-500', + }, + default: { + icon: 'text-primary-500 bg-primary-50', + button: 'bg-primary-600 hover:bg-primary-700 focus:ring-primary-500', + }, +}; + +const ConfirmModal: React.FC = ({ + open, + title, + message, + confirmLabel = 'Confirm', + cancelLabel = 'Cancel', + variant = 'default', + onConfirm, + onCancel, +}) => { + if (!open) return null; + + const styles = variantStyles[variant]; + + return ( +
+ {/* Backdrop */} +
+ {/* Modal */} +
+ +
+
+ +
+
+

{title}

+

{message}

+
+
+
+ + +
+
+
+ ); +}; + +export default ConfirmModal; diff --git a/frontend/src/components/DocumentList.tsx b/frontend/src/components/DocumentList.tsx index 87c17bc..6b01080 100644 --- a/frontend/src/components/DocumentList.tsx +++ b/frontend/src/components/DocumentList.tsx @@ -1,15 +1,16 @@ import React from 'react'; -import { - FileText, - Eye, - Download, - Trash2, - Calendar, - User, +import { + FileText, + Eye, + Download, + Trash2, + Calendar, + User, Clock, CheckCircle, AlertCircle, - PlayCircle + PlayCircle, + RefreshCw } from 'lucide-react'; import { cn } from '../utils/cn'; @@ -85,14 +86,14 @@ const DocumentList: React.FC = ({ const getStatusText = (status: Document['status'], progress?: number, message?: string) => { switch (status) { case 'uploaded': - return 'Uploaded ✓'; + return 'Uploaded'; case 'processing': if (progress !== undefined) { return `Processing... ${progress}%`; } return message || 'Processing...'; case 'completed': - return 'Completed ✓'; + return 'Completed'; case 'error': return 'Error'; case 'extracting_text': @@ -142,11 +143,9 @@ const DocumentList: React.FC = ({ {onRefresh && ( )} @@ -209,13 +208,14 @@ const DocumentList: React.FC = ({ {/* Show a brief status message instead of the full summary */} {document.status === 'completed' && ( -

- ✓ Analysis completed - Click "View" to see detailed CIM review +

+ + Analysis completed - Click "View" to see detailed CIM review

)} {document.error && ( -

+

Error: {document.error}

)} @@ -227,14 +227,14 @@ const DocumentList: React.FC = ({ <> + )} +
+ ); +}; + +export default EmptyState; diff --git a/frontend/src/components/ErrorBoundary.tsx b/frontend/src/components/ErrorBoundary.tsx new file mode 100644 index 0000000..4c5ed85 --- /dev/null +++ b/frontend/src/components/ErrorBoundary.tsx @@ -0,0 +1,69 @@ +import React from 'react'; +import { AlertTriangle, RefreshCw } from 'lucide-react'; + +interface ErrorBoundaryProps { + children: React.ReactNode; +} + +interface ErrorBoundaryState { + hasError: boolean; + error: Error | null; +} + +class ErrorBoundary extends React.Component { + constructor(props: ErrorBoundaryProps) { + super(props); + this.state = { hasError: false, error: null }; + } + + static getDerivedStateFromError(error: Error): ErrorBoundaryState { + return { hasError: true, error }; + } + + componentDidCatch(error: Error, errorInfo: React.ErrorInfo) { + console.error('ErrorBoundary caught an error:', error, errorInfo); + } + + handleRetry = () => { + this.setState({ hasError: false, error: null }); + }; + + render() { + if (this.state.hasError) { + return ( +
+
+
+ +
+

Something went wrong

+

+ An unexpected error occurred. Please try again or refresh the page. +

+ {this.state.error && ( +
+ + Technical details + +
+                  {this.state.error.message}
+                
+
+ )} + +
+
+ ); + } + + return this.props.children; + } +} + +export default ErrorBoundary; diff --git a/frontend/src/components/Skeleton.tsx b/frontend/src/components/Skeleton.tsx new file mode 100644 index 0000000..f740df3 --- /dev/null +++ b/frontend/src/components/Skeleton.tsx @@ -0,0 +1,66 @@ +import React from 'react'; +import { cn } from '../utils/cn'; + +interface SkeletonLineProps { + className?: string; + width?: string; +} + +export const SkeletonLine: React.FC = ({ className, width = 'w-full' }) => { + return ( +
+ ); +}; + +interface SkeletonCardProps { + className?: string; +} + +export const SkeletonCard: React.FC = ({ className }) => { + return ( +
+
+
+
+
+ +
+
+
+ + + +
+
+
+
+
+
+
+
+ ); +}; + +interface DocumentSkeletonListProps { + count?: number; +} + +export const DocumentSkeletonList: React.FC = ({ count = 3 }) => { + return ( +
+
+ {Array.from({ length: count }).map((_, i) => ( +
+ +
+ ))} +
+
+ ); +}; diff --git a/frontend/src/components/TabButton.tsx b/frontend/src/components/TabButton.tsx new file mode 100644 index 0000000..93d4fc0 --- /dev/null +++ b/frontend/src/components/TabButton.tsx @@ -0,0 +1,28 @@ +import React from 'react'; +import { cn } from '../utils/cn'; + +interface TabButtonProps { + active: boolean; + onClick: () => void; + icon: React.ReactNode; + label: string; +} + +const TabButton: React.FC = ({ active, onClick, icon, label }) => { + return ( + + ); +}; + +export default TabButton; diff --git a/frontend/src/components/Toast.tsx b/frontend/src/components/Toast.tsx new file mode 100644 index 0000000..7d36f52 --- /dev/null +++ b/frontend/src/components/Toast.tsx @@ -0,0 +1,90 @@ +import React, { useEffect, useState } from 'react'; +import { X, CheckCircle, AlertTriangle, AlertCircle, Info } from 'lucide-react'; +import { cn } from '../utils/cn'; + +export type ToastVariant = 'success' | 'error' | 'warning' | 'info'; + +export interface ToastData { + id: string; + message: string; + variant: ToastVariant; +} + +interface ToastItemProps { + toast: ToastData; + onDismiss: (id: string) => void; +} + +const variantStyles: Record = { + success: 'bg-success-50 border-success-500 text-success-600', + error: 'bg-error-50 border-error-500 text-error-600', + warning: 'bg-warning-50 border-warning-500 text-warning-600', + info: 'bg-primary-50 border-primary-500 text-primary-600', +}; + +const variantIcons: Record = { + success: , + error: , + warning: , + info: , +}; + +const ToastItem: React.FC = ({ toast, onDismiss }) => { + const [visible, setVisible] = useState(false); + const [exiting, setExiting] = useState(false); + + useEffect(() => { + // Trigger enter animation + const enterTimer = setTimeout(() => setVisible(true), 10); + // Auto-dismiss after 4 seconds + const dismissTimer = setTimeout(() => { + setExiting(true); + setTimeout(() => onDismiss(toast.id), 300); + }, 4000); + return () => { + clearTimeout(enterTimer); + clearTimeout(dismissTimer); + }; + }, [toast.id, onDismiss]); + + const handleClose = () => { + setExiting(true); + setTimeout(() => onDismiss(toast.id), 300); + }; + + return ( +
+
{variantIcons[toast.variant]}
+

{toast.message}

+ +
+ ); +}; + +interface ToastContainerProps { + toasts: ToastData[]; + onDismiss: (id: string) => void; +} + +const ToastContainer: React.FC = ({ toasts, onDismiss }) => { + return ( +
+ {toasts.map((toast) => ( + + ))} +
+ ); +}; + +export default ToastContainer; diff --git a/frontend/src/contexts/ToastContext.tsx b/frontend/src/contexts/ToastContext.tsx new file mode 100644 index 0000000..0a5858f --- /dev/null +++ b/frontend/src/contexts/ToastContext.tsx @@ -0,0 +1,48 @@ +import React, { createContext, useContext, useState, useCallback } from 'react'; +import ToastContainer, { ToastData, ToastVariant } from '../components/Toast'; + +interface ToastContextValue { + success: (message: string) => void; + error: (message: string) => void; + warning: (message: string) => void; + info: (message: string) => void; +} + +const ToastContext = createContext(null); + +let toastIdCounter = 0; + +export const ToastProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => { + const [toasts, setToasts] = useState([]); + + const dismiss = useCallback((id: string) => { + setToasts((prev) => prev.filter((t) => t.id !== id)); + }, []); + + const addToast = useCallback((message: string, variant: ToastVariant) => { + const id = `toast-${++toastIdCounter}`; + setToasts((prev) => [...prev, { id, message, variant }]); + }, []); + + const value: ToastContextValue = { + success: useCallback((msg: string) => addToast(msg, 'success'), [addToast]), + error: useCallback((msg: string) => addToast(msg, 'error'), [addToast]), + warning: useCallback((msg: string) => addToast(msg, 'warning'), [addToast]), + info: useCallback((msg: string) => addToast(msg, 'info'), [addToast]), + }; + + return ( + + {children} + + + ); +}; + +export function useToast(): ToastContextValue { + const context = useContext(ToastContext); + if (!context) { + throw new Error('useToast must be used within a ToastProvider'); + } + return context; +} diff --git a/frontend/src/index.css b/frontend/src/index.css index def9394..1dcf830 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -6,8 +6,19 @@ html { font-family: 'Inter', system-ui, sans-serif; } - + body { font-family: 'Inter', system-ui, sans-serif; } +} + +@keyframes fadeInScale { + from { + opacity: 0; + transform: scale(0.95); + } + to { + opacity: 1; + transform: scale(1); + } } \ No newline at end of file