diff --git a/.env b/.env index 6f6e35a..52d5487 100644 --- a/.env +++ b/.env @@ -1 +1,13 @@ +# ============================================ +# EquoraScale MVP - Environment Variables +# ============================================ + +# ============================================ +# AI Services Configuration +# ============================================ + +# Backend API Base URL +VITE_API_BASE_URL=http://localhost:3000 + +#OpenRouter API Key VITE_API_KEY=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 \ No newline at end of file diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..fdb5565 --- /dev/null +++ b/.env.example @@ -0,0 +1,39 @@ +# ============================================ +# EquoraScale MVP - Environment Variables +# ============================================ +# Copy this file to .env and fill in your actual values +# Never commit your .env file to version control! + +# ============================================ +# AI Services Configuration +# ============================================ + +# OpenRouter API Key +# Used for AI-powered document analysis and Q&A features +# Get your API key from: https://openrouter.ai/keys +# Required for: Document question answering (askDocumentQuestion function) +VITE_API_KEY=your_openrouter_api_key_here + +# Google Gemini API Key (Optional) +# Currently not in use, but reserved for future Gemini AI integration +# Get your API key from: https://aistudio.google.com/app/apikey +# Uncomment when needed: +# VITE_GEMINI_API_KEY=your_gemini_api_key_here + +# ============================================ +# Backend API Configuration +# ============================================ + +# Backend API Base URL +# Default: http://localhost:3000 +# Change this to your production backend URL when deploying +# Example: https://api.equorascale.com +VITE_API_BASE_URL=http://localhost:3000 + +# ============================================ +# Notes +# ============================================ +# - All VITE_ prefixed variables are exposed to the client-side code +# - Never commit sensitive keys to version control +# - For production, set these in your hosting platform's environment variables +# - The app will work without VITE_API_KEY but AI features will be limited diff --git a/.gitignore b/.gitignore index a547bf3..fc98fc5 100644 --- a/.gitignore +++ b/.gitignore @@ -12,6 +12,16 @@ dist dist-ssr *.local +# dotenv environment variable files +.env +.env.local +.env.development +.env.production +.env.test +.env.test.local +.env.production.local +.env.development.local + # Editor directories and files .vscode/* !.vscode/extensions.json diff --git a/App.tsx b/App.tsx index 8e2076b..4f2e150 100644 --- a/App.tsx +++ b/App.tsx @@ -1,5 +1,5 @@ -import React, { useState, useEffect } from 'react'; +import React, { useState, useEffect, createContext, useContext } from 'react'; import { BrowserRouter, Routes, Route, Navigate, Outlet } from 'react-router-dom'; import { User } from './types'; import LandingPage from './components/Landing/LandingPage'; @@ -9,6 +9,27 @@ import DashboardLayout from './components/Layout/DashboardLayout'; import RepositoryView from './components/Dashboard/RepositoryView'; import { getProfile, loginUser, logoutUser } from './services/auth'; import SettingsPage from './components/Settings/SettingsPage'; +import AdminRoute from './components/Admin/AdminRoute'; +import RoleRoute from './components/Admin/RoleRoute'; +import AdminDashboard from './components/Admin/AdminDashboard'; +import UserManagement from './components/Admin/UserManagement'; +import AdminAnalytics from './components/Admin/AdminAnalytics'; +import DesktopRequired from './components/UI/DesktopRequired'; + +export type ThemeMode = 'light' | 'dark' | 'system'; + +// --- Theme Context --- +export const ThemeContext = createContext<{ + theme: ThemeMode; + setTheme: (theme: ThemeMode) => void; + isDarkMode: boolean; +}>({ + theme: 'system', + setTheme: () => {}, + isDarkMode: false, +}); + +export const useTheme = () => useContext(ThemeContext); // --- Auth Context Mockup for App-wide state --- export const AuthContext = React.createContext<{ @@ -32,29 +53,88 @@ const ProtectedRoute: React.FC = ({ children }) => { }; const PublicLayout = () => ( -
+
); const App: React.FC = () => { + const [isDesktop, setIsDesktop] = useState(() => { + if (typeof window === 'undefined') return true; + return window.innerWidth >= 1024; + }); + const [user, setUser] = useState(() => { return JSON.parse(localStorage.getItem('eqorascale_user') || 'null'); }); const [authLoading, setAuthLoading] = useState(true); - const [isDarkMode, setIsDarkMode] = useState(() => { + // Theme state management + const [theme, setTheme] = useState(() => { const saved = localStorage.getItem('theme'); - if (saved) return saved === 'dark'; + return (saved as ThemeMode) || 'system'; + }); + + // Calculate actual dark mode based on theme preference + const getSystemDarkMode = () => { return window.matchMedia('(prefers-color-scheme: dark)').matches; + }; + + const [isDarkMode, setIsDarkMode] = useState(() => { + if (theme === 'system') { + return getSystemDarkMode(); + } + return theme === 'dark'; }); + // Apply theme changes useEffect(() => { const root = window.document.documentElement; - if (isDarkMode) root.classList.add('dark'); - else root.classList.remove('dark'); - localStorage.setItem('theme', isDarkMode ? 'dark' : 'light'); - }, [isDarkMode]); + let shouldBeDark: boolean; + + if (theme === 'system') { + shouldBeDark = getSystemDarkMode(); + } else { + shouldBeDark = theme === 'dark'; + } + + setIsDarkMode(shouldBeDark); + + if (shouldBeDark) { + root.classList.add('dark'); + } else { + root.classList.remove('dark'); + } + + localStorage.setItem('theme', theme); + }, [theme]); + + // Listen to system theme changes when theme is set to 'system' + useEffect(() => { + if (theme !== 'system') return; + + const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)'); + const handleChange = (e: MediaQueryListEvent) => { + const root = window.document.documentElement; + if (e.matches) { + root.classList.add('dark'); + setIsDarkMode(true); + } else { + root.classList.remove('dark'); + setIsDarkMode(false); + } + }; + + // Modern browsers + if (mediaQuery.addEventListener) { + mediaQuery.addEventListener('change', handleChange); + return () => mediaQuery.removeEventListener('change', handleChange); + } else { + // Fallback for older browsers + mediaQuery.addListener(handleChange); + return () => mediaQuery.removeListener(handleChange); + } + }, [theme]); useEffect(() => { const token = localStorage.getItem('eqorascale_token'); @@ -88,69 +168,92 @@ const App: React.FC = () => { localStorage.removeItem('eqorascale_user'); }; + // Show desktop required message for mobile/tablet + if (!isDesktop) { + return ; + } + return ( - - - - - {/* Public Section */} - }> - } /> - - - {/* Auth Section */} - : - } /> - : - } /> - - {/* Dashboard Section */} - - setIsDarkMode(!isDarkMode)} - /> - - } - > - } /> - } /> - - {/* Scale placeholders */} - -
-

Collections

-

Module coming soon

-
-
- } /> - -
-

Analytics

-

Module coming soon

-
- + + + + + + {/* Public Section */} + }> + } /> + + + {/* Auth Section */} + : } /> - + : } /> - - - {/* Catch-all */} - } /> - - - - + + {/* Dashboard Section */} + + + + } + > + } /> + } /> + + {/* Collections - All authenticated users */} + +
+

