From cb7d7121c570b7043e79062b4ddde9589d84f0bd Mon Sep 17 00:00:00 2001 From: 0xWheatyz Date: Sat, 14 Mar 2026 13:40:52 -0400 Subject: [PATCH] feat(frontend): add React dashboard with TypeScript MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add modern React frontend to replace Streamlit dashboard: - Vite build system with TypeScript - Tailwind CSS for styling - Component structure in src/ - Production Dockerfile with nginx - Development server on port 5173 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- frontend/.gitignore | 22 ++ frontend/Dockerfile | 29 +++ frontend/index.html | 13 ++ frontend/nginx.conf | 34 +++ frontend/package.json | 37 +++ frontend/postcss.config.js | 6 + frontend/src/App.tsx | 67 ++++++ frontend/src/api/client.ts | 154 +++++++++++++ frontend/src/components/Layout.tsx | 108 +++++++++ frontend/src/components/ProtectedRoute.tsx | 30 +++ frontend/src/context/AuthContext.tsx | 81 +++++++ frontend/src/index.css | 34 +++ frontend/src/main.tsx | 10 + frontend/src/pages/About.tsx | 171 ++++++++++++++ frontend/src/pages/AdminUsers.tsx | 183 +++++++++++++++ frontend/src/pages/Analysis.tsx | 135 +++++++++++ frontend/src/pages/Analytics.tsx | 179 +++++++++++++++ frontend/src/pages/Batch.tsx | 248 +++++++++++++++++++++ frontend/src/pages/Login.tsx | 121 ++++++++++ frontend/src/pages/Register.tsx | 153 +++++++++++++ frontend/src/types/index.ts | 46 ++++ frontend/src/vite-env.d.ts | 9 + frontend/tailwind.config.js | 32 +++ frontend/tsconfig.json | 24 ++ frontend/vite.config.ts | 16 ++ 25 files changed, 1942 insertions(+) create mode 100644 frontend/.gitignore create mode 100644 frontend/Dockerfile create mode 100644 frontend/index.html create mode 100644 frontend/nginx.conf create mode 100644 frontend/package.json create mode 100644 frontend/postcss.config.js create mode 100644 frontend/src/App.tsx create mode 100644 frontend/src/api/client.ts create mode 100644 frontend/src/components/Layout.tsx create mode 100644 frontend/src/components/ProtectedRoute.tsx create mode 100644 frontend/src/context/AuthContext.tsx create mode 100644 frontend/src/index.css create mode 100644 frontend/src/main.tsx create mode 100644 frontend/src/pages/About.tsx create mode 100644 frontend/src/pages/AdminUsers.tsx create mode 100644 frontend/src/pages/Analysis.tsx create mode 100644 frontend/src/pages/Analytics.tsx create mode 100644 frontend/src/pages/Batch.tsx create mode 100644 frontend/src/pages/Login.tsx create mode 100644 frontend/src/pages/Register.tsx create mode 100644 frontend/src/types/index.ts create mode 100644 frontend/src/vite-env.d.ts create mode 100644 frontend/tailwind.config.js create mode 100644 frontend/tsconfig.json create mode 100644 frontend/vite.config.ts diff --git a/frontend/.gitignore b/frontend/.gitignore new file mode 100644 index 0000000..e01ce8f --- /dev/null +++ b/frontend/.gitignore @@ -0,0 +1,22 @@ +# Dependencies +node_modules/ + +# Build output +dist/ + +# Local env files +.env.local +.env.*.local + +# Editor directories +.vscode/ +.idea/ + +# OS files +.DS_Store +Thumbs.db + +# Debug logs +npm-debug.log* +yarn-debug.log* +yarn-error.log* diff --git a/frontend/Dockerfile b/frontend/Dockerfile new file mode 100644 index 0000000..00f8746 --- /dev/null +++ b/frontend/Dockerfile @@ -0,0 +1,29 @@ +# Build stage +FROM node:20-alpine AS build + +WORKDIR /app + +# Copy package files +COPY package.json package-lock.json* ./ + +# Install dependencies +RUN npm install + +# Copy source files +COPY . . + +# Build the application +RUN npm run build + +# Production stage +FROM nginx:alpine + +# Copy built files +COPY --from=build /app/dist /usr/share/nginx/html + +# Copy nginx config +COPY nginx.conf /etc/nginx/conf.d/default.conf + +EXPOSE 80 + +CMD ["nginx", "-g", "daemon off;"] diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..631e457 --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,13 @@ + + + + + + + SPARC Dashboard + + +
+ + + diff --git a/frontend/nginx.conf b/frontend/nginx.conf new file mode 100644 index 0000000..a16abcf --- /dev/null +++ b/frontend/nginx.conf @@ -0,0 +1,34 @@ +server { + listen 80; + server_name localhost; + root /usr/share/nginx/html; + index index.html; + + # Gzip compression + gzip on; + gzip_types text/plain text/css application/json application/javascript text/xml application/xml application/xml+rss text/javascript; + + # Handle React Router (SPA) + location / { + try_files $uri $uri/ /index.html; + } + + # Proxy API requests to backend + location /api/ { + proxy_pass http://api:8000/; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection 'upgrade'; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_cache_bypass $http_upgrade; + } + + # Cache static assets + location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2)$ { + expires 1y; + add_header Cache-Control "public, immutable"; + } +} diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..b99eee1 --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,37 @@ +{ + "name": "sparc-dashboard", + "private": true, + "version": "1.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc -b && vite build", + "lint": "eslint .", + "preview": "vite preview" + }, + "dependencies": { + "@tanstack/react-query": "^5.51.0", + "axios": "^1.7.2", + "lucide-react": "^0.400.0", + "react": "^18.3.1", + "react-dom": "^18.3.1", + "react-router-dom": "^6.24.0", + "recharts": "^2.12.7" + }, + "devDependencies": { + "@eslint/js": "^9.6.0", + "@types/react": "^18.3.3", + "@types/react-dom": "^18.3.0", + "@vitejs/plugin-react": "^4.3.1", + "autoprefixer": "^10.4.19", + "eslint": "^9.6.0", + "eslint-plugin-react-hooks": "^5.1.0", + "eslint-plugin-react-refresh": "^0.4.7", + "globals": "^15.8.0", + "postcss": "^8.4.39", + "tailwindcss": "^3.4.4", + "typescript": "~5.5.3", + "typescript-eslint": "^8.0.0", + "vite": "^5.3.3" + } +} diff --git a/frontend/postcss.config.js b/frontend/postcss.config.js new file mode 100644 index 0000000..2e7af2b --- /dev/null +++ b/frontend/postcss.config.js @@ -0,0 +1,6 @@ +export default { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, +} diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx new file mode 100644 index 0000000..c3426cd --- /dev/null +++ b/frontend/src/App.tsx @@ -0,0 +1,67 @@ +import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { AuthProvider } from './context/AuthContext'; +import { Layout } from './components/Layout'; +import { ProtectedRoute } from './components/ProtectedRoute'; +import { Login } from './pages/Login'; +import { Register } from './pages/Register'; +import { Analysis } from './pages/Analysis'; +import { Batch } from './pages/Batch'; +import { AnalyticsPage } from './pages/Analytics'; +import { About } from './pages/About'; +import { AdminUsers } from './pages/AdminUsers'; + +const queryClient = new QueryClient({ + defaultOptions: { + queries: { + staleTime: 1000 * 60 * 5, // 5 minutes + retry: 1, + }, + }, +}); + +function App() { + return ( + + + + + {/* Public routes */} + } /> + } /> + + {/* Protected routes */} + + + + } + > + } /> + } /> + } /> + } /> + + {/* Admin routes */} + + + + } + /> + + + {/* Default redirect */} + } /> + } /> + + + + + ); +} + +export default App; diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts new file mode 100644 index 0000000..037d59c --- /dev/null +++ b/frontend/src/api/client.ts @@ -0,0 +1,154 @@ +import axios, { AxiosError, InternalAxiosRequestConfig } from 'axios'; +import type { TokenResponse, User, CompanyAnalysis, BatchAnalysisResult, JobStatus, Analytics } from '../types'; + +const API_BASE_URL = import.meta.env.VITE_API_URL || '/api'; + +const api = axios.create({ + baseURL: API_BASE_URL, + headers: { + 'Content-Type': 'application/json', + }, +}); + +// Token management +let accessToken: string | null = localStorage.getItem('access_token'); +let refreshToken: string | null = localStorage.getItem('refresh_token'); + +export const setTokens = (tokens: TokenResponse) => { + accessToken = tokens.access_token; + refreshToken = tokens.refresh_token; + localStorage.setItem('access_token', tokens.access_token); + localStorage.setItem('refresh_token', tokens.refresh_token); +}; + +export const clearTokens = () => { + accessToken = null; + refreshToken = null; + localStorage.removeItem('access_token'); + localStorage.removeItem('refresh_token'); +}; + +export const getAccessToken = () => accessToken; + +// Request interceptor to add auth header +api.interceptors.request.use((config: InternalAxiosRequestConfig) => { + if (accessToken) { + config.headers.Authorization = `Bearer ${accessToken}`; + } + return config; +}); + +// Response interceptor to handle token refresh +api.interceptors.response.use( + (response) => response, + async (error: AxiosError) => { + const originalRequest = error.config as InternalAxiosRequestConfig & { _retry?: boolean }; + + if (error.response?.status === 401 && !originalRequest._retry && refreshToken) { + originalRequest._retry = true; + + try { + const response = await axios.post(`${API_BASE_URL}/auth/refresh`, { + refresh_token: refreshToken, + }); + + setTokens(response.data); + originalRequest.headers.Authorization = `Bearer ${response.data.access_token}`; + + return api(originalRequest); + } catch { + clearTokens(); + window.location.href = '/login'; + } + } + + return Promise.reject(error); + } +); + +// Auth API +export const authApi = { + register: async (email: string, password: string): Promise => { + const response = await api.post('/auth/register', { email, password }); + return response.data; + }, + + login: async (email: string, password: string): Promise => { + const response = await api.post('/auth/login', { email, password }); + setTokens(response.data); + return response.data; + }, + + getMe: async (): Promise => { + const response = await api.get('/auth/me'); + return response.data; + }, + + logout: () => { + clearTokens(); + }, +}; + +// Analysis API +export const analysisApi = { + analyzeCompany: async (companyName: string): Promise => { + const response = await api.get(`/analyze/${encodeURIComponent(companyName)}`); + return response.data; + }, + + analyzeBatch: async (companies: string[], maxWorkers = 3): Promise => { + const response = await api.post('/analyze/batch', { + companies, + max_workers: maxWorkers, + }); + return response.data; + }, + + analyzeBatchAsync: async (companies: string[], maxWorkers = 3): Promise => { + const response = await api.post('/analyze/batch/async', { + companies, + max_workers: maxWorkers, + }); + return response.data; + }, + + getJobStatus: async (jobId: string): Promise => { + const response = await api.get(`/jobs/${jobId}`); + return response.data; + }, + + listJobs: async (status?: string, limit = 10): Promise => { + const params = new URLSearchParams(); + if (status) params.append('status', status); + params.append('limit', limit.toString()); + const response = await api.get(`/jobs?${params}`); + return response.data; + }, +}; + +// Analytics API +export const analyticsApi = { + getAnalytics: async (days = 30): Promise => { + const response = await api.get(`/analytics?days=${days}`); + return response.data; + }, +}; + +// Admin API +export const adminApi = { + listUsers: async (limit = 100, offset = 0): Promise => { + const response = await api.get(`/admin/users?limit=${limit}&offset=${offset}`); + return response.data; + }, + + updateUserRole: async (userId: number, role: 'admin' | 'user'): Promise => { + const response = await api.patch(`/admin/users/${userId}/role`, { role }); + return response.data; + }, + + deleteUser: async (userId: number): Promise => { + await api.delete(`/admin/users/${userId}`); + }, +}; + +export default api; diff --git a/frontend/src/components/Layout.tsx b/frontend/src/components/Layout.tsx new file mode 100644 index 0000000..501dc1f --- /dev/null +++ b/frontend/src/components/Layout.tsx @@ -0,0 +1,108 @@ +import { Outlet, NavLink, useNavigate } from 'react-router-dom'; +import { useAuth } from '../context/AuthContext'; +import { Search, Layers, BarChart3, Info, Users, LogOut } from 'lucide-react'; + +export function Layout() { + const { user, isAdmin, logout } = useAuth(); + const navigate = useNavigate(); + + const handleLogout = () => { + logout(); + navigate('/login'); + }; + + const navItems = [ + { to: '/analysis', icon: Search, label: 'Analysis' }, + { to: '/batch', icon: Layers, label: 'Batch' }, + { to: '/analytics', icon: BarChart3, label: 'Analytics' }, + { to: '/about', icon: Info, label: 'About' }, + ]; + + if (isAdmin) { + navItems.push({ to: '/admin/users', icon: Users, label: 'Users' }); + } + + return ( +
+ {/* Header */} +
+
+
+ {/* Brand */} +
+ âš¡ +
+

+ SPARC +

+ + Semiconductor Patent Analytics + +
+
+ + {/* Navigation */} + + + {/* User menu */} +
+
+
{user?.email}
+
{user?.role}
+
+ +
+
+
+
+ + {/* Mobile Navigation */} + + + {/* Main content */} +
+ +
+
+ ); +} diff --git a/frontend/src/components/ProtectedRoute.tsx b/frontend/src/components/ProtectedRoute.tsx new file mode 100644 index 0000000..667057d --- /dev/null +++ b/frontend/src/components/ProtectedRoute.tsx @@ -0,0 +1,30 @@ +import { Navigate, useLocation } from 'react-router-dom'; +import { useAuth } from '../context/AuthContext'; + +interface ProtectedRouteProps { + children: React.ReactNode; + requireAdmin?: boolean; +} + +export function ProtectedRoute({ children, requireAdmin = false }: ProtectedRouteProps) { + const { isAuthenticated, isAdmin, isLoading } = useAuth(); + const location = useLocation(); + + if (isLoading) { + return ( +
+
+
+ ); + } + + if (!isAuthenticated) { + return ; + } + + if (requireAdmin && !isAdmin) { + return ; + } + + return <>{children}; +} diff --git a/frontend/src/context/AuthContext.tsx b/frontend/src/context/AuthContext.tsx new file mode 100644 index 0000000..d40eca8 --- /dev/null +++ b/frontend/src/context/AuthContext.tsx @@ -0,0 +1,81 @@ +import { createContext, useContext, useState, useEffect, ReactNode } from 'react'; +import { authApi, getAccessToken } from '../api/client'; +import type { User } from '../types'; + +interface AuthContextType { + user: User | null; + isLoading: boolean; + isAuthenticated: boolean; + isAdmin: boolean; + login: (email: string, password: string) => Promise; + register: (email: string, password: string) => Promise; + logout: () => void; + refreshUser: () => Promise; +} + +const AuthContext = createContext(undefined); + +export function AuthProvider({ children }: { children: ReactNode }) { + const [user, setUser] = useState(null); + const [isLoading, setIsLoading] = useState(true); + + const refreshUser = async () => { + try { + const userData = await authApi.getMe(); + setUser(userData); + } catch { + setUser(null); + } + }; + + useEffect(() => { + const initAuth = async () => { + if (getAccessToken()) { + await refreshUser(); + } + setIsLoading(false); + }; + initAuth(); + }, []); + + const login = async (email: string, password: string) => { + await authApi.login(email, password); + await refreshUser(); + }; + + const register = async (email: string, password: string) => { + await authApi.register(email, password); + await authApi.login(email, password); + await refreshUser(); + }; + + const logout = () => { + authApi.logout(); + setUser(null); + }; + + return ( + + {children} + + ); +} + +export function useAuth() { + const context = useContext(AuthContext); + if (context === undefined) { + throw new Error('useAuth must be used within an AuthProvider'); + } + return context; +} diff --git a/frontend/src/index.css b/frontend/src/index.css new file mode 100644 index 0000000..b94918a --- /dev/null +++ b/frontend/src/index.css @@ -0,0 +1,34 @@ +@tailwind base; +@tailwind components; +@tailwind utilities; + +body { + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; +} + +/* Custom scrollbar */ +::-webkit-scrollbar { + width: 8px; + height: 8px; +} + +::-webkit-scrollbar-track { + background: #1e293b; +} + +::-webkit-scrollbar-thumb { + background: #6366f1; + border-radius: 4px; +} + +::-webkit-scrollbar-thumb:hover { + background: #4f46e5; +} + +/* Selection */ +::selection { + background: rgba(99, 102, 241, 0.3); + color: #f8fafc; +} diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx new file mode 100644 index 0000000..a46835a --- /dev/null +++ b/frontend/src/main.tsx @@ -0,0 +1,10 @@ +import { StrictMode } from 'react'; +import { createRoot } from 'react-dom/client'; +import App from './App'; +import './index.css'; + +createRoot(document.getElementById('root')!).render( + + + +); diff --git a/frontend/src/pages/About.tsx b/frontend/src/pages/About.tsx new file mode 100644 index 0000000..4c71bac --- /dev/null +++ b/frontend/src/pages/About.tsx @@ -0,0 +1,171 @@ +import { useQuery } from '@tanstack/react-query'; +import axios from 'axios'; +import { Search, FileText, Bot, Zap, Globe, BarChart3, CheckCircle, AlertTriangle, XCircle } from 'lucide-react'; + +const API_BASE_URL = import.meta.env.VITE_API_URL || '/api'; + +export function About() { + const { data: health } = useQuery({ + queryKey: ['health'], + queryFn: async () => { + const response = await axios.get(`${API_BASE_URL}/health`); + return response.data; + }, + refetchInterval: 30000, + }); + + const features = [ + { + icon: Search, + title: 'Patent Retrieval', + description: 'Automated collection via SerpAPI\'s Google Patents', + }, + { + icon: FileText, + title: 'Intelligent Parsing', + description: 'Extracts key sections from patent documents', + }, + { + icon: Bot, + title: 'AI Analysis', + description: 'Deep analysis powered by Claude 3.5 Sonnet', + }, + { + icon: Zap, + title: 'Batch Processing', + description: 'Analyze multiple companies concurrently', + }, + { + icon: Globe, + title: 'REST API', + description: 'FastAPI web service for seamless integration', + }, + { + icon: BarChart3, + title: 'Analytics', + description: 'Track and visualize historical analysis data', + }, + ]; + + const techStack = [ + { label: 'Backend', value: 'Python, FastAPI' }, + { label: 'AI Model', value: 'Claude 3.5 Sonnet' }, + { label: 'Database', value: 'PostgreSQL' }, + { label: 'Frontend', value: 'React, TailwindCSS' }, + { label: 'Data Source', value: 'SerpAPI Patents' }, + ]; + + return ( +
+ {/* Header */} +
+

+ About SPARC +

+
+ +
+ {/* Main Content */} +
+ {/* Description */} +

+ SPARC (Semiconductor Patent & Analytics Report Core) + is an AI-powered patent analysis platform that evaluates company performance by analyzing their + patent portfolios with cutting-edge language models. +

+ + {/* Features */} +
+

Key Features

+
+ {features.map(({ icon: Icon, title, description }) => ( +
+
+ +
+
+
{title}
+
{description}
+
+
+ ))} +
+
+
+ + {/* Sidebar */} +
+ {/* Tech Stack */} +
+

Technology Stack

+
+ {techStack.map(({ label, value }) => ( +
+
{label}
+
{value}
+
+ ))} +
+
+ + {/* API Endpoints */} +
+

API Endpoints

+
+ + http://localhost:8000/docs + + + http://localhost:8000/health + +
+
+
+
+ + {/* System Status */} +
+

+ System Status +

+
+ + + +
+
+
+ ); +} + +function StatusCard({ label, status }: { label: string; status: 'online' | 'offline' | 'configured' }) { + const statusConfig = { + online: { icon: CheckCircle, color: 'text-success', bg: 'bg-success' }, + offline: { icon: XCircle, color: 'text-error', bg: 'bg-error' }, + configured: { icon: AlertTriangle, color: 'text-warning', bg: 'bg-warning' }, + }; + + const { icon: Icon, color, bg } = statusConfig[status]; + + return ( +
+
+ +
+
{label}
+
{status}
+
+ ); +} diff --git a/frontend/src/pages/AdminUsers.tsx b/frontend/src/pages/AdminUsers.tsx new file mode 100644 index 0000000..632905c --- /dev/null +++ b/frontend/src/pages/AdminUsers.tsx @@ -0,0 +1,183 @@ +import { useState } from 'react'; +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; +import { adminApi } from '../api/client'; +import { useAuth } from '../context/AuthContext'; +import { Users, Shield, User, Trash2, AlertCircle } from 'lucide-react'; +import type { User as UserType } from '../types'; + +export function AdminUsers() { + const { user: currentUser } = useAuth(); + const queryClient = useQueryClient(); + const [deleteConfirm, setDeleteConfirm] = useState(null); + + const { data: users, isLoading, isError } = useQuery({ + queryKey: ['admin-users'], + queryFn: () => adminApi.listUsers(), + }); + + const updateRoleMutation = useMutation({ + mutationFn: ({ userId, role }: { userId: number; role: 'admin' | 'user' }) => + adminApi.updateUserRole(userId, role), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['admin-users'] }); + }, + }); + + const deleteMutation = useMutation({ + mutationFn: (userId: number) => adminApi.deleteUser(userId), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['admin-users'] }); + setDeleteConfirm(null); + }, + }); + + const handleRoleChange = (user: UserType) => { + const newRole = user.role === 'admin' ? 'user' : 'admin'; + updateRoleMutation.mutate({ userId: user.id, role: newRole }); + }; + + const handleDelete = (userId: number) => { + deleteMutation.mutate(userId); + }; + + if (isLoading) { + return ( +
+
+
+ ); + } + + if (isError) { + return ( +
+ + Failed to load users. +
+ ); + } + + return ( +
+ {/* Header */} +
+
+

+ User Management +

+

Manage user accounts and permissions.

+
+
+ + {users?.length || 0} Users +
+
+ + {/* Users Table */} +
+
+ + + + + + + + + + + {users?.map((user) => ( + + + + + + + ))} + +
+ User + + Role + + Created + + Actions +
+
+
+ {user.role === 'admin' ? ( + + ) : ( + + )} +
+
+
{user.email}
+ {user.id === currentUser?.id && ( + (You) + )} +
+
+
+ + {user.role === 'admin' ? : } + {user.role} + + + {new Date(user.created_at).toLocaleDateString()} + +
+ {user.id !== currentUser?.id && ( + <> + + + {deleteConfirm === user.id ? ( +
+ + +
+ ) : ( + + )} + + )} +
+
+
+
+
+ ); +} diff --git a/frontend/src/pages/Analysis.tsx b/frontend/src/pages/Analysis.tsx new file mode 100644 index 0000000..2dfd2f5 --- /dev/null +++ b/frontend/src/pages/Analysis.tsx @@ -0,0 +1,135 @@ +import { useState } from 'react'; +import { useMutation } from '@tanstack/react-query'; +import { analysisApi } from '../api/client'; +import { Search, CheckCircle, AlertCircle, Clock, FileText } from 'lucide-react'; +import type { CompanyAnalysis } from '../types'; + +export function Analysis() { + const [companyName, setCompanyName] = useState(''); + const [result, setResult] = useState(null); + + const mutation = useMutation({ + mutationFn: (name: string) => analysisApi.analyzeCompany(name), + onSuccess: (data) => setResult(data), + }); + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault(); + if (companyName.trim()) { + mutation.mutate(companyName.trim()); + } + }; + + return ( +
+ {/* Header */} +
+

+ Single Company Analysis +

+

+ Analyze a company's patent portfolio using AI-powered insights. +

+
+ + {/* Search Form */} +
+
+ + setCompanyName(e.target.value)} + placeholder="Enter company name (e.g., nvidia, intel, amd)" + className="w-full bg-bg-card/80 border border-primary/30 rounded-xl pl-12 pr-4 py-3 text-text-primary placeholder-text-secondary/50 focus:outline-none focus:border-primary focus:ring-2 focus:ring-primary/20 transition-all" + /> +
+ +
+ + {/* Error */} + {mutation.isError && ( +
+ + Analysis failed. Please try again. +
+ )} + + {/* Results */} + {result && ( +
+ {/* Success/Failure Status */} + {result.success ? ( +
+ + Analysis complete for {result.company_name.toUpperCase()} +
+ ) : ( +
+ + Analysis failed: {result.error} +
+ )} + + {/* Metrics */} +
+ + + +
+ + {/* Analysis Content */} + {result.success && result.analysis && ( +
+

+ AI Analysis Results +

+
+
+ {result.analysis} +
+
+
+ )} +
+ )} +
+ ); +} + +function MetricCard({ icon: Icon, label, value }: { icon: typeof FileText; label: string; value: string }) { + return ( +
+ +
+ {value} +
+
{label}
+
+ ); +} diff --git a/frontend/src/pages/Analytics.tsx b/frontend/src/pages/Analytics.tsx new file mode 100644 index 0000000..19f4aff --- /dev/null +++ b/frontend/src/pages/Analytics.tsx @@ -0,0 +1,179 @@ +import { useState } from 'react'; +import { useQuery } from '@tanstack/react-query'; +import { analyticsApi } from '../api/client'; +import { AlertCircle, Database } from 'lucide-react'; +import { PieChart, Pie, Cell, BarChart, Bar, XAxis, YAxis, Tooltip, ResponsiveContainer, Legend } from 'recharts'; + +const COLORS = ['#6366f1', '#0ea5e9', '#10b981', '#f59e0b', '#ef4444', '#8b5cf6', '#ec4899', '#14b8a6']; + +export function AnalyticsPage() { + const [days, setDays] = useState(30); + + const { data, isLoading, isError } = useQuery({ + queryKey: ['analytics', days], + queryFn: () => analyticsApi.getAnalytics(days), + }); + + if (isLoading) { + return ( +
+
+
+ ); + } + + if (isError) { + return ( +
+
+

+ Analytics Dashboard +

+
+
+
+ + Database Not Connected +
+

+ Set USE_DATABASE=true in your .env file to enable analytics tracking. +

+
+
+ + Analytics features require storing analysis results in PostgreSQL for historical tracking. +
+
+ ); + } + + if (!data || (data.total_messages === 0 && data.by_company.length === 0)) { + return ( +
+
+

+ Analytics Dashboard +

+

Track historical analysis data and view insights.

+
+
+ + No analytics data available yet. Run some analyses first! +
+
+ ); + } + + const companyData = data.by_company.map((c) => ({ + name: (c.company_name || 'Unknown').toUpperCase(), + value: c.count, + })); + + const typeData = data.by_type.map((t) => ({ + name: t.analysis_type || 'Unknown', + count: t.count, + })); + + return ( +
+ {/* Header */} +
+
+

+ Analytics Dashboard +

+

Track historical analysis data and view insights.

+
+ + {/* Time Range Selector */} + +
+ + {/* Summary Metrics */} +
+ + + +
+ + {/* Charts */} +
+ {/* Pie Chart - Distribution by Company */} + {companyData.length > 0 && ( +
+

Distribution by Company

+ + + `${name} ${(percent * 100).toFixed(0)}%`} + labelLine={false} + > + {companyData.map((_, index) => ( + + ))} + + + + + +
+ )} + + {/* Bar Chart - Analysis Types */} + {typeData.length > 0 && ( +
+

Analysis Types

+ + + + + + + + +
+ )} +
+
+ ); +} + +function MetricCard({ label, value }: { label: string; value: number }) { + return ( +
+
+ {value} +
+
{label}
+
+ ); +} diff --git a/frontend/src/pages/Batch.tsx b/frontend/src/pages/Batch.tsx new file mode 100644 index 0000000..9b9b351 --- /dev/null +++ b/frontend/src/pages/Batch.tsx @@ -0,0 +1,248 @@ +import { useState } from 'react'; +import { useMutation } from '@tanstack/react-query'; +import { analysisApi } from '../api/client'; +import { Rocket, CheckCircle, AlertCircle, ChevronDown, ChevronUp } from 'lucide-react'; +import { BarChart, Bar, XAxis, YAxis, Tooltip, ResponsiveContainer, Cell } from 'recharts'; +import type { BatchAnalysisResult } from '../types'; + +export function Batch() { + const [companiesInput, setCompaniesInput] = useState(''); + const [maxWorkers, setMaxWorkers] = useState(3); + const [result, setResult] = useState(null); + const [expandedItems, setExpandedItems] = useState>(new Set()); + + const mutation = useMutation({ + mutationFn: ({ companies, workers }: { companies: string[]; workers: number }) => + analysisApi.analyzeBatch(companies, workers), + onSuccess: (data) => setResult(data), + }); + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault(); + const companies = companiesInput + .split(/[,\n]/) + .map((c) => c.trim()) + .filter((c) => c.length > 0); + + if (companies.length > 0) { + mutation.mutate({ companies, workers: maxWorkers }); + } + }; + + const toggleExpand = (company: string) => { + const newExpanded = new Set(expandedItems); + if (newExpanded.has(company)) { + newExpanded.delete(company); + } else { + newExpanded.add(company); + } + setExpandedItems(newExpanded); + }; + + const chartData = result?.results.map((r) => ({ + name: r.company_name.toUpperCase(), + patents: r.patent_count, + success: r.success, + })); + + return ( +
+ {/* Header */} +
+

+ Batch Company Analysis +

+

+ Analyze multiple companies simultaneously for comparative insights. +

+
+ + {/* Input Form */} +
+
+