Collections

+

Module coming soon

+
+ + } /> + + {/* Analytics - Admin only */} + +
+
+

Analytics

+

Module coming soon

+
+
+ + } /> + + {/* Settings - All authenticated users (but admin sees more) */} + + } /> + + {/* Admin Panel Routes */} + + + + }> + } /> + } /> + } /> + + + + {/* Catch-all */} + } /> + + + + +
); }; diff --git a/components/Admin/AccessDenied.tsx b/components/Admin/AccessDenied.tsx new file mode 100644 index 0000000..d5d11ed --- /dev/null +++ b/components/Admin/AccessDenied.tsx @@ -0,0 +1,65 @@ +/** + * Access Denied Component + * Shown when user doesn't have required permissions + */ + +import React from 'react'; +import { useNavigate } from 'react-router-dom'; +import { Icons } from '../../constants'; + +interface AccessDeniedProps { + requiredRole?: string; + requiredPermission?: string; + message?: string; +} + +const AccessDenied: React.FC = ({ + requiredRole, + requiredPermission, + message +}) => { + const navigate = useNavigate(); + + const getMessage = () => { + if (message) return message; + if (requiredRole) { + return `This page requires ${requiredRole} role. You don't have the necessary permissions to access this resource.`; + } + if (requiredPermission) { + return `This action requires the following permission: ${requiredPermission}`; + } + return "You don't have permission to access this resource."; + }; + + return ( +
+
+
+ +
+

+ Access Denied +

+

+ {getMessage()} +

+
+ + +
+
+
+ ); +}; + +export default AccessDenied; diff --git a/components/Admin/AdminAnalytics.tsx b/components/Admin/AdminAnalytics.tsx new file mode 100644 index 0000000..2c227f6 --- /dev/null +++ b/components/Admin/AdminAnalytics.tsx @@ -0,0 +1,231 @@ +/** + * Admin Analytics Component + * System-wide analytics and reporting for administrators + */ + +import React from 'react'; +import { useQuery } from '@tanstack/react-query'; +import { Icons } from '../../constants'; +import { listFiles, flattenFileStructure } from '../../services/files'; +import { getAllUsers } from '../../services/users'; +import { DocumentType } from '../../types'; + +const AdminAnalytics: React.FC = () => { + // Fetch real data from API + const { data: fileStructure } = useQuery({ + queryKey: ['files'], + queryFn: () => listFiles(), + }); + + const { data: users } = useQuery({ + queryKey: ['admin-users'], + queryFn: () => getAllUsers(), + }); + + // Calculate analytics from real data + const allFiles = fileStructure ? flattenFileStructure(fileStructure) : []; + const totalDocuments = allFiles.length; + const totalUsers = users?.length || 0; + + const documentsByType = { + RFQ: allFiles.filter(f => f.docType === DocumentType.RFQ).length, + PO: allFiles.filter(f => f.docType === DocumentType.PO).length, + QUOTATION: allFiles.filter(f => f.docType === DocumentType.QUOTATION).length, + INVOICE: allFiles.filter(f => f.docType === DocumentType.INVOICE).length, + GENERAL: allFiles.filter(f => f.docType === DocumentType.GENERAL).length, + }; + + const analytics = { + totalDocuments, + totalUsers, + documentsByType, + recentActivity: [], + systemHealth: { + status: 'healthy', + uptime: '99.9%', + storage: 'N/A', // Backend doesn't provide storage info yet + }, + }; + + const metrics = [ + { + label: 'Total Documents', + value: analytics.totalDocuments.toLocaleString(), + icon: Icons.FileText, + color: 'bg-indigo-500', + change: '+12%', + trend: 'up', + }, + { + label: 'Total Users', + value: analytics.totalUsers.toLocaleString(), + icon: Icons.Users, + color: 'bg-blue-500', + change: '+5%', + trend: 'up', + }, + { + label: 'System Uptime', + value: analytics.systemHealth.uptime, + icon: Icons.Activity, + color: 'bg-emerald-500', + change: 'Stable', + trend: 'stable', + }, + { + label: 'Storage Used', + value: analytics.systemHealth.storage, + icon: Icons.Database, + color: 'bg-purple-500', + change: '25%', + trend: 'stable', + }, + ]; + + return ( +
+
+ + {/* Header */} +
+
+

+ Analytics Dashboard +

+

+ System-wide insights and performance metrics +

+
+
+ +
+
+ + {/* Metrics Grid */} +
+ {metrics.map((metric, index) => ( +
+
+
+ +
+
+ {metric.trend === 'up' && } + {metric.change} +
+
+

+ {metric.value} +

+

+ {metric.label} +

+
+ ))} +
+ + {/* Documents by Type */} +
+

+ Documents by Type +

+
+ {Object.entries(analytics.documentsByType).map(([type, count]) => ( +
+

+ {count} +

+

+ {type} +

+
+ ))} +
+
+ + {/* System Health */} +
+
+

+ System Health +

+
+
+
+ + Status +
+ + {analytics.systemHealth.status} + +
+
+
+ + Uptime +
+ + {analytics.systemHealth.uptime} + +
+
+
+ + Storage +
+ + {analytics.systemHealth.storage} + +
+
+
+ +
+

+ Quick Actions +

+
+ + + +
+
+
+ + {/* Placeholder for Charts */} +
+
+ +

+ Advanced Analytics +

+

+ Charts and detailed analytics will be available here +

+
+
+ +
+
+ ); +}; + +export default AdminAnalytics; diff --git a/components/Admin/AdminDashboard.tsx b/components/Admin/AdminDashboard.tsx new file mode 100644 index 0000000..0c898f8 --- /dev/null +++ b/components/Admin/AdminDashboard.tsx @@ -0,0 +1,194 @@ +/** + * Admin Dashboard Component + * Overview of system statistics and admin actions + */ + +import React from 'react'; +import { useNavigate } from 'react-router-dom'; +import { Icons } from '../../constants'; +import { useUser } from '../../hooks/usePermissions'; +import { + Users, + FileText, + BarChart3, + Settings, + Shield, + Activity, + Database, + Key +} from 'lucide-react'; + +const AdminDashboard: React.FC = () => { + const navigate = useNavigate(); + const user = useUser(); + + const stats = [ + { + label: 'Total Users', + value: '0', + icon: Icons.Users, + color: 'bg-blue-500', + path: '/app/admin/users', + }, + { + label: 'Total Documents', + value: '0', + icon: Icons.FileText, + color: 'bg-indigo-500', + path: '/app/repository/ALL', + }, + { + label: 'System Health', + value: '100%', + icon: Icons.Activity, + color: 'bg-emerald-500', + path: '/app/admin', + }, + { + label: 'Active Sessions', + value: '1', + icon: Icons.Shield, + color: 'bg-purple-500', + path: '/app/admin', + }, + ]; + + const quickActions = [ + { + label: 'User Management', + description: 'Manage users, roles, and permissions', + icon: Icons.Users, + path: '/app/admin/users', + color: 'bg-blue-50 dark:bg-blue-900/20 text-blue-600 dark:text-blue-400', + }, + { + label: 'System Settings', + description: 'Configure system-wide settings', + icon: Icons.Settings, + path: '/app/settings', + color: 'bg-indigo-50 dark:bg-indigo-900/20 text-indigo-600 dark:text-indigo-400', + }, + { + label: 'Analytics', + description: 'View system analytics and reports', + icon: Icons.BarChart3, + path: '/app/analytics', + color: 'bg-purple-50 dark:bg-purple-900/20 text-purple-600 dark:text-purple-400', + }, + { + label: 'API Keys', + description: 'Manage API keys and integrations', + icon: Icons.Key, + path: '/app/admin', + color: 'bg-amber-50 dark:bg-amber-900/20 text-amber-600 dark:text-amber-400', + }, + ]; + + return ( +
+
+ + {/* Header */} +
+
+

+ Admin Dashboard +

+

+ System overview and administrative controls +

+
+
+ + + Administrator Access + +
+
+ + {/* Stats Grid */} +
+ {stats.map((stat, index) => ( +
navigate(stat.path)} + className="bg-white dark:bg-slate-900 rounded-xl border border-slate-200 dark:border-slate-800 p-4 sm:p-6 shadow-sm hover:shadow-md transition-all cursor-pointer group" + > +
+
+ +
+ +
+

+ {stat.value} +

+

+ {stat.label} +

+
+ ))} +
+ + {/* Quick Actions */} +
+

+ Quick Actions +

+
+ {quickActions.map((action, index) => ( +
navigate(action.path)} + className="bg-white dark:bg-slate-900 rounded-xl border border-slate-200 dark:border-slate-800 p-4 sm:p-6 shadow-sm hover:shadow-md transition-all cursor-pointer group" + > +
+
+ +
+
+

+ {action.label} +

+

+ {action.description} +

+
+ +
+
+ ))} +
+
+ + {/* System Info */} +
+

+ System Information +

+
+
+

+ Current Admin +

+

+ {user?.username || 'Unknown'} +

+
+
+

+ Role +

+

+ {user?.role || 'User'} +

+
+
+
+ +
+
+ ); +}; + +export default AdminDashboard; diff --git a/components/Admin/AdminRoute.tsx b/components/Admin/AdminRoute.tsx new file mode 100644 index 0000000..3e9dae7 --- /dev/null +++ b/components/Admin/AdminRoute.tsx @@ -0,0 +1,39 @@ +/** + * Admin Route Guard + * Protects routes that require admin role + */ + +import React from 'react'; +import { Navigate } from 'react-router-dom'; +import { useContext } from 'react'; +import { AuthContext } from '../../App'; +import { isAdmin } from '../../utils/permissions'; +import AccessDenied from './AccessDenied'; + +interface AdminRouteProps { + children: React.ReactNode; +} + +const AdminRoute: React.FC = ({ children }) => { + const { user, loading } = useContext(AuthContext); + + if (loading) { + return ( +
+
+
+ ); + } + + if (!user) { + return ; + } + + if (!isAdmin(user)) { + return ; + } + + return <>{children}; +}; + +export default AdminRoute; diff --git a/components/Admin/RoleRoute.tsx b/components/Admin/RoleRoute.tsx new file mode 100644 index 0000000..faca057 --- /dev/null +++ b/components/Admin/RoleRoute.tsx @@ -0,0 +1,42 @@ +/** + * Role-based Route Guard + * Protects routes that require specific roles + */ + +import React from 'react'; +import { Navigate } from 'react-router-dom'; +import { useContext } from 'react'; +import { AuthContext } from '../../App'; +import { hasRole } from '../../utils/permissions'; +import AccessDenied from './AccessDenied'; + +interface RoleRouteProps { + children: React.ReactNode; + allowedRoles: string[]; +} + +const RoleRoute: React.FC = ({ children, allowedRoles }) => { + const { user, loading } = useContext(AuthContext); + + if (loading) { + return ( +
+
+
+ ); + } + + if (!user) { + return ; + } + + const hasAccess = allowedRoles.some(role => hasRole(user, role)); + + if (!hasAccess) { + return ; + } + + return <>{children}; +}; + +export default RoleRoute; diff --git a/components/Admin/UserManagement.tsx b/components/Admin/UserManagement.tsx new file mode 100644 index 0000000..a25b6a5 --- /dev/null +++ b/components/Admin/UserManagement.tsx @@ -0,0 +1,268 @@ +/** + * User Management Component + * Admin interface for managing users, roles, and permissions + */ + +import React, { useState } from 'react'; +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; +import { Icons } from '../../constants'; +import { UserProfile } from '../../services/auth'; +import { useToast } from '../UI/Toast'; +import { getAllUsers, deleteUser } from '../../services/users'; + +interface UserListResponse { + users: UserProfile[]; + total: number; +} + +const UserManagement: React.FC = () => { + const { toast } = useToast(); + const [searchQuery, setSearchQuery] = useState(''); + const [roleFilter, setRoleFilter] = useState('all'); + + // Fetch users from API + const { data, isLoading, error, refetch } = useQuery({ + queryKey: ['admin-users', searchQuery, roleFilter], + queryFn: async () => { + const users = await getAllUsers(); + return { + users, + total: users.length, + }; + }, + staleTime: 1000 * 60 * 5, + }); + + const handleCreateUser = () => { + toast.info('User creation feature coming soon'); + }; + + const handleEditUser = (userId: string) => { + toast.info(`Edit user ${userId} - Feature coming soon`); + }; + + const queryClient = useQueryClient(); + + const deleteUserMutation = useMutation({ + mutationFn: (id: number) => deleteUser(id), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['admin-users'] }); + toast.success('User deleted successfully'); + }, + onError: (error: any) => { + toast.error(error?.message || 'Failed to delete user'); + }, + }); + + const handleDeleteUser = async (userId: string) => { + if (confirm('Are you sure you want to delete this user?')) { + try { + await deleteUserMutation.mutateAsync(parseInt(userId, 10)); + } catch (error) { + // Error already handled by mutation + } + } + }; + + const handleRoleChange = async (userId: string, newRole: 'admin' | 'user') => { + try { + // TODO: Replace with actual API call + // await apiFetch(`/admin/users/${userId}/role`, { + // method: 'PATCH', + // body: JSON.stringify({ role: newRole }), + // }); + toast.success(`User role updated to ${newRole}`); + } catch (error) { + toast.error('Failed to update user role'); + } + }; + + if (isLoading) { + return ( +
+
+
+ ); + } + + if (error) { + return ( +
+
+

Failed to load users

+

Please try again later

+
+
+ ); + } + + const filteredUsers = (data?.users || []).filter(user => { + const matchesSearch = user.username.toLowerCase().includes(searchQuery.toLowerCase()) || + user.email?.toLowerCase().includes(searchQuery.toLowerCase()); + const matchesRole = roleFilter === 'all' || user.role === roleFilter; + return matchesSearch && matchesRole; + }); + + return ( +
+
+ + {/* Header */} +
+
+

+ User Management +

+

+ Manage users, roles, and permissions +

+
+ +
+ + {/* Filters */} +
+
+
+ + setSearchQuery(e.target.value)} + /> +
+
+ + +
+
+
+ + {/* Users Table */} +
+
+ + + + + + + + + + + + {filteredUsers.length === 0 ? ( + + + + ) : ( + filteredUsers.map((user) => ( + + + + + + + + )) + )} + +
UserEmailRoleStatusActions
+
+ +

+ No Users Found +

+

+ {searchQuery || roleFilter !== 'all' + ? 'Try adjusting your filters' + : 'User management requires backend integration'} +

+
+
+
+
+ {user.username.charAt(0).toUpperCase()} +
+
+

+ {user.username} +

+

+ {user.email || 'No email'} +

+
+
+
+

+ {user.email || '—'} +

+
+ + + + {user.is_active ? 'Active' : 'Inactive'} + + +
+ + +
+
+
+
+ + {/* Stats Footer */} +
+

+ Showing {filteredUsers.length} of {data?.total || 0} users +

+
+ +
+
+ ); +}; + +export default UserManagement; diff --git a/components/Dashboard/RepositoryView.tsx b/components/Dashboard/RepositoryView.tsx index 4956f3f..4a31486 100644 --- a/components/Dashboard/RepositoryView.tsx +++ b/components/Dashboard/RepositoryView.tsx @@ -4,10 +4,15 @@ import { useParams, useOutletContext } from 'react-router-dom'; import { DocumentType, FileRecord, ViewMode } from '../../types'; import TableView from '../FileExplorer/TableView'; import FileExplorer from '../FileExplorer/FileExplorer'; +import { useHasPermission, useIsAdmin } from '../../hooks/usePermissions'; +import { Permission } from '../../utils/permissions'; const RepositoryView: React.FC = () => { const { tab } = useParams<{ tab: string }>(); const activeTab = tab || 'ALL'; + const canDelete = useHasPermission(Permission.DELETE_DOCUMENTS); + const canUpload = useHasPermission(Permission.UPLOAD_DOCUMENTS); + const canReset = useIsAdmin(); // Only admin can reset database const { files, @@ -33,21 +38,23 @@ const RepositoryView: React.FC = () => { return (
-
-
-

+
+
+

{activeTab === 'ALL' ? 'Repository Overview' : `${activeTab} Management`}

Indexing {filteredFiles.length} enterprise documents

- + {canReset && ( + + )}
@@ -55,7 +62,7 @@ const RepositoryView: React.FC = () => { ) : ( @@ -64,10 +71,10 @@ const RepositoryView: React.FC = () => { currentPath={currentFolderPath} setCurrentPath={setCurrentFolderPath} setSelectedFile={setSelectedFile} - onDeleteFile={onDeleteFile} + onDeleteFile={canDelete ? onDeleteFile : undefined} onRenameFolder={() => {}} onDeleteFolder={() => {}} - onUploadTrigger={onUploadTrigger} + onUploadTrigger={canUpload ? onUploadTrigger : undefined} /> )}
diff --git a/components/FileExplorer/FileDetailModal.tsx b/components/FileExplorer/FileDetailModal.tsx index c25eca1..042244e 100644 --- a/components/FileExplorer/FileDetailModal.tsx +++ b/components/FileExplorer/FileDetailModal.tsx @@ -111,51 +111,52 @@ const FileDetailModal: React.FC = ({ const docClassification = file.isClassifying ? 'Classifying...' : file.docType; return ( -
+
-
+
{/* Header */} -
-
-
- +
+
+
+
-
-

{file.name}

-
- +
+

{file.name}

+
+ {docClassification} - - {(file.size / 1024 / 1024).toFixed(2)} MB + + {(file.size / 1024 / 1024).toFixed(2)} MB
-
+
{/* Content Area */} -
+
{/* Left: Document Viewer */} -
+
{isPdf && rawFile ? ( @@ -255,22 +256,22 @@ const FileDetailModal: React.FC = ({
{/* Chat Input */} -
-
+
+ setChatMessage(e.target.value)} />
diff --git a/components/FileExplorer/FileExplorer.tsx b/components/FileExplorer/FileExplorer.tsx index d9974c8..8aa56d5 100644 --- a/components/FileExplorer/FileExplorer.tsx +++ b/components/FileExplorer/FileExplorer.tsx @@ -10,8 +10,8 @@ interface FileExplorerProps { setSelectedFile: (file: FileRecord) => void; onRenameFolder: (path: string) => void; onDeleteFolder: (path: string) => void; - onDeleteFile: (fileId: string) => void; - onUploadTrigger: () => void; + onDeleteFile?: (fileId: string) => void; + onUploadTrigger?: () => void; } const FileExplorer: React.FC = ({ @@ -62,18 +62,18 @@ const FileExplorer: React.FC = ({ return (
{/* Navigation Header */} -
+
{currentPath !== 'Root' && ( )} -
+
{breadcrumbs.map((part, i) => ( - {i < breadcrumbs.length - 1 && } + {i < breadcrumbs.length - 1 && } ))}
-
+
{/* Go Back Trigger Card */} {currentPath !== 'Root' && ( @@ -170,15 +170,17 @@ const FileExplorer: React.FC = ({ className="bg-white dark:bg-slate-900 p-5 rounded-2xl border border-slate-200 dark:border-slate-800 shadow-sm hover:shadow-md hover:border-indigo-300 dark:hover:border-indigo-700 transition-all group cursor-pointer flex flex-col relative" > {/* Action Bar */} -
- -
+ {onDeleteFile && ( +
+ +
+ )}
@@ -206,15 +208,17 @@ const FileExplorer: React.FC = ({ ))} {/* ADD FILE TRIGGER */} -
-
- + {onUploadTrigger && ( +
+
+ +
+ Add Document
- Add Document -
+ )}
diff --git a/components/FileExplorer/TableView.tsx b/components/FileExplorer/TableView.tsx index 9f768fe..4650412 100644 --- a/components/FileExplorer/TableView.tsx +++ b/components/FileExplorer/TableView.tsx @@ -6,7 +6,7 @@ import { Icons, STATUS_COLORS, DOC_TYPE_COLORS } from '../../constants'; interface TableViewProps { files: FileRecord[]; onAnalyze: (file: FileRecord) => void; - onDelete: (fileId: string) => void; + onDelete?: (fileId: string) => void; setSelectedFile: (file: FileRecord) => void; } @@ -20,25 +20,27 @@ const TableView: React.FC = ({ files, onAnalyze, onDelete, setSe }; return ( -
-
- - - - - - - - - - +
+
+
+
+
Document IdentifierAI IntelligenceWorkflowTimestampActions
+ + + + + + + + + {files.length === 0 ? ( - @@ -49,26 +51,35 @@ const TableView: React.FC = ({ files, onAnalyze, onDelete, setSe className="hover:bg-slate-50 dark:hover:bg-slate-800/40 group cursor-pointer transition-colors" onClick={() => setSelectedFile(file)} > - - - - - )) )} -
DocumentAI IntelligenceWorkflowTimestampActions
+
- -

Database Empty

+ +

Database Empty

-
-
- +
+
+
+
-
-

{file.name}

-

{file.path}

+
+

{file.name}

+

{file.path}

+ {/* Mobile: Show doc type and status inline */} +
+ + {file.isClassifying ? 'Processing' : file.docType} + + + {file.status} + +
+ {file.isClassifying ? ( -
-
- Processing +
+
+ Processing
) : (
- + {file.docType} {file.summary && ( @@ -77,39 +88,43 @@ const TableView: React.FC = ({ files, onAnalyze, onDelete, setSe
)}
- + + {file.status} -

{new Date(file.createdAt).toLocaleDateString()}

-

{formatSize(file.size)}

+
+

{new Date(file.createdAt).toLocaleDateString()}

+

{formatSize(file.size)}

-
+
+
- + {onDelete && ( + + )}
+ +
+
); diff --git a/components/Forms/AuthForm.tsx b/components/Forms/AuthForm.tsx index 6947030..d09f95a 100644 --- a/components/Forms/AuthForm.tsx +++ b/components/Forms/AuthForm.tsx @@ -35,47 +35,47 @@ const AuthForm: React.FC = ({ onLogin }) => { }; return ( -
-
+
+
-
-

- Eqorascale - , Intelligent document indexing for procurement +
+

+ Eqorascale + , Intelligent document indexing for procurement

-

+

Migrate folder trees, index every file, and generate quotes & invoices using AI. Secure, enterprise-focused.

-
+
-
-
+
+
-

+

Log In to Eqorascale

-
+
- +
-
- +
+
setUsername(e.target.value)} @@ -83,34 +83,34 @@ const AuthForm: React.FC = ({ onLogin }) => {
- +
-
- +
+
setPassword(e.target.value)} />
- +
- {error &&

{error}

} + {error &&

{error}

} -
If you need an account, contact your system admin.
+
If you need an account, contact your system admin.
diff --git a/components/Landing/LandingPage.tsx b/components/Landing/LandingPage.tsx index 1f89deb..ae3bbdd 100644 --- a/components/Landing/LandingPage.tsx +++ b/components/Landing/LandingPage.tsx @@ -11,14 +11,14 @@ const LandingPage: React.FC = () => { const navigate = useNavigate(); return ( -
+
-
+
-
Supply Chain Intelligence
-

Master your Supply Chain with AI Precision.

-

Enterprise-grade document management for industrial sectors. Automate RFQs, POs, and Invoices with AI intelligence.

-
- - +
Supply Chain Intelligence
+

Master your Supply Chain with AI Precision.

+

Enterprise-grade document management for industrial sectors. Automate RFQs, POs, and Invoices with AI intelligence.

+
+ +
{/* Other sections removed for brevity but they remain functional links */} -
-

© 2026 Eqorascale Enterprise. V1.0.0 MVP PLATFORM

+
+

© 2026 Eqorascale Enterprise. V1.0.0 MVP PLATFORM

); diff --git a/components/Layout/DashboardLayout.tsx b/components/Layout/DashboardLayout.tsx index 7c2e1e6..8e7873f 100644 --- a/components/Layout/DashboardLayout.tsx +++ b/components/Layout/DashboardLayout.tsx @@ -1,65 +1,104 @@ import React, { useState, useEffect, useRef } from 'react'; import { Outlet, useLocation } from 'react-router-dom'; +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import Sidebar from './Sidebar'; import { User, FileRecord, DocumentType, ViewMode, FileStatus } from '../../types'; -import { INITIAL_FILES } from '../../store/mockData'; import { Icons } from '../../constants'; import { useToast } from '../UI/Toast'; import { analyzeDocument, askDocumentQuestion } from '../../services/ai'; import FileDetailModal from '../FileExplorer/FileDetailModal'; import ConfirmationModal from '../UI/ConfirmationModal'; +import { useHasPermission, useIsAdmin } from '../../hooks/usePermissions'; +import { Permission } from '../../utils/permissions'; +import { + listFiles, + flattenFileStructure, + uploadFile, + uploadMultipleFiles, + uploadFolder, + deleteFile, + getFile, +} from '../../services/files'; interface DashboardLayoutProps { user: User | null; onLogout: () => void; isDarkMode: boolean; - toggleTheme: () => void; } -const DashboardLayout: React.FC = ({ user, onLogout, isDarkMode, toggleTheme }) => { +const DashboardLayout: React.FC = ({ user, onLogout, isDarkMode }) => { const { toast } = useToast(); - const [files, setFiles] = useState(() => { - const saved = localStorage.getItem('eqorascale_files'); - const filesData = saved ? JSON.parse(saved) : INITIAL_FILES; - - // Recreate blob URLs for stored files - if (saved) { - filesData.forEach((file: FileRecord) => { - const fileBlob = localStorage.getItem(`eqorascale_blob_${file.id}`); - if (fileBlob && !file.blobUrl) { - try { - const binaryData = atob(fileBlob); - const bytes = new Uint8Array(binaryData.length); - for (let i = 0; i < binaryData.length; i++) { - bytes[i] = binaryData.charCodeAt(i); - } - const blob = new Blob([bytes]); - file.blobUrl = URL.createObjectURL(blob); - } catch (e) { - console.error('Failed to recreate blob URL:', e); - } - } - }); - } - - return filesData; - }); - - const [rawFilesMap, setRawFilesMap] = useState>(new Map()); + const queryClient = useQueryClient(); + const canUpload = useHasPermission(Permission.UPLOAD_DOCUMENTS); + const canDelete = useHasPermission(Permission.DELETE_DOCUMENTS); + const canReset = useIsAdmin(); + const [viewMode, setViewMode] = useState(ViewMode.TABLE); const [searchQuery, setSearchQuery] = useState(''); const [selectedFile, setSelectedFile] = useState(null); const [currentFolderPath, setCurrentFolderPath] = useState('Root'); const [isResetModalOpen, setIsResetModalOpen] = useState(false); + const [currentFolderId, setCurrentFolderId] = useState(undefined); const fileInputRef = useRef(null); const location = useLocation(); - useEffect(() => { - const metadata = files.map(({blobUrl, isClassifying, ...f}) => f); - localStorage.setItem('eqorascale_files', JSON.stringify(metadata)); - }, [files]); + // Fetch files from API + const { data: fileStructure, isLoading: filesLoading, refetch: refetchFiles } = useQuery({ + queryKey: ['files', currentFolderId], + queryFn: () => listFiles(currentFolderId), + staleTime: 1000 * 60, // 1 minute + }); + + // Convert file structure to flat array + const files: FileRecord[] = fileStructure ? flattenFileStructure(fileStructure, currentFolderPath) : []; + + // Upload mutations + const uploadFileMutation = useMutation({ + mutationFn: uploadFile, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['files'] }); + toast.success('File uploaded successfully'); + }, + onError: (error: any) => { + toast.error(error?.message || 'Failed to upload file'); + }, + }); + + const uploadMultipleMutation = useMutation({ + mutationFn: uploadMultipleFiles, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['files'] }); + toast.success('Files uploaded successfully'); + }, + onError: (error: any) => { + toast.error(error?.message || 'Failed to upload files'); + }, + }); + + const uploadFolderMutation = useMutation({ + mutationFn: ({ files, folderPath }: { files: File[]; folderPath?: string }) => + uploadFolder(files, folderPath), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['files'] }); + toast.success('Folder uploaded successfully'); + }, + onError: (error: any) => { + toast.error(error?.message || 'Failed to upload folder'); + }, + }); + + const deleteFileMutation = useMutation({ + mutationFn: deleteFile, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['files'] }); + toast.success('File deleted successfully'); + }, + onError: (error: any) => { + toast.error(error?.message || 'Failed to delete file'); + }, + }); const extractText = async (file: File): Promise => { const fileName = file.name.toLowerCase(); @@ -92,97 +131,69 @@ const DashboardLayout: React.FC = ({ user, onLogout, isDar const triggerAnalysis = async (fileId: string, name: string, content: string) => { try { const result = await analyzeDocument(name, content); - setFiles(prev => prev.map(f => f.id === fileId ? { - ...f, - docType: result?.documentType as DocumentType || DocumentType.GENERAL, - tags: [...new Set([...f.tags, ...(result?.suggestedTags || [])])], - summary: result?.summary, - isClassifying: false - } : f)); + // Invalidate queries to refresh file data after analysis + queryClient.invalidateQueries({ queryKey: ['files'] }); + toast.success(`Analysis completed for ${name}`); } catch (err) { - setFiles(prev => prev.map(f => f.id === fileId ? { ...f, isClassifying: false } : f)); + toast.error(`Failed to analyze ${name}`); } }; const handleFileUpload = async (event: React.ChangeEvent) => { const rawFiles = event.target.files; - if (!rawFiles) return; - const newRecords: FileRecord[] = []; - const newRawMap = new Map(rawFilesMap); - - // FIX: Cast Array.from(rawFiles) to File[] to resolve 'unknown' type issues and access File properties - for (const file of Array.from(rawFiles) as File[]) { - const fileName = file.name.toLowerCase(); - if (!fileName.endsWith('.pdf') && !fileName.endsWith('.doc') && !fileName.endsWith('.docx')) continue; - - const fileId = Math.random().toString(36).substr(2, 9); - const content = await extractText(file); - const blobUrl = URL.createObjectURL(file); - newRawMap.set(fileId, file); - - // Store file blob as base64 for persistence across reloads - const reader = new FileReader(); - reader.onload = (e) => { - if (e.target?.result && typeof e.target.result === 'string') { - localStorage.setItem(`eqorascale_blob_${fileId}`, e.target.result.split(',')[1]); - } - }; - reader.readAsDataURL(file); - - const relativePath = (file as any).webkitRelativePath as string | undefined; - const normalizedPath = relativePath - ? `Root/${relativePath.split('/').slice(0, -1).join('/')}` - : currentFolderPath; - - const record: FileRecord = { - id: fileId, - name: file.name, - type: file.type, - size: file.size, - path: normalizedPath, - createdAt: new Date().toISOString(), - status: FileStatus.COMPLETED, - docType: DocumentType.GENERAL, - tags: ['New'], - content, - blobUrl, - isClassifying: true - }; - newRecords.push(record); - } + if (!rawFiles || rawFiles.length === 0) return; - if (newRecords.length > 0) { - setRawFilesMap(newRawMap); - setFiles(prev => [...prev, ...newRecords]); - newRecords.forEach((record) => { - triggerAnalysis(record.id, record.name, record.content || ''); + const fileArray = Array.from(rawFiles) as File[]; + + // Check if this is a folder upload (has webkitRelativePath) + const isFolderUpload = fileArray.some(f => (f as any).webkitRelativePath); + + if (isFolderUpload) { + // Upload as folder structure + uploadFolderMutation.mutate({ + files: fileArray, + folderPath: currentFolderPath !== 'Root' ? currentFolderPath.replace('Root/', '') : undefined, }); - toast.success(`Indexed ${newRecords.length} documents.`); + } else if (fileArray.length === 1) { + // Single file upload + uploadFileMutation.mutate(fileArray[0]); + } else { + // Multiple files upload + uploadMultipleMutation.mutate(fileArray); } + event.target.value = ''; }; const handleManualAnalyze = async (file: FileRecord) => { - setFiles(prev => prev.map(f => f.id === file.id ? { ...f, isClassifying: true } : f)); await triggerAnalysis(file.id, file.name, file.content || ''); toast.info(`Analyzing ${file.name}...`); }; - const handleDeleteFile = (fileId: string) => { + const handleDeleteFile = async (fileId: string) => { + if (!canDelete) { + toast.error("You don't have permission to delete documents."); + return; + } if (confirm("Delete document?")) { - setFiles(prev => prev.filter(f => f.id !== fileId)); - toast.success("Document removed."); + try { + await deleteFileMutation.mutateAsync(parseInt(fileId, 10)); + setSelectedFile(null); + } catch (error) { + // Error already handled by mutation + } } }; const handleReset = () => { - // Clean up blob URLs and storage - files.forEach(file => { - localStorage.removeItem(`eqorascale_blob_${file.id}`); - }); - setFiles(INITIAL_FILES); - localStorage.removeItem('eqorascale_files'); - toast.warning("Repository wiped."); + if (!canReset) { + toast.error("Only administrators can reset the database."); + return; + } + // Note: Backend doesn't have a reset endpoint + // This would need to be implemented on the backend + toast.warning("Reset functionality requires backend implementation."); + setIsResetModalOpen(false); }; return ( @@ -190,71 +201,74 @@ const DashboardLayout: React.FC = ({ user, onLogout, isDar
-
-
-
- +
+
+
+ setSearchQuery(e.target.value)} />
-
- +
+ {/* Theme toggle removed - now handled in Settings */}
- - + +
-
- fileInputRef.current?.click(), - onReset: () => setIsResetModalOpen(true) - }} /> + {filesLoading ? ( +
+
+
+ ) : ( + fileInputRef.current?.click(), + onReset: () => setIsResetModalOpen(true) + }} /> + )}
{selectedFile && ( setSelectedFile(null)} onAnalyze={handleManualAnalyze} onAsk={(q) => askDocumentQuestion(selectedFile.name, selectedFile.content || '', q)} - isProcessing={selectedFile.isClassifying || false} + isProcessing={false} /> )} diff --git a/components/Layout/Sidebar.tsx b/components/Layout/Sidebar.tsx index e8db366..7973e9c 100644 --- a/components/Layout/Sidebar.tsx +++ b/components/Layout/Sidebar.tsx @@ -3,6 +3,7 @@ import React from 'react'; import { NavLink, useNavigate } from 'react-router-dom'; import { DocumentType, User } from '../../types'; import { Icons } from '../../constants'; +import { useIsAdmin, useCanAccess } from '../../hooks/usePermissions'; interface SidebarProps { onLogout: () => void; @@ -11,6 +12,8 @@ interface SidebarProps { const Sidebar: React.FC = ({ onLogout, user }) => { const navigate = useNavigate(); + const isAdmin = useIsAdmin(); + const canViewAnalytics = useCanAccess('analytics'); const menuItems = [ { id: 'ALL', path: '/app/repository/ALL', label: 'Dashboard', icon: Icons.LayoutGrid }, @@ -21,7 +24,9 @@ const Sidebar: React.FC = ({ onLogout, user }) => { ]; return ( -
+ ); }; diff --git a/components/Settings/SettingsPage.tsx b/components/Settings/SettingsPage.tsx index b6b5319..c854581 100644 --- a/components/Settings/SettingsPage.tsx +++ b/components/Settings/SettingsPage.tsx @@ -1,6 +1,8 @@ import React from 'react'; import { useQuery } from '@tanstack/react-query'; -import { getProfileDetails, UserProfile } from '../../services/auth'; // Adjust path as needed +import { getProfileDetails, UserProfile } from '../../services/auth'; +import { useTheme, ThemeMode } from '../../App'; +import { Icons } from '../../constants'; import { User, Mail, @@ -31,6 +33,7 @@ const formatDate = (value?: string) => { const getInitials = (name: string) => name.slice(0, 2).toUpperCase(); const SettingsPage: React.FC = () => { + const { theme, setTheme } = useTheme(); const { data, isLoading, isError, error } = useQuery({ queryKey: ['profile'], queryFn: getProfileDetails, @@ -41,69 +44,70 @@ const SettingsPage: React.FC = () => { if (isError) return ; return ( -
-
+
+
{/* Header Section */} -
-
-

+
+
+

Settings

-

+

Manage your personal details and security preferences.

-
- - +
+ + - + - System Operational + System Operational + Operational
{data && ( -
+
{/* 1. Hero Profile Card (Spans 8 columns) */} -
+
{/* Decorative Gradient Background */} -
+
-
-
+
+
{/* Avatar */} -
+
{getInitials(data.username)}
{/* Name & Badge */} -
-
-

+
+
+

{data.username}

{data.is_active ? ( - + Active ) : ( - + Inactive )}
- - {data.email || 'No email linked'} + + {data.email || 'No email linked'}

-
+
{
{/* 2. Status & Security Card (Spans 4 columns) */} -
-
+
+ {/* Theme Selector */} +
+

+ + Appearance +

+
+ {(['light', 'dark', 'system'] as ThemeMode[]).map((themeOption) => ( + + ))} +
+
+ +

@@ -141,7 +178,7 @@ const SettingsPage: React.FC = () => {

- Your session is secured via JWT. Role-based access control is currently enforced. + Your session is secured via JWT. Role-based access control is actively enforced.

@@ -213,13 +250,13 @@ const InfoItem = ({ highlight?: boolean, copyable?: boolean }) => ( -
-
- +
+
+
-
-

{label}

-

+

+

{label}

+

{value || '—'}

diff --git a/components/UI/DesktopRequired.tsx b/components/UI/DesktopRequired.tsx new file mode 100644 index 0000000..6bf26fd --- /dev/null +++ b/components/UI/DesktopRequired.tsx @@ -0,0 +1,51 @@ +/** + * Desktop Required Component + * Shown to users accessing the app on mobile/tablet devices + */ + +import React from 'react'; +import { Icons } from '../../constants'; + +const DesktopRequired: React.FC = () => { + return ( +
+
+
+ +
+

+ Desktop Required +

+

+ EquoraScale is optimized for desktop use. Please access this application from a desktop or laptop computer with a minimum screen width of 1024px. +

+
+
+ +
+

+ Recommended Screen Size +

+

+ Minimum 1024px width for optimal experience +

+
+
+
+ +
+

+ Document Management +

+

+ File indexing and management require desktop access +

+
+
+
+
+
+ ); +}; + +export default DesktopRequired; diff --git a/constants.tsx b/constants.tsx index 73ce060..2b632a3 100644 --- a/constants.tsx +++ b/constants.tsx @@ -19,7 +19,19 @@ import { Sun, Trash2, Upload, - LockIcon + LockIcon, + Monitor, + Users, + UserPlus, + Edit, + Shield, + Activity, + Database, + Clock, + BarChart3, + TrendingUp, + Key, + Filter } from 'lucide-react'; export const STATUS_COLORS = { @@ -40,6 +52,7 @@ export const DOC_TYPE_COLORS = { export const Icons = { Sun, Moon, + Monitor, Folder, File, FileText, @@ -57,5 +70,16 @@ export const Icons = { ArrowRight, Globe, Trash: Trash2, - Lock:LockIcon + Lock: LockIcon, + Users, + UserPlus, + Edit, + Shield, + Activity, + Database, + Clock, + BarChart3, + TrendingUp, + Key, + Filter }; diff --git a/hooks/usePermissions.ts b/hooks/usePermissions.ts new file mode 100644 index 0000000..59092f6 --- /dev/null +++ b/hooks/usePermissions.ts @@ -0,0 +1,81 @@ +/** + * Permission Hooks for RBAC + * React hooks for checking user permissions and roles + */ + +import { useContext } from 'react'; +import { AuthContext } from '../App'; +import { + Permission, + hasPermission, + hasAnyPermission, + hasAllPermissions, + hasRole, + isAdmin, + getUserPermissions, + canAccess, +} from '../utils/permissions'; + +/** + * Hook to check if current user has a specific permission + */ +export const useHasPermission = (permission: Permission): boolean => { + const { user } = useContext(AuthContext); + return hasPermission(user, permission); +}; + +/** + * Hook to check if current user has any of the specified permissions + */ +export const useHasAnyPermission = (permissions: Permission[]): boolean => { + const { user } = useContext(AuthContext); + return hasAnyPermission(user, permissions); +}; + +/** + * Hook to check if current user has all of the specified permissions + */ +export const useHasAllPermissions = (permissions: Permission[]): boolean => { + const { user } = useContext(AuthContext); + return hasAllPermissions(user, permissions); +}; + +/** + * Hook to check if current user has a specific role + */ +export const useHasRole = (role: string): boolean => { + const { user } = useContext(AuthContext); + return hasRole(user, role); +}; + +/** + * Hook to check if current user is an admin + */ +export const useIsAdmin = (): boolean => { + const { user } = useContext(AuthContext); + return isAdmin(user); +}; + +/** + * Hook to get all permissions for current user + */ +export const useUserPermissions = (): Permission[] => { + const { user } = useContext(AuthContext); + return getUserPermissions(user); +}; + +/** + * Hook to check if current user can access a feature + */ +export const useCanAccess = (feature: string): boolean => { + const { user } = useContext(AuthContext); + return canAccess(user, feature); +}; + +/** + * Hook to get current user + */ +export const useUser = () => { + const { user } = useContext(AuthContext); + return user; +}; diff --git a/index.css b/index.css index 6ec24e6..2388b32 100644 --- a/index.css +++ b/index.css @@ -10,6 +10,16 @@ html.dark { color-scheme: dark; } + + /* Prevent flash of white background */ + body { + background-color: #f8fafc; + transition: background-color 0.2s ease; + } + + html.dark body { + background-color: #020617; + } } /* Custom scrollbar */ diff --git a/index.html b/index.html index 7edf7f3..ade9438 100644 --- a/index.html +++ b/index.html @@ -11,6 +11,24 @@ +