This commit is contained in:
Yaojia Wang
2026-01-27 00:47:10 +01:00
parent e83a0cae36
commit 58bf75db68
141 changed files with 24814 additions and 3884 deletions

5
frontend/.env.example Normal file
View File

@@ -0,0 +1,5 @@
# Backend API URL
VITE_API_URL=http://localhost:8000
# WebSocket URL (for future real-time updates)
VITE_WS_URL=ws://localhost:8000/ws

24
frontend/.gitignore vendored Normal file
View File

@@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

20
frontend/README.md Normal file
View File

@@ -0,0 +1,20 @@
<div align="center">
<img width="1200" height="475" alt="GHBanner" src="https://github.com/user-attachments/assets/0aa67016-6eaf-458a-adb2-6e31a0763ed6" />
</div>
# Run and deploy your AI Studio app
This contains everything you need to run your app locally.
View your app in AI Studio: https://ai.studio/apps/drive/13hqd80ft4g_LngMYB8LLJxx2XU8C_eI4
## Run Locally
**Prerequisites:** Node.js
1. Install dependencies:
`npm install`
2. Set the `GEMINI_API_KEY` in [.env.local](.env.local) to your Gemini API key
3. Run the app:
`npm run dev`

View File

@@ -0,0 +1,240 @@
# Frontend Refactoring Plan
## Current Structure Issues
1. **Flat component organization** - All components in one directory
2. **Mock data only** - No real API integration
3. **No state management** - Props drilling everywhere
4. **CDN dependencies** - Should use npm packages
5. **Manual routing** - Using useState instead of react-router
6. **No TypeScript integration with backend** - Types don't match API schemas
## Recommended Structure
```
frontend/
├── public/
│ └── favicon.ico
├── src/
│ ├── api/ # API Layer
│ │ ├── client.ts # Axios instance + interceptors
│ │ ├── types.ts # API request/response types
│ │ └── endpoints/
│ │ ├── documents.ts # GET /api/v1/admin/documents
│ │ ├── annotations.ts # GET/POST /api/v1/admin/documents/{id}/annotations
│ │ ├── training.ts # GET/POST /api/v1/admin/training/*
│ │ ├── inference.ts # POST /api/v1/infer
│ │ └── async.ts # POST /api/v1/async/submit
│ │
│ ├── components/
│ │ ├── common/ # Reusable components
│ │ │ ├── Badge.tsx
│ │ │ ├── Button.tsx
│ │ │ ├── Input.tsx
│ │ │ ├── Modal.tsx
│ │ │ ├── Table.tsx
│ │ │ ├── ProgressBar.tsx
│ │ │ └── StatusBadge.tsx
│ │ │
│ │ ├── layout/ # Layout components
│ │ │ ├── TopNav.tsx
│ │ │ ├── Sidebar.tsx
│ │ │ └── PageHeader.tsx
│ │ │
│ │ ├── documents/ # Document-specific components
│ │ │ ├── DocumentTable.tsx
│ │ │ ├── DocumentFilters.tsx
│ │ │ ├── DocumentRow.tsx
│ │ │ ├── UploadModal.tsx
│ │ │ └── BatchUploadModal.tsx
│ │ │
│ │ ├── annotations/ # Annotation components
│ │ │ ├── AnnotationCanvas.tsx
│ │ │ ├── AnnotationBox.tsx
│ │ │ ├── AnnotationTable.tsx
│ │ │ ├── FieldEditor.tsx
│ │ │ └── VerificationPanel.tsx
│ │ │
│ │ └── training/ # Training components
│ │ ├── DocumentSelector.tsx
│ │ ├── TrainingConfig.tsx
│ │ ├── TrainingJobList.tsx
│ │ ├── ModelCard.tsx
│ │ └── MetricsChart.tsx
│ │
│ ├── pages/ # Page-level components
│ │ ├── DocumentsPage.tsx # Was Dashboard.tsx
│ │ ├── DocumentDetailPage.tsx # Was DocumentDetail.tsx
│ │ ├── TrainingPage.tsx # Was Training.tsx
│ │ ├── ModelsPage.tsx # Was Models.tsx
│ │ └── InferencePage.tsx # New: Test inference
│ │
│ ├── hooks/ # Custom React Hooks
│ │ ├── useDocuments.ts # Document CRUD + listing
│ │ ├── useAnnotations.ts # Annotation management
│ │ ├── useTraining.ts # Training jobs
│ │ ├── usePolling.ts # Auto-refresh for async jobs
│ │ └── useDebounce.ts # Debounce search inputs
│ │
│ ├── store/ # State Management (Zustand)
│ │ ├── documentsStore.ts
│ │ ├── annotationsStore.ts
│ │ ├── trainingStore.ts
│ │ └── uiStore.ts
│ │
│ ├── types/ # TypeScript Types
│ │ ├── index.ts
│ │ ├── document.ts
│ │ ├── annotation.ts
│ │ ├── training.ts
│ │ └── api.ts
│ │
│ ├── utils/ # Utility Functions
│ │ ├── formatters.ts # Date, currency, etc.
│ │ ├── validators.ts # Form validation
│ │ └── constants.ts # Field definitions, statuses
│ │
│ ├── styles/
│ │ └── index.css # Tailwind entry
│ │
│ ├── App.tsx
│ ├── main.tsx
│ └── router.tsx # React Router config
├── .env.example
├── package.json
├── tsconfig.json
├── vite.config.ts
├── tailwind.config.js
├── postcss.config.js
└── index.html
```
## Migration Steps
### Phase 1: Setup Infrastructure
- [ ] Install dependencies (axios, react-router, zustand, @tanstack/react-query)
- [ ] Setup local Tailwind (remove CDN)
- [ ] Create API client with interceptors
- [ ] Add environment variables (.env.local with VITE_API_URL)
### Phase 2: Create API Layer
- [ ] Create `src/api/client.ts` with axios instance
- [ ] Create `src/api/endpoints/documents.ts` matching backend API
- [ ] Create `src/api/endpoints/annotations.ts`
- [ ] Create `src/api/endpoints/training.ts`
- [ ] Add types matching backend schemas
### Phase 3: Reorganize Components
- [ ] Move existing components to new structure
- [ ] Split large components (Dashboard > DocumentTable + DocumentFilters + DocumentRow)
- [ ] Extract reusable components (Badge, Button already done)
- [ ] Create layout components (TopNav, Sidebar)
### Phase 4: Add Routing
- [ ] Install react-router-dom
- [ ] Create router.tsx with routes
- [ ] Update App.tsx to use RouterProvider
- [ ] Add navigation links
### Phase 5: State Management
- [ ] Create custom hooks (useDocuments, useAnnotations)
- [ ] Use @tanstack/react-query for server state
- [ ] Add Zustand stores for UI state
- [ ] Replace mock data with API calls
### Phase 6: Backend Integration
- [ ] Update CORS settings in backend
- [ ] Test all API endpoints
- [ ] Add error handling
- [ ] Add loading states
## Dependencies to Add
```json
{
"dependencies": {
"react-router-dom": "^6.22.0",
"axios": "^1.6.7",
"zustand": "^4.5.0",
"@tanstack/react-query": "^5.20.0",
"date-fns": "^3.3.0",
"clsx": "^2.1.0"
},
"devDependencies": {
"tailwindcss": "^3.4.1",
"autoprefixer": "^10.4.17",
"postcss": "^8.4.35"
}
}
```
## Configuration Files to Create
### tailwind.config.js
```javascript
export default {
content: ['./index.html', './src/**/*.{js,ts,jsx,tsx}'],
theme: {
extend: {
colors: {
warm: {
bg: '#FAFAF8',
card: '#FFFFFF',
hover: '#F1F0ED',
selected: '#ECEAE6',
border: '#E6E4E1',
divider: '#D8D6D2',
text: {
primary: '#121212',
secondary: '#2A2A2A',
muted: '#6B6B6B',
disabled: '#9A9A9A',
},
state: {
success: '#3E4A3A',
error: '#4A3A3A',
warning: '#4A4A3A',
info: '#3A3A3A',
}
}
}
}
}
}
```
### .env.example
```bash
VITE_API_URL=http://localhost:8000
VITE_WS_URL=ws://localhost:8000/ws
```
## Type Generation from Backend
Consider generating TypeScript types from Python Pydantic schemas:
- Option 1: Use `datamodel-code-generator` to convert schemas
- Option 2: Manually maintain types in `src/types/api.ts`
- Option 3: Use OpenAPI spec + openapi-typescript-codegen
## Testing Strategy
- Unit tests: Vitest for components
- Integration tests: React Testing Library
- E2E tests: Playwright (matching backend)
## Performance Considerations
- Code splitting by route
- Lazy load heavy components (AnnotationCanvas)
- Optimize re-renders with React.memo
- Use virtual scrolling for large tables
- Image lazy loading for document previews
## Accessibility
- Proper ARIA labels
- Keyboard navigation
- Focus management
- Color contrast compliance (already done with Warm Graphite theme)

256
frontend/SETUP.md Normal file
View File

@@ -0,0 +1,256 @@
# Frontend Setup Guide
## Quick Start
### 1. Install Dependencies
```bash
cd frontend
npm install
```
### 2. Configure Environment
Copy `.env.example` to `.env.local` and update if needed:
```bash
cp .env.example .env.local
```
Default configuration:
```
VITE_API_URL=http://localhost:8000
VITE_WS_URL=ws://localhost:8000/ws
```
### 3. Start Backend API
Make sure the backend is running first:
```bash
# From project root
wsl bash -c "source ~/miniconda3/etc/profile.d/conda.sh && conda activate invoice-py311 && python run_server.py"
```
Backend will be available at: http://localhost:8000
### 4. Start Frontend Dev Server
```bash
cd frontend
npm run dev
```
Frontend will be available at: http://localhost:3000
## Project Structure
```
frontend/
├── src/
│ ├── api/ # API client layer
│ │ ├── client.ts # Axios instance with interceptors
│ │ ├── types.ts # API type definitions
│ │ └── endpoints/
│ │ ├── documents.ts # Document API calls
│ │ ├── annotations.ts # Annotation API calls
│ │ └── training.ts # Training API calls
│ │
│ ├── components/ # React components
│ │ └── Dashboard.tsx # Updated with real API integration
│ │
│ ├── hooks/ # Custom React Hooks
│ │ ├── useDocuments.ts
│ │ ├── useDocumentDetail.ts
│ │ ├── useAnnotations.ts
│ │ └── useTraining.ts
│ │
│ ├── styles/
│ │ └── index.css # Tailwind CSS entry
│ │
│ ├── App.tsx
│ └── main.tsx # App entry point with QueryClient
├── components/ # Legacy components (to be migrated)
│ ├── Badge.tsx
│ ├── Button.tsx
│ ├── Layout.tsx
│ ├── DocumentDetail.tsx
│ ├── Training.tsx
│ ├── Models.tsx
│ └── UploadModal.tsx
├── tailwind.config.js # Tailwind configuration
├── postcss.config.js
├── vite.config.ts
├── package.json
└── index.html
```
## Key Technologies
- **React 19** - UI framework
- **TypeScript** - Type safety
- **Vite** - Build tool
- **Tailwind CSS** - Styling (Warm Graphite theme)
- **Axios** - HTTP client
- **@tanstack/react-query** - Server state management
- **lucide-react** - Icon library
## API Integration
### Authentication
The app stores admin token in localStorage:
```typescript
localStorage.setItem('admin_token', 'your-token')
```
All API requests automatically include the `X-Admin-Token` header.
### Available Hooks
#### useDocuments
```typescript
const {
documents,
total,
isLoading,
uploadDocument,
deleteDocument,
triggerAutoLabel,
} = useDocuments({ status: 'labeled', limit: 20 })
```
#### useDocumentDetail
```typescript
const { document, annotations, isLoading } = useDocumentDetail(documentId)
```
#### useAnnotations
```typescript
const {
createAnnotation,
updateAnnotation,
deleteAnnotation,
verifyAnnotation,
overrideAnnotation,
} = useAnnotations(documentId)
```
#### useTraining
```typescript
const {
models,
isLoadingModels,
startTraining,
downloadModel,
} = useTraining()
```
## Features Implemented
### Phase 1 (Completed)
- ✅ API client with axios interceptors
- ✅ Type-safe API endpoints
- ✅ React Query for server state
- ✅ Custom hooks for all APIs
- ✅ Dashboard with real data
- ✅ Local Tailwind CSS
- ✅ Environment configuration
- ✅ CORS configured in backend
### Phase 2 (TODO)
- [ ] Update DocumentDetail to use useDocumentDetail
- [ ] Update Training page to use useTraining hooks
- [ ] Update Models page with real data
- [ ] Add UploadModal integration with API
- [ ] Add react-router for proper routing
- [ ] Add error boundary
- [ ] Add loading states
- [ ] Add toast notifications
### Phase 3 (TODO)
- [ ] Annotation canvas with real data
- [ ] Batch upload functionality
- [ ] Auto-label progress polling
- [ ] Training job monitoring
- [ ] Model download functionality
- [ ] Search and filtering
- [ ] Pagination
## Development Tips
### Hot Module Replacement
Vite supports HMR. Changes will reflect immediately without page reload.
### API Debugging
Check browser console for API requests:
- Network tab shows all requests/responses
- Axios interceptors log errors automatically
### Type Safety
TypeScript types in `src/api/types.ts` match backend Pydantic schemas.
To regenerate types from backend:
```bash
# TODO: Add type generation script
```
### Backend API Documentation
Visit http://localhost:8000/docs for interactive API documentation (Swagger UI).
## Troubleshooting
### CORS Errors
If you see CORS errors:
1. Check backend is running at http://localhost:8000
2. Verify CORS settings in `src/web/app.py`
3. Check `.env.local` has correct `VITE_API_URL`
### Module Not Found
If imports fail:
```bash
rm -rf node_modules package-lock.json
npm install
```
### Types Not Matching
If API responses don't match types:
1. Check backend version is up-to-date
2. Verify types in `src/api/types.ts`
3. Check API response in Network tab
## Next Steps
1. Run `npm install` to install dependencies
2. Start backend server
3. Run `npm run dev` to start frontend
4. Open http://localhost:3000
5. Create an admin token via backend API
6. Store token in localStorage via browser console:
```javascript
localStorage.setItem('admin_token', 'your-token-here')
```
7. Refresh page to see authenticated API calls
## Production Build
```bash
npm run build
npm run preview # Preview production build
```
Build output will be in `dist/` directory.

15
frontend/index.html Normal file
View File

@@ -0,0 +1,15 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Graphite Annotator - Invoice Field Extraction</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

5
frontend/metadata.json Normal file
View File

@@ -0,0 +1,5 @@
{
"name": "Graphite Annotator",
"description": "A professional, warm graphite themed document annotation and training tool for enterprise use cases.",
"requestFramePermissions": []
}

3510
frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

32
frontend/package.json Normal file
View File

@@ -0,0 +1,32 @@
{
"name": "graphite-annotator",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"react": "^19.2.3",
"react-dom": "^19.2.3",
"lucide-react": "^0.563.0",
"recharts": "^3.7.0",
"axios": "^1.6.7",
"react-router-dom": "^6.22.0",
"zustand": "^4.5.0",
"@tanstack/react-query": "^5.20.0",
"date-fns": "^3.3.0",
"clsx": "^2.1.0"
},
"devDependencies": {
"@types/node": "^22.14.0",
"@vitejs/plugin-react": "^5.0.0",
"typescript": "~5.8.2",
"vite": "^6.2.0",
"tailwindcss": "^3.4.1",
"autoprefixer": "^10.4.17",
"postcss": "^8.4.35"
}
}

View File

@@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}

73
frontend/src/App.tsx Normal file
View File

@@ -0,0 +1,73 @@
import React, { useState, useEffect } from 'react'
import { Layout } from './components/Layout'
import { DashboardOverview } from './components/DashboardOverview'
import { Dashboard } from './components/Dashboard'
import { DocumentDetail } from './components/DocumentDetail'
import { Training } from './components/Training'
import { Models } from './components/Models'
import { Login } from './components/Login'
import { InferenceDemo } from './components/InferenceDemo'
const App: React.FC = () => {
const [currentView, setCurrentView] = useState('dashboard')
const [selectedDocId, setSelectedDocId] = useState<string | null>(null)
const [isAuthenticated, setIsAuthenticated] = useState(false)
useEffect(() => {
const token = localStorage.getItem('admin_token')
setIsAuthenticated(!!token)
}, [])
const handleNavigate = (view: string, docId?: string) => {
setCurrentView(view)
if (docId) {
setSelectedDocId(docId)
}
}
const handleLogin = (token: string) => {
setIsAuthenticated(true)
}
const handleLogout = () => {
localStorage.removeItem('admin_token')
setIsAuthenticated(false)
setCurrentView('documents')
}
if (!isAuthenticated) {
return <Login onLogin={handleLogin} />
}
const renderContent = () => {
switch (currentView) {
case 'dashboard':
return <DashboardOverview onNavigate={handleNavigate} />
case 'documents':
return <Dashboard onNavigate={handleNavigate} />
case 'detail':
return (
<DocumentDetail
docId={selectedDocId || '1'}
onBack={() => setCurrentView('documents')}
/>
)
case 'demo':
return <InferenceDemo />
case 'training':
return <Training />
case 'models':
return <Models />
default:
return <DashboardOverview onNavigate={handleNavigate} />
}
}
return (
<Layout activeView={currentView} onNavigate={handleNavigate} onLogout={handleLogout}>
{renderContent()}
</Layout>
)
}
export default App

View File

@@ -0,0 +1,41 @@
import axios, { AxiosInstance, AxiosError } from 'axios'
const apiClient: AxiosInstance = axios.create({
baseURL: import.meta.env.VITE_API_URL || 'http://localhost:8000',
headers: {
'Content-Type': 'application/json',
},
timeout: 30000,
})
apiClient.interceptors.request.use(
(config) => {
const token = localStorage.getItem('admin_token')
if (token) {
config.headers['X-Admin-Token'] = token
}
return config
},
(error) => {
return Promise.reject(error)
}
)
apiClient.interceptors.response.use(
(response) => response,
(error: AxiosError) => {
if (error.response?.status === 401) {
console.warn('Authentication required. Please set admin_token in localStorage.')
// Don't redirect to avoid infinite loop
// User should manually set: localStorage.setItem('admin_token', 'your-token')
}
if (error.response?.status === 429) {
console.error('Rate limit exceeded')
}
return Promise.reject(error)
}
)
export default apiClient

View File

@@ -0,0 +1,66 @@
import apiClient from '../client'
import type {
AnnotationItem,
CreateAnnotationRequest,
AnnotationOverrideRequest,
} from '../types'
export const annotationsApi = {
list: async (documentId: string): Promise<AnnotationItem[]> => {
const { data } = await apiClient.get(
`/api/v1/admin/documents/${documentId}/annotations`
)
return data.annotations
},
create: async (
documentId: string,
annotation: CreateAnnotationRequest
): Promise<AnnotationItem> => {
const { data } = await apiClient.post(
`/api/v1/admin/documents/${documentId}/annotations`,
annotation
)
return data
},
update: async (
documentId: string,
annotationId: string,
updates: Partial<CreateAnnotationRequest>
): Promise<AnnotationItem> => {
const { data } = await apiClient.patch(
`/api/v1/admin/documents/${documentId}/annotations/${annotationId}`,
updates
)
return data
},
delete: async (documentId: string, annotationId: string): Promise<void> => {
await apiClient.delete(
`/api/v1/admin/documents/${documentId}/annotations/${annotationId}`
)
},
verify: async (
documentId: string,
annotationId: string
): Promise<{ annotation_id: string; is_verified: boolean; message: string }> => {
const { data } = await apiClient.post(
`/api/v1/admin/documents/${documentId}/annotations/${annotationId}/verify`
)
return data
},
override: async (
documentId: string,
annotationId: string,
overrideData: AnnotationOverrideRequest
): Promise<{ annotation_id: string; source: string; message: string }> => {
const { data } = await apiClient.patch(
`/api/v1/admin/documents/${documentId}/annotations/${annotationId}/override`,
overrideData
)
return data
},
}

View File

@@ -0,0 +1,80 @@
import apiClient from '../client'
import type {
DocumentListResponse,
DocumentDetailResponse,
DocumentItem,
UploadDocumentResponse,
} from '../types'
export const documentsApi = {
list: async (params?: {
status?: string
limit?: number
offset?: number
}): Promise<DocumentListResponse> => {
const { data } = await apiClient.get('/api/v1/admin/documents', { params })
return data
},
getDetail: async (documentId: string): Promise<DocumentDetailResponse> => {
const { data } = await apiClient.get(`/api/v1/admin/documents/${documentId}`)
return data
},
upload: async (file: File): Promise<UploadDocumentResponse> => {
const formData = new FormData()
formData.append('file', file)
const { data } = await apiClient.post('/api/v1/admin/documents', formData, {
headers: {
'Content-Type': 'multipart/form-data',
},
})
return data
},
batchUpload: async (
files: File[],
csvFile?: File
): Promise<{ batch_id: string; message: string; documents_created: number }> => {
const formData = new FormData()
files.forEach((file) => {
formData.append('files', file)
})
if (csvFile) {
formData.append('csv_file', csvFile)
}
const { data } = await apiClient.post('/api/v1/admin/batch/upload', formData, {
headers: {
'Content-Type': 'multipart/form-data',
},
})
return data
},
delete: async (documentId: string): Promise<void> => {
await apiClient.delete(`/api/v1/admin/documents/${documentId}`)
},
updateStatus: async (
documentId: string,
status: string
): Promise<DocumentItem> => {
const { data } = await apiClient.patch(
`/api/v1/admin/documents/${documentId}/status`,
null,
{ params: { status } }
)
return data
},
triggerAutoLabel: async (documentId: string): Promise<{ message: string }> => {
const { data } = await apiClient.post(
`/api/v1/admin/documents/${documentId}/auto-label`
)
return data
},
}

View File

@@ -0,0 +1,4 @@
export { documentsApi } from './documents'
export { annotationsApi } from './annotations'
export { trainingApi } from './training'
export { inferenceApi } from './inference'

View File

@@ -0,0 +1,16 @@
import apiClient from '../client'
import type { InferenceResponse } from '../types'
export const inferenceApi = {
processDocument: async (file: File): Promise<InferenceResponse> => {
const formData = new FormData()
formData.append('file', file)
const { data } = await apiClient.post('/api/v1/infer', formData, {
headers: {
'Content-Type': 'multipart/form-data',
},
})
return data
},
}

View File

@@ -0,0 +1,74 @@
import apiClient from '../client'
import type { TrainingModelsResponse, DocumentListResponse } from '../types'
export const trainingApi = {
getDocumentsForTraining: async (params?: {
has_annotations?: boolean
min_annotation_count?: number
exclude_used_in_training?: boolean
limit?: number
offset?: number
}): Promise<DocumentListResponse> => {
const { data } = await apiClient.get('/api/v1/admin/training/documents', {
params,
})
return data
},
getModels: async (params?: {
status?: string
limit?: number
offset?: number
}): Promise<TrainingModelsResponse> => {
const { data} = await apiClient.get('/api/v1/admin/training/models', {
params,
})
return data
},
getTaskDetail: async (taskId: string) => {
const { data } = await apiClient.get(`/api/v1/admin/training/tasks/${taskId}`)
return data
},
startTraining: async (config: {
name: string
description?: string
document_ids: string[]
epochs?: number
batch_size?: number
model_base?: string
}) => {
// Convert frontend config to backend TrainingTaskCreate format
const taskRequest = {
name: config.name,
task_type: 'yolo',
description: config.description,
config: {
document_ids: config.document_ids,
epochs: config.epochs,
batch_size: config.batch_size,
base_model: config.model_base,
},
}
const { data } = await apiClient.post('/api/v1/admin/training/tasks', taskRequest)
return data
},
cancelTask: async (taskId: string) => {
const { data } = await apiClient.post(
`/api/v1/admin/training/tasks/${taskId}/cancel`
)
return data
},
downloadModel: async (taskId: string): Promise<Blob> => {
const { data } = await apiClient.get(
`/api/v1/admin/training/models/${taskId}/download`,
{
responseType: 'blob',
}
)
return data
},
}

173
frontend/src/api/types.ts Normal file
View File

@@ -0,0 +1,173 @@
export interface DocumentItem {
document_id: string
filename: string
file_size: number
content_type: string
page_count: number
status: 'pending' | 'labeled' | 'verified' | 'exported'
auto_label_status: 'pending' | 'running' | 'completed' | 'failed' | null
auto_label_error: string | null
upload_source: string
created_at: string
updated_at: string
annotation_count?: number
annotation_sources?: {
manual: number
auto: number
verified: number
}
}
export interface DocumentListResponse {
documents: DocumentItem[]
total: number
limit: number
offset: number
}
export interface AnnotationItem {
annotation_id: string
page_number: number
class_id: number
class_name: string
bbox: {
x: number
y: number
width: number
height: number
}
normalized_bbox: {
x_center: number
y_center: number
width: number
height: number
}
text_value: string | null
confidence: number | null
source: 'manual' | 'auto'
created_at: string
}
export interface DocumentDetailResponse {
document_id: string
filename: string
file_size: number
content_type: string
page_count: number
status: 'pending' | 'labeled' | 'verified' | 'exported'
auto_label_status: 'pending' | 'running' | 'completed' | 'failed' | null
auto_label_error: string | null
upload_source: string
batch_id: string | null
csv_field_values: Record<string, string> | null
can_annotate: boolean
annotation_lock_until: string | null
annotations: AnnotationItem[]
image_urls: string[]
training_history: Array<{
task_id: string
name: string
trained_at: string
model_metrics: {
mAP: number | null
precision: number | null
recall: number | null
} | null
}>
created_at: string
updated_at: string
}
export interface TrainingTask {
task_id: string
admin_token: string
name: string
description: string | null
status: 'pending' | 'running' | 'completed' | 'failed'
task_type: string
config: Record<string, unknown>
started_at: string | null
completed_at: string | null
error_message: string | null
result_metrics: Record<string, unknown>
model_path: string | null
document_count: number
metrics_mAP: number | null
metrics_precision: number | null
metrics_recall: number | null
created_at: string
updated_at: string
}
export interface TrainingModelsResponse {
models: TrainingTask[]
total: number
limit: number
offset: number
}
export interface ErrorResponse {
detail: string
}
export interface UploadDocumentResponse {
document_id: string
filename: string
status: string
message: string
}
export interface CreateAnnotationRequest {
page_number: number
class_id: number
bbox: {
x: number
y: number
width: number
height: number
}
text_value?: string
}
export interface AnnotationOverrideRequest {
text_value?: string
bbox?: {
x: number
y: number
width: number
height: number
}
class_id?: number
class_name?: string
reason?: string
}
export interface CrossValidationResult {
is_valid: boolean
payment_line_ocr: string | null
payment_line_amount: string | null
payment_line_account: string | null
payment_line_account_type: 'bankgiro' | 'plusgiro' | null
ocr_match: boolean | null
amount_match: boolean | null
bankgiro_match: boolean | null
plusgiro_match: boolean | null
details: string[]
}
export interface InferenceResult {
document_id: string
document_type: string
success: boolean
fields: Record<string, string>
confidence: Record<string, number>
cross_validation: CrossValidationResult | null
processing_time_ms: number
visualization_url: string | null
errors: string[]
fallback_used: boolean
}
export interface InferenceResponse {
result: InferenceResult
}

View File

@@ -0,0 +1,39 @@
import React from 'react';
import { DocumentStatus } from '../types';
import { Check } from 'lucide-react';
interface BadgeProps {
status: DocumentStatus | 'Exported';
}
export const Badge: React.FC<BadgeProps> = ({ status }) => {
if (status === 'Exported') {
return (
<span className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs font-medium bg-warm-selected text-warm-text-secondary">
<Check size={12} strokeWidth={3} />
Exported
</span>
);
}
const styles = {
[DocumentStatus.PENDING]: "bg-white border border-warm-divider text-warm-text-secondary",
[DocumentStatus.LABELED]: "bg-warm-text-secondary text-white border border-transparent",
[DocumentStatus.VERIFIED]: "bg-warm-state-success/10 text-warm-state-success border border-warm-state-success/20",
[DocumentStatus.PARTIAL]: "bg-warm-state-warning/10 text-warm-state-warning border border-warm-state-warning/20",
};
const icons = {
[DocumentStatus.VERIFIED]: <Check size={12} className="mr-1" />,
[DocumentStatus.PARTIAL]: <span className="mr-1 text-[10px] font-bold">!</span>,
[DocumentStatus.PENDING]: null,
[DocumentStatus.LABELED]: null,
}
return (
<span className={`inline-flex items-center px-3 py-1 rounded-full text-xs font-medium border ${styles[status]}`}>
{icons[status]}
{status}
</span>
);
};

View File

@@ -0,0 +1,38 @@
import React from 'react';
interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
variant?: 'primary' | 'secondary' | 'outline' | 'text';
size?: 'sm' | 'md' | 'lg';
}
export const Button: React.FC<ButtonProps> = ({
variant = 'primary',
size = 'md',
className = '',
children,
...props
}) => {
const baseStyles = "inline-flex items-center justify-center rounded-md font-medium transition-all duration-150 ease-out active:scale-98 disabled:opacity-50 disabled:pointer-events-none";
const variants = {
primary: "bg-warm-text-secondary text-white hover:bg-warm-text-primary shadow-sm",
secondary: "bg-white border border-warm-divider text-warm-text-secondary hover:bg-warm-hover",
outline: "bg-transparent border border-warm-text-secondary text-warm-text-secondary hover:bg-warm-hover",
text: "text-warm-text-muted hover:text-warm-text-primary hover:bg-warm-hover",
};
const sizes = {
sm: "h-8 px-3 text-xs",
md: "h-10 px-4 text-sm",
lg: "h-12 px-6 text-base",
};
return (
<button
className={`${baseStyles} ${variants[variant]} ${sizes[size]} ${className}`}
{...props}
>
{children}
</button>
);
};

View File

@@ -0,0 +1,266 @@
import React, { useState } from 'react'
import { Search, ChevronDown, MoreHorizontal, FileText } from 'lucide-react'
import { Badge } from './Badge'
import { Button } from './Button'
import { UploadModal } from './UploadModal'
import { useDocuments } from '../hooks/useDocuments'
import type { DocumentItem } from '../api/types'
interface DashboardProps {
onNavigate: (view: string, docId?: string) => void
}
const getStatusForBadge = (status: string): string => {
const statusMap: Record<string, string> = {
pending: 'Pending',
labeled: 'Labeled',
verified: 'Verified',
exported: 'Exported',
}
return statusMap[status] || status
}
const getAutoLabelProgress = (doc: DocumentItem): number | undefined => {
if (doc.auto_label_status === 'running') {
return 45
}
if (doc.auto_label_status === 'completed') {
return 100
}
return undefined
}
export const Dashboard: React.FC<DashboardProps> = ({ onNavigate }) => {
const [isUploadOpen, setIsUploadOpen] = useState(false)
const [selectedDocs, setSelectedDocs] = useState<Set<string>>(new Set())
const [statusFilter, setStatusFilter] = useState<string>('')
const [limit] = useState(20)
const [offset] = useState(0)
const { documents, total, isLoading, error, refetch } = useDocuments({
status: statusFilter || undefined,
limit,
offset,
})
const toggleSelection = (id: string) => {
const newSet = new Set(selectedDocs)
if (newSet.has(id)) {
newSet.delete(id)
} else {
newSet.add(id)
}
setSelectedDocs(newSet)
}
if (error) {
return (
<div className="p-8 max-w-7xl mx-auto">
<div className="bg-red-50 border border-red-200 text-red-800 p-4 rounded-lg">
Error loading documents. Please check your connection to the backend API.
<button
onClick={() => refetch()}
className="ml-4 underline hover:no-underline"
>
Retry
</button>
</div>
</div>
)
}
return (
<div className="p-8 max-w-7xl mx-auto animate-fade-in">
<div className="flex items-center justify-between mb-8">
<div>
<h1 className="text-3xl font-bold text-warm-text-primary tracking-tight">
Documents
</h1>
<p className="text-sm text-warm-text-muted mt-1">
{isLoading ? 'Loading...' : `${total} documents total`}
</p>
</div>
<div className="flex gap-3">
<Button variant="secondary" disabled={selectedDocs.size === 0}>
Export Selection ({selectedDocs.size})
</Button>
<Button onClick={() => setIsUploadOpen(true)}>Upload Documents</Button>
</div>
</div>
<div className="bg-warm-card border border-warm-border rounded-lg p-4 mb-6 shadow-sm flex flex-wrap gap-4 items-center">
<div className="relative flex-1 min-w-[200px]">
<Search
className="absolute left-3 top-1/2 -translate-y-1/2 text-warm-text-muted"
size={16}
/>
<input
type="text"
placeholder="Search documents..."
className="w-full pl-9 pr-4 h-10 rounded-md border border-warm-border bg-white focus:outline-none focus:ring-1 focus:ring-warm-state-info transition-shadow text-sm"
/>
</div>
<div className="flex gap-3">
<div className="relative">
<select
value={statusFilter}
onChange={(e) => setStatusFilter(e.target.value)}
className="h-10 pl-3 pr-8 rounded-md border border-warm-border bg-white text-sm text-warm-text-secondary focus:outline-none appearance-none cursor-pointer hover:bg-warm-hover"
>
<option value="">All Statuses</option>
<option value="pending">Pending</option>
<option value="labeled">Labeled</option>
<option value="verified">Verified</option>
<option value="exported">Exported</option>
</select>
<ChevronDown
className="absolute right-2.5 top-1/2 -translate-y-1/2 pointer-events-none text-warm-text-muted"
size={14}
/>
</div>
</div>
</div>
<div className="bg-warm-card border border-warm-border rounded-lg shadow-sm overflow-hidden">
<table className="w-full text-left border-collapse">
<thead>
<tr className="border-b border-warm-border bg-white">
<th className="py-3 pl-6 pr-4 w-12">
<input
type="checkbox"
className="rounded border-warm-divider text-warm-text-primary focus:ring-warm-text-secondary"
/>
</th>
<th className="py-3 px-4 text-xs font-semibold text-warm-text-muted uppercase tracking-wider">
Document Name
</th>
<th className="py-3 px-4 text-xs font-semibold text-warm-text-muted uppercase tracking-wider">
Date
</th>
<th className="py-3 px-4 text-xs font-semibold text-warm-text-muted uppercase tracking-wider">
Status
</th>
<th className="py-3 px-4 text-xs font-semibold text-warm-text-muted uppercase tracking-wider">
Annotations
</th>
<th className="py-3 px-4 text-xs font-semibold text-warm-text-muted uppercase tracking-wider w-64">
Auto-label
</th>
<th className="py-3 px-4 w-12"></th>
</tr>
</thead>
<tbody>
{isLoading ? (
<tr>
<td colSpan={7} className="py-8 text-center text-warm-text-muted">
Loading documents...
</td>
</tr>
) : documents.length === 0 ? (
<tr>
<td colSpan={7} className="py-8 text-center text-warm-text-muted">
No documents found. Upload your first document to get started.
</td>
</tr>
) : (
documents.map((doc) => {
const isSelected = selectedDocs.has(doc.document_id)
const progress = getAutoLabelProgress(doc)
return (
<tr
key={doc.document_id}
onClick={() => onNavigate('detail', doc.document_id)}
className={`
group transition-colors duration-150 cursor-pointer border-b border-warm-border last:border-0
${isSelected ? 'bg-warm-selected' : 'hover:bg-warm-hover bg-white'}
`}
>
<td
className="py-4 pl-6 pr-4 relative"
onClick={(e) => {
e.stopPropagation()
toggleSelection(doc.document_id)
}}
>
{isSelected && (
<div className="absolute left-0 top-0 bottom-0 w-[3px] bg-warm-state-info" />
)}
<input
type="checkbox"
checked={isSelected}
readOnly
className="rounded border-warm-divider text-warm-text-primary focus:ring-warm-text-secondary cursor-pointer"
/>
</td>
<td className="py-4 px-4">
<div className="flex items-center gap-3">
<div className="p-2 bg-warm-bg rounded border border-warm-border text-warm-text-muted">
<FileText size={16} />
</div>
<span className="font-medium text-warm-text-secondary">
{doc.filename}
</span>
</div>
</td>
<td className="py-4 px-4 text-sm text-warm-text-secondary font-mono">
{new Date(doc.created_at).toLocaleDateString()}
</td>
<td className="py-4 px-4">
<Badge status={getStatusForBadge(doc.status)} />
</td>
<td className="py-4 px-4 text-sm text-warm-text-secondary">
{doc.annotation_count || 0} annotations
</td>
<td className="py-4 px-4">
{doc.auto_label_status === 'running' && progress && (
<div className="w-full">
<div className="flex justify-between text-xs mb-1">
<span className="text-warm-text-secondary font-medium">
Running
</span>
<span className="text-warm-text-muted">{progress}%</span>
</div>
<div className="h-1.5 w-full bg-warm-selected rounded-full overflow-hidden">
<div
className="h-full bg-warm-state-info transition-all duration-500 ease-out"
style={{ width: `${progress}%` }}
/>
</div>
</div>
)}
{doc.auto_label_status === 'completed' && (
<span className="text-sm font-medium text-warm-state-success">
Completed
</span>
)}
{doc.auto_label_status === 'failed' && (
<span className="text-sm font-medium text-warm-state-error">
Failed
</span>
)}
</td>
<td className="py-4 px-4 text-right">
<button className="text-warm-text-muted hover:text-warm-text-secondary p-1 rounded hover:bg-black/5 transition-colors">
<MoreHorizontal size={18} />
</button>
</td>
</tr>
)
})
)}
</tbody>
</table>
</div>
<UploadModal
isOpen={isUploadOpen}
onClose={() => {
setIsUploadOpen(false)
refetch()
}}
/>
</div>
)
}

View File

@@ -0,0 +1,148 @@
import React from 'react'
import { FileText, CheckCircle, Clock, TrendingUp, Activity } from 'lucide-react'
import { Button } from './Button'
import { useDocuments } from '../hooks/useDocuments'
import { useTraining } from '../hooks/useTraining'
interface DashboardOverviewProps {
onNavigate: (view: string) => void
}
export const DashboardOverview: React.FC<DashboardOverviewProps> = ({ onNavigate }) => {
const { total: totalDocs, isLoading: docsLoading } = useDocuments({ limit: 1 })
const { models, isLoadingModels } = useTraining()
const stats = [
{
label: 'Total Documents',
value: docsLoading ? '...' : totalDocs.toString(),
icon: FileText,
color: 'text-warm-text-primary',
bgColor: 'bg-warm-bg',
},
{
label: 'Labeled',
value: '0',
icon: CheckCircle,
color: 'text-warm-state-success',
bgColor: 'bg-green-50',
},
{
label: 'Pending',
value: '0',
icon: Clock,
color: 'text-warm-state-warning',
bgColor: 'bg-yellow-50',
},
{
label: 'Training Models',
value: isLoadingModels ? '...' : models.length.toString(),
icon: TrendingUp,
color: 'text-warm-state-info',
bgColor: 'bg-blue-50',
},
]
return (
<div className="p-8 max-w-7xl mx-auto animate-fade-in">
{/* Header */}
<div className="mb-8">
<h1 className="text-3xl font-bold text-warm-text-primary tracking-tight">
Dashboard
</h1>
<p className="text-sm text-warm-text-muted mt-1">
Overview of your document annotation system
</p>
</div>
{/* Stats Grid */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 mb-8">
{stats.map((stat) => (
<div
key={stat.label}
className="bg-warm-card border border-warm-border rounded-lg p-6 shadow-sm hover:shadow-md transition-shadow"
>
<div className="flex items-center justify-between mb-4">
<div className={`p-3 rounded-lg ${stat.bgColor}`}>
<stat.icon className={stat.color} size={24} />
</div>
</div>
<p className="text-2xl font-bold text-warm-text-primary mb-1">
{stat.value}
</p>
<p className="text-sm text-warm-text-muted">{stat.label}</p>
</div>
))}
</div>
{/* Quick Actions */}
<div className="bg-warm-card border border-warm-border rounded-lg p-6 shadow-sm mb-8">
<h2 className="text-lg font-semibold text-warm-text-primary mb-4">
Quick Actions
</h2>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<Button onClick={() => onNavigate('documents')} className="justify-start">
<FileText size={18} className="mr-2" />
Manage Documents
</Button>
<Button onClick={() => onNavigate('training')} variant="secondary" className="justify-start">
<Activity size={18} className="mr-2" />
Start Training
</Button>
<Button onClick={() => onNavigate('models')} variant="secondary" className="justify-start">
<TrendingUp size={18} className="mr-2" />
View Models
</Button>
</div>
</div>
{/* Recent Activity */}
<div className="bg-warm-card border border-warm-border rounded-lg shadow-sm overflow-hidden">
<div className="p-6 border-b border-warm-border">
<h2 className="text-lg font-semibold text-warm-text-primary">
Recent Activity
</h2>
</div>
<div className="p-6">
<div className="text-center py-8 text-warm-text-muted">
<Activity size={48} className="mx-auto mb-3 opacity-20" />
<p className="text-sm">No recent activity</p>
<p className="text-xs mt-1">
Start by uploading documents or creating training jobs
</p>
</div>
</div>
</div>
{/* System Status */}
<div className="mt-8 bg-warm-card border border-warm-border rounded-lg p-6 shadow-sm">
<h2 className="text-lg font-semibold text-warm-text-primary mb-4">
System Status
</h2>
<div className="space-y-3">
<div className="flex items-center justify-between">
<span className="text-sm text-warm-text-secondary">Backend API</span>
<span className="flex items-center text-sm text-warm-state-success">
<span className="w-2 h-2 bg-green-500 rounded-full mr-2"></span>
Online
</span>
</div>
<div className="flex items-center justify-between">
<span className="text-sm text-warm-text-secondary">Database</span>
<span className="flex items-center text-sm text-warm-state-success">
<span className="w-2 h-2 bg-green-500 rounded-full mr-2"></span>
Connected
</span>
</div>
<div className="flex items-center justify-between">
<span className="text-sm text-warm-text-secondary">GPU</span>
<span className="flex items-center text-sm text-warm-state-success">
<span className="w-2 h-2 bg-green-500 rounded-full mr-2"></span>
Available
</span>
</div>
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,504 @@
import React, { useState, useRef, useEffect } from 'react'
import { ChevronLeft, ZoomIn, ZoomOut, Plus, Edit2, Trash2, Tag, CheckCircle } from 'lucide-react'
import { Button } from './Button'
import { useDocumentDetail } from '../hooks/useDocumentDetail'
import { useAnnotations } from '../hooks/useAnnotations'
import { documentsApi } from '../api/endpoints/documents'
import type { AnnotationItem } from '../api/types'
interface DocumentDetailProps {
docId: string
onBack: () => void
}
// Field class mapping from backend
const FIELD_CLASSES: Record<number, string> = {
0: 'invoice_number',
1: 'invoice_date',
2: 'invoice_due_date',
3: 'ocr_number',
4: 'bankgiro',
5: 'plusgiro',
6: 'amount',
7: 'supplier_organisation_number',
8: 'payment_line',
9: 'customer_number',
}
export const DocumentDetail: React.FC<DocumentDetailProps> = ({ docId, onBack }) => {
const { document, annotations, isLoading } = useDocumentDetail(docId)
const {
createAnnotation,
updateAnnotation,
deleteAnnotation,
isCreating,
isDeleting,
} = useAnnotations(docId)
const [selectedId, setSelectedId] = useState<string | null>(null)
const [zoom, setZoom] = useState(100)
const [isDrawing, setIsDrawing] = useState(false)
const [drawStart, setDrawStart] = useState<{ x: number; y: number } | null>(null)
const [drawEnd, setDrawEnd] = useState<{ x: number; y: number } | null>(null)
const [selectedClassId, setSelectedClassId] = useState<number>(0)
const [currentPage, setCurrentPage] = useState(1)
const [imageSize, setImageSize] = useState<{ width: number; height: number } | null>(null)
const [imageBlobUrl, setImageBlobUrl] = useState<string | null>(null)
const canvasRef = useRef<HTMLDivElement>(null)
const imageRef = useRef<HTMLImageElement>(null)
const [isMarkingComplete, setIsMarkingComplete] = useState(false)
const selectedAnnotation = annotations?.find((a) => a.annotation_id === selectedId)
// Handle mark as complete
const handleMarkComplete = async () => {
if (!annotations || annotations.length === 0) {
alert('Please add at least one annotation before marking as complete.')
return
}
if (!confirm('Mark this document as labeled? This will save annotations to the database.')) {
return
}
setIsMarkingComplete(true)
try {
const result = await documentsApi.updateStatus(docId, 'labeled')
alert(`Document marked as labeled. ${(result as any).fields_saved || annotations.length} annotations saved.`)
onBack() // Return to document list
} catch (error) {
console.error('Failed to mark document as complete:', error)
alert('Failed to mark document as complete. Please try again.')
} finally {
setIsMarkingComplete(false)
}
}
// Load image via fetch with authentication header
useEffect(() => {
let objectUrl: string | null = null
const loadImage = async () => {
if (!docId) return
const token = localStorage.getItem('admin_token')
const imageUrl = `${import.meta.env.VITE_API_URL || 'http://localhost:8000'}/api/v1/admin/documents/${docId}/images/${currentPage}`
try {
const response = await fetch(imageUrl, {
headers: {
'X-Admin-Token': token || '',
},
})
if (!response.ok) {
throw new Error(`Failed to load image: ${response.status}`)
}
const blob = await response.blob()
objectUrl = URL.createObjectURL(blob)
setImageBlobUrl(objectUrl)
} catch (error) {
console.error('Failed to load image:', error)
}
}
loadImage()
// Cleanup: revoke object URL when component unmounts or page changes
return () => {
if (objectUrl) {
URL.revokeObjectURL(objectUrl)
}
}
}, [currentPage, docId])
// Load image size
useEffect(() => {
if (imageRef.current && imageRef.current.complete) {
setImageSize({
width: imageRef.current.naturalWidth,
height: imageRef.current.naturalHeight,
})
}
}, [imageBlobUrl])
const handleImageLoad = () => {
if (imageRef.current) {
setImageSize({
width: imageRef.current.naturalWidth,
height: imageRef.current.naturalHeight,
})
}
}
const handleMouseDown = (e: React.MouseEvent<HTMLDivElement>) => {
if (!canvasRef.current || !imageSize) return
const rect = canvasRef.current.getBoundingClientRect()
const x = (e.clientX - rect.left) / (zoom / 100)
const y = (e.clientY - rect.top) / (zoom / 100)
setIsDrawing(true)
setDrawStart({ x, y })
setDrawEnd({ x, y })
}
const handleMouseMove = (e: React.MouseEvent<HTMLDivElement>) => {
if (!isDrawing || !canvasRef.current || !imageSize) return
const rect = canvasRef.current.getBoundingClientRect()
const x = (e.clientX - rect.left) / (zoom / 100)
const y = (e.clientY - rect.top) / (zoom / 100)
setDrawEnd({ x, y })
}
const handleMouseUp = () => {
if (!isDrawing || !drawStart || !drawEnd || !imageSize) {
setIsDrawing(false)
return
}
const bbox_x = Math.min(drawStart.x, drawEnd.x)
const bbox_y = Math.min(drawStart.y, drawEnd.y)
const bbox_width = Math.abs(drawEnd.x - drawStart.x)
const bbox_height = Math.abs(drawEnd.y - drawStart.y)
// Only create if box is large enough (min 10x10 pixels)
if (bbox_width > 10 && bbox_height > 10) {
createAnnotation({
page_number: currentPage,
class_id: selectedClassId,
bbox: {
x: Math.round(bbox_x),
y: Math.round(bbox_y),
width: Math.round(bbox_width),
height: Math.round(bbox_height),
},
})
}
setIsDrawing(false)
setDrawStart(null)
setDrawEnd(null)
}
const handleDeleteAnnotation = (annotationId: string) => {
if (confirm('Are you sure you want to delete this annotation?')) {
deleteAnnotation(annotationId)
setSelectedId(null)
}
}
if (isLoading || !document) {
return (
<div className="flex h-screen items-center justify-center">
<div className="text-warm-text-muted">Loading...</div>
</div>
)
}
// Get current page annotations
const pageAnnotations = annotations?.filter((a) => a.page_number === currentPage) || []
return (
<div className="flex h-[calc(100vh-56px)] overflow-hidden">
{/* Main Canvas Area */}
<div className="flex-1 bg-warm-bg flex flex-col relative">
{/* Toolbar */}
<div className="h-14 border-b border-warm-border bg-white flex items-center justify-between px-4 z-10">
<div className="flex items-center gap-4">
<button
onClick={onBack}
className="p-2 hover:bg-warm-hover rounded-md text-warm-text-secondary transition-colors"
>
<ChevronLeft size={20} />
</button>
<div>
<h2 className="text-sm font-semibold text-warm-text-primary">{document.filename}</h2>
<p className="text-xs text-warm-text-muted">
Page {currentPage} of {document.page_count}
</p>
</div>
<div className="h-6 w-px bg-warm-divider mx-2" />
<div className="flex items-center gap-2">
<button
className="p-1.5 hover:bg-warm-hover rounded text-warm-text-secondary"
onClick={() => setZoom((z) => Math.max(50, z - 10))}
>
<ZoomOut size={16} />
</button>
<span className="text-xs font-mono w-12 text-center text-warm-text-secondary">
{zoom}%
</span>
<button
className="p-1.5 hover:bg-warm-hover rounded text-warm-text-secondary"
onClick={() => setZoom((z) => Math.min(200, z + 10))}
>
<ZoomIn size={16} />
</button>
</div>
</div>
<div className="flex gap-2">
<Button variant="secondary" size="sm">
Auto-label
</Button>
<Button
variant="primary"
size="sm"
onClick={handleMarkComplete}
disabled={isMarkingComplete || document.status === 'labeled'}
>
<CheckCircle size={16} className="mr-1" />
{isMarkingComplete ? 'Saving...' : document.status === 'labeled' ? 'Labeled' : 'Mark Complete'}
</Button>
{document.page_count > 1 && (
<div className="flex gap-1">
<Button
variant="secondary"
size="sm"
onClick={() => setCurrentPage((p) => Math.max(1, p - 1))}
disabled={currentPage === 1}
>
Prev
</Button>
<Button
variant="secondary"
size="sm"
onClick={() => setCurrentPage((p) => Math.min(document.page_count, p + 1))}
disabled={currentPage === document.page_count}
>
Next
</Button>
</div>
)}
</div>
</div>
{/* Canvas Scroll Area */}
<div className="flex-1 overflow-auto p-8 flex justify-center bg-warm-bg">
<div
ref={canvasRef}
className="bg-white shadow-lg relative transition-transform duration-200 ease-out origin-top"
style={{
width: imageSize?.width || 800,
height: imageSize?.height || 1132,
transform: `scale(${zoom / 100})`,
marginBottom: '100px',
cursor: isDrawing ? 'crosshair' : 'default',
}}
onMouseDown={handleMouseDown}
onMouseMove={handleMouseMove}
onMouseUp={handleMouseUp}
onClick={() => setSelectedId(null)}
>
{/* Document Image */}
{imageBlobUrl ? (
<img
ref={imageRef}
src={imageBlobUrl}
alt={`Page ${currentPage}`}
className="w-full h-full object-contain select-none pointer-events-none"
onLoad={handleImageLoad}
/>
) : (
<div className="flex items-center justify-center h-full">
<div className="text-warm-text-muted">Loading image...</div>
</div>
)}
{/* Annotation Overlays */}
{pageAnnotations.map((ann) => {
const isSelected = selectedId === ann.annotation_id
return (
<div
key={ann.annotation_id}
onClick={(e) => {
e.stopPropagation()
setSelectedId(ann.annotation_id)
}}
className={`
absolute group cursor-pointer transition-all duration-100
${
ann.source === 'auto'
? 'border border-dashed border-warm-text-muted bg-transparent'
: 'border-2 border-warm-text-secondary bg-warm-text-secondary/5'
}
${
isSelected
? 'border-2 border-warm-state-info ring-4 ring-warm-state-info/10 z-20'
: 'hover:bg-warm-state-info/5 z-10'
}
`}
style={{
left: ann.bbox.x,
top: ann.bbox.y,
width: ann.bbox.width,
height: ann.bbox.height,
}}
>
{/* Label Tag */}
<div
className={`
absolute -top-6 left-0 text-[10px] uppercase font-bold px-1.5 py-0.5 rounded-sm tracking-wide shadow-sm whitespace-nowrap
${
isSelected
? 'bg-warm-state-info text-white'
: 'bg-white text-warm-text-secondary border border-warm-border'
}
`}
>
{ann.class_name}
</div>
{/* Resize Handles (Visual only) */}
{isSelected && (
<>
<div className="absolute -top-1 -left-1 w-2 h-2 bg-white border border-warm-state-info rounded-full" />
<div className="absolute -top-1 -right-1 w-2 h-2 bg-white border border-warm-state-info rounded-full" />
<div className="absolute -bottom-1 -left-1 w-2 h-2 bg-white border border-warm-state-info rounded-full" />
<div className="absolute -bottom-1 -right-1 w-2 h-2 bg-white border border-warm-state-info rounded-full" />
</>
)}
</div>
)
})}
{/* Drawing Box Preview */}
{isDrawing && drawStart && drawEnd && (
<div
className="absolute border-2 border-warm-state-info bg-warm-state-info/10 z-30 pointer-events-none"
style={{
left: Math.min(drawStart.x, drawEnd.x),
top: Math.min(drawStart.y, drawEnd.y),
width: Math.abs(drawEnd.x - drawStart.x),
height: Math.abs(drawEnd.y - drawStart.y),
}}
/>
)}
</div>
</div>
</div>
{/* Right Sidebar */}
<div className="w-80 bg-white border-l border-warm-border flex flex-col shadow-[-4px_0_15px_-3px_rgba(0,0,0,0.03)] z-20">
{/* Field Selector */}
<div className="p-4 border-b border-warm-border">
<h3 className="text-sm font-semibold text-warm-text-primary mb-3">Draw Annotation</h3>
<div className="space-y-2">
<label className="block text-xs text-warm-text-muted mb-1">Select Field Type</label>
<select
value={selectedClassId}
onChange={(e) => setSelectedClassId(Number(e.target.value))}
className="w-full px-3 py-2 border border-warm-border rounded-md text-sm focus:outline-none focus:ring-1 focus:ring-warm-state-info"
>
{Object.entries(FIELD_CLASSES).map(([id, name]) => (
<option key={id} value={id}>
{name.replace(/_/g, ' ')}
</option>
))}
</select>
<p className="text-xs text-warm-text-muted mt-2">
Click and drag on the document to create a bounding box
</p>
</div>
</div>
{/* Document Info Card */}
<div className="p-4 border-b border-warm-border">
<div className="bg-white rounded-lg border border-warm-border p-4 shadow-sm">
<h3 className="text-sm font-semibold text-warm-text-primary mb-3">Document Info</h3>
<div className="space-y-2">
<div className="flex justify-between text-xs">
<span className="text-warm-text-muted">Status</span>
<span className="text-warm-text-secondary font-medium capitalize">
{document.status}
</span>
</div>
<div className="flex justify-between text-xs">
<span className="text-warm-text-muted">Size</span>
<span className="text-warm-text-secondary font-medium">
{(document.file_size / 1024 / 1024).toFixed(2)} MB
</span>
</div>
<div className="flex justify-between text-xs">
<span className="text-warm-text-muted">Uploaded</span>
<span className="text-warm-text-secondary font-medium">
{new Date(document.created_at).toLocaleDateString()}
</span>
</div>
</div>
</div>
</div>
{/* Annotations List */}
<div className="flex-1 overflow-y-auto p-4">
<div className="flex items-center justify-between mb-4">
<h3 className="text-sm font-semibold text-warm-text-primary">Annotations</h3>
<span className="text-xs text-warm-text-muted">{pageAnnotations.length} items</span>
</div>
{pageAnnotations.length === 0 ? (
<div className="text-center py-8 text-warm-text-muted">
<Tag size={48} className="mx-auto mb-3 opacity-20" />
<p className="text-sm">No annotations yet</p>
<p className="text-xs mt-1">Draw on the document to add annotations</p>
</div>
) : (
<div className="space-y-3">
{pageAnnotations.map((ann) => (
<div
key={ann.annotation_id}
onClick={() => setSelectedId(ann.annotation_id)}
className={`
group p-3 rounded-md border transition-all duration-150 cursor-pointer
${
selectedId === ann.annotation_id
? 'bg-warm-bg border-warm-state-info shadow-sm'
: 'bg-white border-warm-border hover:border-warm-text-muted'
}
`}
>
<div className="flex justify-between items-start mb-1">
<span className="text-xs font-bold text-warm-text-secondary uppercase tracking-wider">
{ann.class_name.replace(/_/g, ' ')}
</span>
{selectedId === ann.annotation_id && (
<div className="flex gap-1">
<button
onClick={() => handleDeleteAnnotation(ann.annotation_id)}
className="text-warm-text-muted hover:text-warm-state-error"
disabled={isDeleting}
>
<Trash2 size={12} />
</button>
</div>
)}
</div>
<p className="text-sm text-warm-text-muted font-mono truncate">
{ann.text_value || '(no text)'}
</p>
<div className="flex items-center gap-2 mt-2">
<span
className={`text-[10px] px-1.5 py-0.5 rounded ${
ann.source === 'auto'
? 'bg-blue-50 text-blue-700'
: 'bg-green-50 text-green-700'
}`}
>
{ann.source}
</span>
{ann.confidence && (
<span className="text-[10px] text-warm-text-muted">
{(ann.confidence * 100).toFixed(0)}%
</span>
)}
</div>
</div>
))}
</div>
)}
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,466 @@
import React, { useState, useRef } from 'react'
import { UploadCloud, FileText, Loader2, CheckCircle2, AlertCircle, Clock } from 'lucide-react'
import { Button } from './Button'
import { inferenceApi } from '../api/endpoints'
import type { InferenceResult } from '../api/types'
export const InferenceDemo: React.FC = () => {
const [isDragging, setIsDragging] = useState(false)
const [selectedFile, setSelectedFile] = useState<File | null>(null)
const [isProcessing, setIsProcessing] = useState(false)
const [result, setResult] = useState<InferenceResult | null>(null)
const [error, setError] = useState<string | null>(null)
const fileInputRef = useRef<HTMLInputElement>(null)
const handleFileSelect = (file: File | null) => {
if (!file) return
const validTypes = ['application/pdf', 'image/png', 'image/jpeg', 'image/jpg']
if (!validTypes.includes(file.type)) {
setError('Please upload a PDF, PNG, or JPG file')
return
}
if (file.size > 50 * 1024 * 1024) {
setError('File size must be less than 50MB')
return
}
setSelectedFile(file)
setResult(null)
setError(null)
}
const handleDrop = (e: React.DragEvent) => {
e.preventDefault()
setIsDragging(false)
if (e.dataTransfer.files.length > 0) {
handleFileSelect(e.dataTransfer.files[0])
}
}
const handleBrowseClick = () => {
fileInputRef.current?.click()
}
const handleProcess = async () => {
if (!selectedFile) return
setIsProcessing(true)
setError(null)
try {
const response = await inferenceApi.processDocument(selectedFile)
console.log('API Response:', response)
console.log('Visualization URL:', response.result?.visualization_url)
setResult(response.result)
} catch (err) {
setError(err instanceof Error ? err.message : 'Processing failed')
} finally {
setIsProcessing(false)
}
}
const handleReset = () => {
setSelectedFile(null)
setResult(null)
setError(null)
}
const formatFieldName = (field: string): string => {
const fieldNames: Record<string, string> = {
InvoiceNumber: 'Invoice Number',
InvoiceDate: 'Invoice Date',
InvoiceDueDate: 'Due Date',
OCR: 'OCR Number',
Amount: 'Amount',
Bankgiro: 'Bankgiro',
Plusgiro: 'Plusgiro',
supplier_org_number: 'Supplier Org Number',
customer_number: 'Customer Number',
payment_line: 'Payment Line',
}
return fieldNames[field] || field
}
return (
<div className="max-w-7xl mx-auto px-4 py-6 space-y-6">
{/* Header */}
<div className="text-center">
<h2 className="text-3xl font-bold text-warm-text-primary mb-2">
Invoice Extraction Demo
</h2>
<p className="text-warm-text-muted">
Upload a Swedish invoice to see our AI-powered field extraction in action
</p>
</div>
{/* Upload Area */}
{!result && (
<div className="max-w-2xl mx-auto">
<div className="bg-warm-card rounded-xl border border-warm-border p-8 shadow-sm">
<div
className={`
relative h-72 rounded-xl border-2 border-dashed transition-all duration-200
${isDragging
? 'border-warm-text-secondary bg-warm-selected scale-[1.02]'
: 'border-warm-divider bg-warm-bg hover:bg-warm-hover hover:border-warm-text-secondary/50'
}
${isProcessing ? 'opacity-60 pointer-events-none' : 'cursor-pointer'}
`}
onDragOver={(e) => {
e.preventDefault()
setIsDragging(true)
}}
onDragLeave={() => setIsDragging(false)}
onDrop={handleDrop}
onClick={handleBrowseClick}
>
<div className="absolute inset-0 flex flex-col items-center justify-center gap-6">
{isProcessing ? (
<>
<Loader2 size={56} className="text-warm-text-secondary animate-spin" />
<div className="text-center">
<p className="text-lg font-semibold text-warm-text-primary mb-1">
Processing invoice...
</p>
<p className="text-sm text-warm-text-muted">
This may take a few moments
</p>
</div>
</>
) : selectedFile ? (
<>
<div className="p-5 bg-warm-text-secondary/10 rounded-full">
<FileText size={40} className="text-warm-text-secondary" />
</div>
<div className="text-center px-4">
<p className="text-lg font-semibold text-warm-text-primary mb-1">
{selectedFile.name}
</p>
<p className="text-sm text-warm-text-muted">
{(selectedFile.size / 1024 / 1024).toFixed(2)} MB
</p>
</div>
</>
) : (
<>
<div className="p-5 bg-warm-text-secondary/10 rounded-full">
<UploadCloud size={40} className="text-warm-text-secondary" />
</div>
<div className="text-center px-4">
<p className="text-lg font-semibold text-warm-text-primary mb-2">
Drag & drop invoice here
</p>
<p className="text-sm text-warm-text-muted mb-3">
or{' '}
<span className="text-warm-text-secondary font-medium">
browse files
</span>
</p>
<p className="text-xs text-warm-text-muted">
Supports PDF, PNG, JPG (up to 50MB)
</p>
</div>
</>
)}
</div>
</div>
<input
ref={fileInputRef}
type="file"
accept=".pdf,image/*"
className="hidden"
onChange={(e) => handleFileSelect(e.target.files?.[0] || null)}
/>
{error && (
<div className="mt-5 p-4 bg-red-50 border border-red-200 rounded-lg flex items-start gap-3">
<AlertCircle size={18} className="text-red-600 flex-shrink-0 mt-0.5" />
<span className="text-sm text-red-800 font-medium">{error}</span>
</div>
)}
{selectedFile && !isProcessing && (
<div className="mt-6 flex gap-3 justify-end">
<Button variant="secondary" onClick={handleReset}>
Cancel
</Button>
<Button onClick={handleProcess}>Process Invoice</Button>
</div>
)}
</div>
</div>
)}
{/* Results */}
{result && (
<div className="space-y-6">
{/* Status Header */}
<div className="bg-warm-card rounded-xl border border-warm-border shadow-sm overflow-hidden">
<div className="p-6 flex items-center justify-between border-b border-warm-divider">
<div className="flex items-center gap-4">
{result.success ? (
<div className="p-3 bg-green-100 rounded-xl">
<CheckCircle2 size={28} className="text-green-600" />
</div>
) : (
<div className="p-3 bg-yellow-100 rounded-xl">
<AlertCircle size={28} className="text-yellow-600" />
</div>
)}
<div>
<h3 className="text-xl font-bold text-warm-text-primary">
{result.success ? 'Extraction Complete' : 'Partial Results'}
</h3>
<p className="text-sm text-warm-text-muted mt-0.5">
Document ID: <span className="font-mono">{result.document_id}</span>
</p>
</div>
</div>
<Button variant="secondary" onClick={handleReset}>
Process Another
</Button>
</div>
<div className="px-6 py-4 bg-warm-bg/50 flex items-center gap-6 text-sm">
<div className="flex items-center gap-2 text-warm-text-secondary">
<Clock size={16} />
<span className="font-medium">
{result.processing_time_ms.toFixed(0)}ms
</span>
</div>
{result.fallback_used && (
<span className="px-3 py-1.5 bg-warm-selected rounded-md text-warm-text-secondary font-medium text-xs">
Fallback OCR Used
</span>
)}
</div>
</div>
{/* Main Content Grid */}
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
{/* Left Column: Extracted Fields */}
<div className="lg:col-span-2 space-y-6">
<div className="bg-warm-card rounded-xl border border-warm-border p-6 shadow-sm">
<h3 className="text-lg font-bold text-warm-text-primary mb-5 flex items-center gap-2">
<span className="w-1 h-5 bg-warm-text-secondary rounded-full"></span>
Extracted Fields
</h3>
<div className="flex flex-wrap gap-4">
{Object.entries(result.fields).map(([field, value]) => {
const confidence = result.confidence[field]
return (
<div
key={field}
className="p-4 bg-warm-bg/70 rounded-lg border border-warm-divider hover:border-warm-text-secondary/30 transition-colors w-[calc(50%-0.5rem)]"
>
<div className="text-xs font-semibold text-warm-text-muted uppercase tracking-wide mb-2">
{formatFieldName(field)}
</div>
<div className="text-sm font-bold text-warm-text-primary mb-2 min-h-[1.5rem]">
{value || <span className="text-warm-text-muted italic">N/A</span>}
</div>
{confidence && (
<div className="flex items-center gap-1.5 text-xs font-medium text-warm-text-secondary">
<CheckCircle2 size={13} />
<span>{(confidence * 100).toFixed(1)}%</span>
</div>
)}
</div>
)
})}
</div>
</div>
{/* Visualization */}
{result.visualization_url && (
<div className="bg-warm-card rounded-xl border border-warm-border p-6 shadow-sm">
<h3 className="text-lg font-bold text-warm-text-primary mb-5 flex items-center gap-2">
<span className="w-1 h-5 bg-warm-text-secondary rounded-full"></span>
Detection Visualization
</h3>
<div className="bg-warm-bg rounded-lg overflow-hidden border border-warm-divider">
<img
src={`${import.meta.env.VITE_API_URL || 'http://localhost:8000'}${result.visualization_url}`}
alt="Detection visualization"
className="w-full h-auto"
/>
</div>
</div>
)}
</div>
{/* Right Column: Cross-Validation & Errors */}
<div className="space-y-6">
{/* Cross-Validation */}
{result.cross_validation && (
<div className="bg-warm-card rounded-xl border border-warm-border p-6 shadow-sm">
<h3 className="text-lg font-bold text-warm-text-primary mb-4 flex items-center gap-2">
<span className="w-1 h-5 bg-warm-text-secondary rounded-full"></span>
Payment Line Validation
</h3>
<div
className={`
p-4 rounded-lg mb-4 flex items-center gap-3
${result.cross_validation.is_valid
? 'bg-green-50 border border-green-200'
: 'bg-yellow-50 border border-yellow-200'
}
`}
>
{result.cross_validation.is_valid ? (
<>
<CheckCircle2 size={22} className="text-green-600 flex-shrink-0" />
<span className="font-bold text-green-800">All Fields Match</span>
</>
) : (
<>
<AlertCircle size={22} className="text-yellow-600 flex-shrink-0" />
<span className="font-bold text-yellow-800">Mismatch Detected</span>
</>
)}
</div>
<div className="space-y-2.5">
{result.cross_validation.payment_line_ocr && (
<div
className={`
p-3 rounded-lg border transition-colors
${result.cross_validation.ocr_match === true
? 'bg-green-50 border-green-200'
: result.cross_validation.ocr_match === false
? 'bg-red-50 border-red-200'
: 'bg-warm-bg border-warm-divider'
}
`}
>
<div className="flex items-center justify-between">
<div className="flex-1">
<div className="text-xs font-semibold text-warm-text-muted mb-1">
OCR NUMBER
</div>
<div className="text-sm font-bold text-warm-text-primary font-mono">
{result.cross_validation.payment_line_ocr}
</div>
</div>
{result.cross_validation.ocr_match === true && (
<CheckCircle2 size={16} className="text-green-600" />
)}
{result.cross_validation.ocr_match === false && (
<AlertCircle size={16} className="text-red-600" />
)}
</div>
</div>
)}
{result.cross_validation.payment_line_amount && (
<div
className={`
p-3 rounded-lg border transition-colors
${result.cross_validation.amount_match === true
? 'bg-green-50 border-green-200'
: result.cross_validation.amount_match === false
? 'bg-red-50 border-red-200'
: 'bg-warm-bg border-warm-divider'
}
`}
>
<div className="flex items-center justify-between">
<div className="flex-1">
<div className="text-xs font-semibold text-warm-text-muted mb-1">
AMOUNT
</div>
<div className="text-sm font-bold text-warm-text-primary font-mono">
{result.cross_validation.payment_line_amount}
</div>
</div>
{result.cross_validation.amount_match === true && (
<CheckCircle2 size={16} className="text-green-600" />
)}
{result.cross_validation.amount_match === false && (
<AlertCircle size={16} className="text-red-600" />
)}
</div>
</div>
)}
{result.cross_validation.payment_line_account && (
<div
className={`
p-3 rounded-lg border transition-colors
${(result.cross_validation.payment_line_account_type === 'bankgiro'
? result.cross_validation.bankgiro_match
: result.cross_validation.plusgiro_match) === true
? 'bg-green-50 border-green-200'
: (result.cross_validation.payment_line_account_type === 'bankgiro'
? result.cross_validation.bankgiro_match
: result.cross_validation.plusgiro_match) === false
? 'bg-red-50 border-red-200'
: 'bg-warm-bg border-warm-divider'
}
`}
>
<div className="flex items-center justify-between">
<div className="flex-1">
<div className="text-xs font-semibold text-warm-text-muted mb-1">
{result.cross_validation.payment_line_account_type === 'bankgiro'
? 'BANKGIRO'
: 'PLUSGIRO'}
</div>
<div className="text-sm font-bold text-warm-text-primary font-mono">
{result.cross_validation.payment_line_account}
</div>
</div>
{(result.cross_validation.payment_line_account_type === 'bankgiro'
? result.cross_validation.bankgiro_match
: result.cross_validation.plusgiro_match) === true && (
<CheckCircle2 size={16} className="text-green-600" />
)}
{(result.cross_validation.payment_line_account_type === 'bankgiro'
? result.cross_validation.bankgiro_match
: result.cross_validation.plusgiro_match) === false && (
<AlertCircle size={16} className="text-red-600" />
)}
</div>
</div>
)}
</div>
{result.cross_validation.details.length > 0 && (
<div className="mt-4 p-3 bg-warm-bg/70 rounded-lg text-xs text-warm-text-secondary leading-relaxed border border-warm-divider">
{result.cross_validation.details[result.cross_validation.details.length - 1]}
</div>
)}
</div>
)}
{/* Errors */}
{result.errors.length > 0 && (
<div className="bg-warm-card rounded-xl border border-warm-border p-6 shadow-sm">
<h3 className="text-lg font-bold text-warm-text-primary mb-4 flex items-center gap-2">
<span className="w-1 h-5 bg-red-500 rounded-full"></span>
Issues
</h3>
<div className="space-y-2.5">
{result.errors.map((err, idx) => (
<div
key={idx}
className="p-3 bg-yellow-50 border border-yellow-200 rounded-lg flex items-start gap-3"
>
<AlertCircle size={16} className="text-yellow-600 flex-shrink-0 mt-0.5" />
<span className="text-xs text-yellow-800 leading-relaxed">{err}</span>
</div>
))}
</div>
</div>
)}
</div>
</div>
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,102 @@
import React, { useState } from 'react';
import { Box, LayoutTemplate, Users, BookOpen, LogOut, Sparkles } from 'lucide-react';
interface LayoutProps {
children: React.ReactNode;
activeView: string;
onNavigate: (view: string) => void;
onLogout?: () => void;
}
export const Layout: React.FC<LayoutProps> = ({ children, activeView, onNavigate, onLogout }) => {
const [showDropdown, setShowDropdown] = useState(false);
const navItems = [
{ id: 'dashboard', label: 'Dashboard', icon: LayoutTemplate },
{ id: 'demo', label: 'Demo', icon: Sparkles },
{ id: 'training', label: 'Training', icon: Box }, // Mapped to Compliants visually in prompt, using logical name
{ id: 'documents', label: 'Documents', icon: BookOpen },
{ id: 'models', label: 'Models', icon: Users }, // Contacts in prompt, mapped to models for this use case
];
return (
<div className="min-h-screen bg-warm-bg font-sans text-warm-text-primary flex flex-col">
{/* Top Navigation */}
<nav className="h-14 bg-warm-bg border-b border-warm-border px-6 flex items-center justify-between shrink-0 sticky top-0 z-40">
<div className="flex items-center gap-8">
{/* Logo */}
<div className="flex items-center gap-2">
<div className="w-8 h-8 bg-warm-text-primary rounded-full flex items-center justify-center text-white">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="3" strokeLinecap="round" strokeLinejoin="round">
<path d="M12 2L2 7l10 5 10-5-10-5zM2 17l10 5 10-5M2 12l10 5 10-5"/>
</svg>
</div>
</div>
{/* Nav Links */}
<div className="flex h-14">
{navItems.map(item => {
const isActive = activeView === item.id || (activeView === 'detail' && item.id === 'documents');
return (
<button
key={item.id}
onClick={() => onNavigate(item.id)}
className={`
relative px-4 h-full flex items-center text-sm font-medium transition-colors
${isActive ? 'text-warm-text-primary' : 'text-warm-text-muted hover:text-warm-text-secondary'}
`}
>
{item.label}
{isActive && (
<div className="absolute bottom-0 left-0 right-0 h-0.5 bg-warm-text-secondary rounded-t-full mx-2" />
)}
</button>
);
})}
</div>
</div>
{/* User Profile */}
<div className="flex items-center gap-3 pl-6 border-l border-warm-border h-6 relative">
<button
onClick={() => setShowDropdown(!showDropdown)}
className="w-8 h-8 rounded-full bg-warm-selected flex items-center justify-center text-xs font-semibold text-warm-text-secondary border border-warm-divider hover:bg-warm-hover transition-colors"
>
AD
</button>
{showDropdown && (
<>
<div
className="fixed inset-0 z-10"
onClick={() => setShowDropdown(false)}
/>
<div className="absolute right-0 top-10 w-48 bg-warm-card border border-warm-border rounded-lg shadow-modal z-20">
<div className="p-3 border-b border-warm-border">
<p className="text-sm font-medium text-warm-text-primary">Admin User</p>
<p className="text-xs text-warm-text-muted mt-0.5">Authenticated</p>
</div>
{onLogout && (
<button
onClick={() => {
setShowDropdown(false)
onLogout()
}}
className="w-full px-3 py-2 text-left text-sm text-warm-text-secondary hover:bg-warm-hover transition-colors flex items-center gap-2"
>
<LogOut size={14} />
Sign Out
</button>
)}
</div>
</>
)}
</div>
</nav>
{/* Main Content */}
<main className="flex-1 overflow-auto">
{children}
</main>
</div>
);
};

View File

@@ -0,0 +1,188 @@
import React, { useState } from 'react'
import { Button } from './Button'
interface LoginProps {
onLogin: (token: string) => void
}
export const Login: React.FC<LoginProps> = ({ onLogin }) => {
const [token, setToken] = useState('')
const [name, setName] = useState('')
const [description, setDescription] = useState('')
const [isCreating, setIsCreating] = useState(false)
const [error, setError] = useState('')
const [createdToken, setCreatedToken] = useState('')
const handleLoginWithToken = () => {
if (!token.trim()) {
setError('Please enter a token')
return
}
localStorage.setItem('admin_token', token.trim())
onLogin(token.trim())
}
const handleCreateToken = async () => {
if (!name.trim()) {
setError('Please enter a token name')
return
}
setIsCreating(true)
setError('')
try {
const response = await fetch('http://localhost:8000/api/v1/admin/auth/token', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
name: name.trim(),
description: description.trim() || undefined,
}),
})
if (!response.ok) {
throw new Error('Failed to create token')
}
const data = await response.json()
setCreatedToken(data.token)
setToken(data.token)
setError('')
} catch (err) {
setError('Failed to create token. Please check your connection.')
console.error(err)
} finally {
setIsCreating(false)
}
}
const handleUseCreatedToken = () => {
if (createdToken) {
localStorage.setItem('admin_token', createdToken)
onLogin(createdToken)
}
}
return (
<div className="min-h-screen bg-warm-bg flex items-center justify-center p-4">
<div className="bg-warm-card border border-warm-border rounded-lg shadow-modal p-8 max-w-md w-full">
<h1 className="text-2xl font-bold text-warm-text-primary mb-2">
Admin Authentication
</h1>
<p className="text-sm text-warm-text-muted mb-6">
Sign in with an admin token to access the document management system
</p>
{error && (
<div className="mb-4 p-3 bg-red-50 border border-red-200 text-red-800 rounded text-sm">
{error}
</div>
)}
{createdToken && (
<div className="mb-4 p-3 bg-green-50 border border-green-200 rounded">
<p className="text-sm font-medium text-green-800 mb-2">Token created successfully!</p>
<div className="bg-white border border-green-300 rounded p-2 mb-3">
<code className="text-xs font-mono text-warm-text-primary break-all">
{createdToken}
</code>
</div>
<p className="text-xs text-green-700 mb-3">
Save this token securely. You won't be able to see it again.
</p>
<Button onClick={handleUseCreatedToken} className="w-full">
Use This Token
</Button>
</div>
)}
<div className="space-y-6">
{/* Login with existing token */}
<div>
<h2 className="text-sm font-semibold text-warm-text-secondary mb-3">
Sign in with existing token
</h2>
<div className="space-y-3">
<div>
<label className="block text-sm text-warm-text-secondary mb-1">
Admin Token
</label>
<input
type="text"
value={token}
onChange={(e) => setToken(e.target.value)}
placeholder="Enter your admin token"
className="w-full px-3 py-2 border border-warm-border rounded-md text-sm focus:outline-none focus:ring-1 focus:ring-warm-state-info font-mono"
onKeyDown={(e) => e.key === 'Enter' && handleLoginWithToken()}
/>
</div>
<Button onClick={handleLoginWithToken} className="w-full">
Sign In
</Button>
</div>
</div>
<div className="relative">
<div className="absolute inset-0 flex items-center">
<div className="w-full border-t border-warm-border"></div>
</div>
<div className="relative flex justify-center text-xs">
<span className="px-2 bg-warm-card text-warm-text-muted">OR</span>
</div>
</div>
{/* Create new token */}
<div>
<h2 className="text-sm font-semibold text-warm-text-secondary mb-3">
Create new admin token
</h2>
<div className="space-y-3">
<div>
<label className="block text-sm text-warm-text-secondary mb-1">
Token Name <span className="text-red-500">*</span>
</label>
<input
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="e.g., my-laptop"
className="w-full px-3 py-2 border border-warm-border rounded-md text-sm focus:outline-none focus:ring-1 focus:ring-warm-state-info"
/>
</div>
<div>
<label className="block text-sm text-warm-text-secondary mb-1">
Description (optional)
</label>
<input
type="text"
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder="e.g., Personal laptop access"
className="w-full px-3 py-2 border border-warm-border rounded-md text-sm focus:outline-none focus:ring-1 focus:ring-warm-state-info"
/>
</div>
<Button
onClick={handleCreateToken}
variant="secondary"
disabled={isCreating}
className="w-full"
>
{isCreating ? 'Creating...' : 'Create Token'}
</Button>
</div>
</div>
</div>
<div className="mt-6 pt-4 border-t border-warm-border">
<p className="text-xs text-warm-text-muted">
Admin tokens are used to authenticate with the document management API.
Keep your tokens secure and never share them.
</p>
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,134 @@
import React from 'react';
import { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer } from 'recharts';
import { Button } from './Button';
const CHART_DATA = [
{ name: 'Model A', value: 75 },
{ name: 'Model B', value: 82 },
{ name: 'Model C', value: 95 },
{ name: 'Model D', value: 68 },
];
const METRICS_DATA = [
{ name: 'Precision', value: 88 },
{ name: 'Recall', value: 76 },
{ name: 'F1 Score', value: 91 },
{ name: 'Accuracy', value: 82 },
];
const JOBS = [
{ id: 1, name: 'Training Job Job 1', date: '12/29/2024 10:33 PM', status: 'Running', progress: 65 },
{ id: 2, name: 'Training Job 2', date: '12/29/2024 10:33 PM', status: 'Completed', success: 37, metrics: 89 },
{ id: 3, name: 'Model Training Compentr 1', date: '12/29/2024 10:19 PM', status: 'Completed', success: 87, metrics: 92 },
];
export const Models: React.FC = () => {
return (
<div className="p-8 max-w-7xl mx-auto flex gap-8">
{/* Left: Job History */}
<div className="flex-1">
<h2 className="text-2xl font-bold text-warm-text-primary mb-6">Models & History</h2>
<h3 className="text-lg font-semibold text-warm-text-primary mb-4">Recent Training Jobs</h3>
<div className="space-y-4">
{JOBS.map(job => (
<div key={job.id} className="bg-warm-card border border-warm-border rounded-lg p-5 shadow-sm hover:border-warm-divider transition-colors">
<div className="flex justify-between items-start mb-2">
<div>
<h4 className="font-semibold text-warm-text-primary text-lg mb-1">{job.name}</h4>
<p className="text-sm text-warm-text-muted">Started {job.date}</p>
</div>
<span className={`px-3 py-1 rounded-full text-xs font-medium ${job.status === 'Running' ? 'bg-warm-selected text-warm-text-secondary' : 'bg-warm-selected text-warm-state-success'}`}>
{job.status}
</span>
</div>
{job.status === 'Running' ? (
<div className="mt-4">
<div className="h-2 w-full bg-warm-selected rounded-full overflow-hidden">
<div className="h-full bg-warm-text-secondary w-[65%] rounded-full"></div>
</div>
</div>
) : (
<div className="mt-4 flex gap-8">
<div>
<span className="block text-xs text-warm-text-muted uppercase tracking-wide">Success</span>
<span className="text-lg font-mono text-warm-text-secondary">{job.success}</span>
</div>
<div>
<span className="block text-xs text-warm-text-muted uppercase tracking-wide">Performance</span>
<span className="text-lg font-mono text-warm-text-secondary">{job.metrics}%</span>
</div>
<div>
<span className="block text-xs text-warm-text-muted uppercase tracking-wide">Completed</span>
<span className="text-lg font-mono text-warm-text-secondary">100%</span>
</div>
</div>
)}
</div>
))}
</div>
</div>
{/* Right: Model Detail */}
<div className="w-[400px]">
<div className="bg-warm-card border border-warm-border rounded-lg p-6 shadow-card sticky top-8">
<div className="flex justify-between items-center mb-6">
<h3 className="text-xl font-bold text-warm-text-primary">Model Detail</h3>
<span className="text-sm font-medium text-warm-state-success">Completed</span>
</div>
<div className="mb-8">
<p className="text-sm text-warm-text-muted mb-1">Model name</p>
<p className="font-medium text-warm-text-primary">Invoices Q4 v2.1</p>
</div>
<div className="space-y-8">
{/* Chart 1 */}
<div>
<h4 className="text-sm font-semibold text-warm-text-secondary mb-4">Bar Rate Metrics</h4>
<div className="h-40">
<ResponsiveContainer width="100%" height="100%">
<BarChart data={CHART_DATA}>
<CartesianGrid strokeDasharray="3 3" vertical={false} stroke="#E6E4E1" />
<XAxis dataKey="name" hide />
<YAxis hide domain={[0, 100]} />
<Tooltip
cursor={{fill: '#F1F0ED'}}
contentStyle={{borderRadius: '8px', border: '1px solid #E6E4E1', boxShadow: '0 2px 5px rgba(0,0,0,0.05)'}}
/>
<Bar dataKey="value" fill="#3A3A3A" radius={[4, 4, 0, 0]} barSize={32} />
</BarChart>
</ResponsiveContainer>
</div>
</div>
{/* Chart 2 */}
<div>
<h4 className="text-sm font-semibold text-warm-text-secondary mb-4">Entity Extraction Accuracy</h4>
<div className="h-40">
<ResponsiveContainer width="100%" height="100%">
<BarChart data={METRICS_DATA}>
<CartesianGrid strokeDasharray="3 3" vertical={false} stroke="#E6E4E1" />
<XAxis dataKey="name" tick={{fontSize: 10, fill: '#6B6B6B'}} axisLine={false} tickLine={false} />
<YAxis hide domain={[0, 100]} />
<Tooltip cursor={{fill: '#F1F0ED'}} />
<Bar dataKey="value" fill="#3A3A3A" radius={[4, 4, 0, 0]} barSize={32} />
</BarChart>
</ResponsiveContainer>
</div>
</div>
</div>
<div className="mt-8 space-y-3">
<Button className="w-full">Download Model</Button>
<div className="flex gap-3">
<Button variant="secondary" className="flex-1">View Logs</Button>
<Button variant="secondary" className="flex-1">Use as Base</Button>
</div>
</div>
</div>
</div>
</div>
);
};

View File

@@ -0,0 +1,113 @@
import React, { useState } from 'react';
import { Check, AlertCircle } from 'lucide-react';
import { Button } from './Button';
import { DocumentStatus } from '../types';
export const Training: React.FC = () => {
const [split, setSplit] = useState(80);
const docs = [
{ id: '1', name: 'Document Document 1', date: '12/28/2024', status: DocumentStatus.VERIFIED },
{ id: '2', name: 'Document Document 2', date: '12/29/2024', status: DocumentStatus.VERIFIED },
{ id: '3', name: 'Document Document 3', date: '12/29/2024', status: DocumentStatus.VERIFIED },
{ id: '4', name: 'Document Document 4', date: '12/29/2024', status: DocumentStatus.PARTIAL },
{ id: '5', name: 'Document Document 5', date: '12/29/2024', status: DocumentStatus.PARTIAL },
{ id: '6', name: 'Document Document 6', date: '12/29/2024', status: DocumentStatus.PARTIAL },
{ id: '8', name: 'Document Document 8', date: '12/29/2024', status: DocumentStatus.VERIFIED },
];
return (
<div className="p-8 max-w-7xl mx-auto h-[calc(100vh-56px)] flex gap-8">
{/* Document Selection List */}
<div className="flex-1 flex flex-col">
<h2 className="text-2xl font-bold text-warm-text-primary mb-6">Document Selection</h2>
<div className="flex-1 bg-warm-card border border-warm-border rounded-lg overflow-hidden flex flex-col shadow-sm">
<div className="overflow-auto flex-1">
<table className="w-full text-left">
<thead className="sticky top-0 bg-white border-b border-warm-border z-10">
<tr>
<th className="py-3 pl-6 pr-4 w-12"><input type="checkbox" className="rounded border-warm-divider"/></th>
<th className="py-3 px-4 text-xs font-semibold text-warm-text-muted uppercase">Document name</th>
<th className="py-3 px-4 text-xs font-semibold text-warm-text-muted uppercase">Date</th>
<th className="py-3 px-4 text-xs font-semibold text-warm-text-muted uppercase">Status</th>
</tr>
</thead>
<tbody>
{docs.map(doc => (
<tr key={doc.id} className="border-b border-warm-border hover:bg-warm-hover transition-colors">
<td className="py-3 pl-6 pr-4"><input type="checkbox" defaultChecked className="rounded border-warm-divider accent-warm-state-info"/></td>
<td className="py-3 px-4 text-sm font-medium text-warm-text-secondary">{doc.name}</td>
<td className="py-3 px-4 text-sm text-warm-text-muted font-mono">{doc.date}</td>
<td className="py-3 px-4">
{doc.status === DocumentStatus.VERIFIED ? (
<div className="flex items-center text-warm-state-success text-sm font-medium">
<div className="w-5 h-5 rounded-full bg-warm-state-success flex items-center justify-center text-white mr-2">
<Check size={12} strokeWidth={3}/>
</div>
Verified
</div>
) : (
<div className="flex items-center text-warm-text-muted text-sm">
<div className="w-5 h-5 rounded-full bg-[#BDBBB5] flex items-center justify-center text-white mr-2">
<span className="font-bold text-[10px]">!</span>
</div>
Partial
</div>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
</div>
{/* Configuration Panel */}
<div className="w-96">
<div className="bg-warm-card rounded-lg border border-warm-border shadow-card p-6 sticky top-8">
<h3 className="text-lg font-semibold text-warm-text-primary mb-6">Training Configuration</h3>
<div className="space-y-6">
<div>
<label className="block text-sm font-medium text-warm-text-secondary mb-2">Model Name</label>
<input
type="text"
placeholder="e.g. Invoices Q4"
className="w-full h-10 px-3 rounded-md border border-warm-divider bg-white text-warm-text-primary focus:outline-none focus:ring-1 focus:ring-warm-state-info"
/>
</div>
<div>
<label className="block text-sm font-medium text-warm-text-secondary mb-2">Base Model</label>
<select className="w-full h-10 px-3 rounded-md border border-warm-divider bg-white text-warm-text-primary focus:outline-none focus:ring-1 focus:ring-warm-state-info appearance-none">
<option>LayoutLMv3 (Standard)</option>
<option>Donut (Beta)</option>
</select>
</div>
<div>
<div className="flex justify-between mb-2">
<label className="block text-sm font-medium text-warm-text-secondary">Train/Test Split</label>
<span className="text-xs font-mono text-warm-text-muted">{split}% / {100-split}%</span>
</div>
<input
type="range"
min="50"
max="95"
value={split}
onChange={(e) => setSplit(parseInt(e.target.value))}
className="w-full h-1.5 bg-warm-border rounded-lg appearance-none cursor-pointer accent-warm-state-info"
/>
</div>
<div className="pt-4 border-t border-warm-border">
<Button className="w-full h-12">Start Training</Button>
</div>
</div>
</div>
</div>
</div>
);
};

View File

@@ -0,0 +1,210 @@
import React, { useState, useRef } from 'react'
import { X, UploadCloud, File, CheckCircle, AlertCircle } from 'lucide-react'
import { Button } from './Button'
import { useDocuments } from '../hooks/useDocuments'
interface UploadModalProps {
isOpen: boolean
onClose: () => void
}
export const UploadModal: React.FC<UploadModalProps> = ({ isOpen, onClose }) => {
const [isDragging, setIsDragging] = useState(false)
const [selectedFiles, setSelectedFiles] = useState<File[]>([])
const [uploadStatus, setUploadStatus] = useState<'idle' | 'uploading' | 'success' | 'error'>('idle')
const [errorMessage, setErrorMessage] = useState('')
const fileInputRef = useRef<HTMLInputElement>(null)
const { uploadDocument, isUploading } = useDocuments({})
if (!isOpen) return null
const handleFileSelect = (files: FileList | null) => {
if (!files) return
const pdfFiles = Array.from(files).filter(file => {
const isPdf = file.type === 'application/pdf'
const isImage = file.type.startsWith('image/')
const isUnder25MB = file.size <= 25 * 1024 * 1024
return (isPdf || isImage) && isUnder25MB
})
setSelectedFiles(prev => [...prev, ...pdfFiles])
setUploadStatus('idle')
setErrorMessage('')
}
const handleDrop = (e: React.DragEvent) => {
e.preventDefault()
setIsDragging(false)
handleFileSelect(e.dataTransfer.files)
}
const handleBrowseClick = () => {
fileInputRef.current?.click()
}
const removeFile = (index: number) => {
setSelectedFiles(prev => prev.filter((_, i) => i !== index))
}
const handleUpload = async () => {
if (selectedFiles.length === 0) {
setErrorMessage('Please select at least one file')
return
}
setUploadStatus('uploading')
setErrorMessage('')
try {
// Upload files one by one
for (const file of selectedFiles) {
await new Promise<void>((resolve, reject) => {
uploadDocument(file, {
onSuccess: () => resolve(),
onError: (error: Error) => reject(error),
})
})
}
setUploadStatus('success')
setTimeout(() => {
onClose()
setSelectedFiles([])
setUploadStatus('idle')
}, 1500)
} catch (error) {
setUploadStatus('error')
setErrorMessage(error instanceof Error ? error.message : 'Upload failed')
}
}
const handleClose = () => {
if (uploadStatus === 'uploading') {
return // Prevent closing during upload
}
setSelectedFiles([])
setUploadStatus('idle')
setErrorMessage('')
onClose()
}
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/20 backdrop-blur-sm transition-opacity duration-200">
<div
className="w-full max-w-lg bg-warm-card rounded-lg shadow-modal border border-warm-border transform transition-all duration-200 scale-100 p-6"
onClick={(e) => e.stopPropagation()}
>
<div className="flex items-center justify-between mb-6">
<h3 className="text-xl font-semibold text-warm-text-primary">Upload Documents</h3>
<button
onClick={handleClose}
className="text-warm-text-muted hover:text-warm-text-primary transition-colors disabled:opacity-50"
disabled={uploadStatus === 'uploading'}
>
<X size={20} />
</button>
</div>
{/* Drop Zone */}
<div
className={`
w-full h-48 rounded-lg border-2 border-dashed flex flex-col items-center justify-center gap-3 transition-colors duration-150 mb-6 cursor-pointer
${isDragging ? 'border-warm-text-secondary bg-warm-selected' : 'border-warm-divider bg-warm-bg hover:bg-warm-hover'}
${uploadStatus === 'uploading' ? 'opacity-50 pointer-events-none' : ''}
`}
onDragOver={(e) => { e.preventDefault(); setIsDragging(true); }}
onDragLeave={() => setIsDragging(false)}
onDrop={handleDrop}
onClick={handleBrowseClick}
>
<div className="p-3 bg-white rounded-full shadow-sm">
<UploadCloud size={24} className="text-warm-text-secondary" />
</div>
<div className="text-center">
<p className="text-sm font-medium text-warm-text-primary">
Drag & drop files here or <span className="underline decoration-1 underline-offset-2 hover:text-warm-state-info">Browse</span>
</p>
<p className="text-xs text-warm-text-muted mt-1">PDF, JPG, PNG up to 25MB</p>
</div>
</div>
<input
ref={fileInputRef}
type="file"
multiple
accept=".pdf,image/*"
className="hidden"
onChange={(e) => handleFileSelect(e.target.files)}
/>
{/* Selected Files */}
{selectedFiles.length > 0 && (
<div className="mb-6 max-h-40 overflow-y-auto">
<p className="text-sm font-medium text-warm-text-secondary mb-2">
Selected Files ({selectedFiles.length})
</p>
<div className="space-y-2">
{selectedFiles.map((file, index) => (
<div
key={index}
className="flex items-center justify-between p-2 bg-warm-bg rounded border border-warm-border"
>
<div className="flex items-center gap-2 flex-1 min-w-0">
<File size={16} className="text-warm-text-muted flex-shrink-0" />
<span className="text-sm text-warm-text-secondary truncate">
{file.name}
</span>
<span className="text-xs text-warm-text-muted flex-shrink-0">
({(file.size / 1024 / 1024).toFixed(2)} MB)
</span>
</div>
<button
onClick={() => removeFile(index)}
className="text-warm-text-muted hover:text-warm-state-error ml-2 flex-shrink-0"
disabled={uploadStatus === 'uploading'}
>
<X size={16} />
</button>
</div>
))}
</div>
</div>
)}
{/* Status Messages */}
{uploadStatus === 'success' && (
<div className="mb-4 p-3 bg-green-50 border border-green-200 rounded flex items-center gap-2">
<CheckCircle size={16} className="text-green-600" />
<span className="text-sm text-green-800">Upload successful!</span>
</div>
)}
{uploadStatus === 'error' && errorMessage && (
<div className="mb-4 p-3 bg-red-50 border border-red-200 rounded flex items-center gap-2">
<AlertCircle size={16} className="text-red-600" />
<span className="text-sm text-red-800">{errorMessage}</span>
</div>
)}
{/* Actions */}
<div className="mt-8 flex justify-end gap-3">
<Button
variant="secondary"
onClick={handleClose}
disabled={uploadStatus === 'uploading'}
>
Cancel
</Button>
<Button
onClick={handleUpload}
disabled={selectedFiles.length === 0 || uploadStatus === 'uploading'}
>
{uploadStatus === 'uploading' ? 'Uploading...' : `Upload ${selectedFiles.length > 0 ? `(${selectedFiles.length})` : ''}`}
</Button>
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,4 @@
export { useDocuments } from './useDocuments'
export { useDocumentDetail } from './useDocumentDetail'
export { useAnnotations } from './useAnnotations'
export { useTraining, useTrainingDocuments } from './useTraining'

View File

@@ -0,0 +1,70 @@
import { useMutation, useQueryClient } from '@tanstack/react-query'
import { annotationsApi } from '../api/endpoints'
import type { CreateAnnotationRequest, AnnotationOverrideRequest } from '../api/types'
export const useAnnotations = (documentId: string) => {
const queryClient = useQueryClient()
const createMutation = useMutation({
mutationFn: (annotation: CreateAnnotationRequest) =>
annotationsApi.create(documentId, annotation),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['document', documentId] })
},
})
const updateMutation = useMutation({
mutationFn: ({
annotationId,
updates,
}: {
annotationId: string
updates: Partial<CreateAnnotationRequest>
}) => annotationsApi.update(documentId, annotationId, updates),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['document', documentId] })
},
})
const deleteMutation = useMutation({
mutationFn: (annotationId: string) =>
annotationsApi.delete(documentId, annotationId),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['document', documentId] })
},
})
const verifyMutation = useMutation({
mutationFn: (annotationId: string) =>
annotationsApi.verify(documentId, annotationId),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['document', documentId] })
},
})
const overrideMutation = useMutation({
mutationFn: ({
annotationId,
overrideData,
}: {
annotationId: string
overrideData: AnnotationOverrideRequest
}) => annotationsApi.override(documentId, annotationId, overrideData),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['document', documentId] })
},
})
return {
createAnnotation: createMutation.mutate,
isCreating: createMutation.isPending,
updateAnnotation: updateMutation.mutate,
isUpdating: updateMutation.isPending,
deleteAnnotation: deleteMutation.mutate,
isDeleting: deleteMutation.isPending,
verifyAnnotation: verifyMutation.mutate,
isVerifying: verifyMutation.isPending,
overrideAnnotation: overrideMutation.mutate,
isOverriding: overrideMutation.isPending,
}
}

View File

@@ -0,0 +1,25 @@
import { useQuery } from '@tanstack/react-query'
import { documentsApi } from '../api/endpoints'
import type { DocumentDetailResponse } from '../api/types'
export const useDocumentDetail = (documentId: string | null) => {
const { data, isLoading, error, refetch } = useQuery<DocumentDetailResponse>({
queryKey: ['document', documentId],
queryFn: () => {
if (!documentId) {
throw new Error('Document ID is required')
}
return documentsApi.getDetail(documentId)
},
enabled: !!documentId,
staleTime: 10000,
})
return {
document: data || null,
annotations: data?.annotations || [],
isLoading,
error,
refetch,
}
}

View File

@@ -0,0 +1,78 @@
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { documentsApi } from '../api/endpoints'
import type { DocumentListResponse, UploadDocumentResponse } from '../api/types'
interface UseDocumentsParams {
status?: string
limit?: number
offset?: number
}
export const useDocuments = (params: UseDocumentsParams = {}) => {
const queryClient = useQueryClient()
const { data, isLoading, error, refetch } = useQuery<DocumentListResponse>({
queryKey: ['documents', params],
queryFn: () => documentsApi.list(params),
staleTime: 30000,
})
const uploadMutation = useMutation({
mutationFn: (file: File) => documentsApi.upload(file),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['documents'] })
},
})
const batchUploadMutation = useMutation({
mutationFn: ({ files, csvFile }: { files: File[]; csvFile?: File }) =>
documentsApi.batchUpload(files, csvFile),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['documents'] })
},
})
const deleteMutation = useMutation({
mutationFn: (documentId: string) => documentsApi.delete(documentId),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['documents'] })
},
})
const updateStatusMutation = useMutation({
mutationFn: ({ documentId, status }: { documentId: string; status: string }) =>
documentsApi.updateStatus(documentId, status),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['documents'] })
},
})
const triggerAutoLabelMutation = useMutation({
mutationFn: (documentId: string) => documentsApi.triggerAutoLabel(documentId),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['documents'] })
},
})
return {
documents: data?.documents || [],
total: data?.total || 0,
limit: data?.limit || params.limit || 20,
offset: data?.offset || params.offset || 0,
isLoading,
error,
refetch,
uploadDocument: uploadMutation.mutate,
uploadDocumentAsync: uploadMutation.mutateAsync,
isUploading: uploadMutation.isPending,
batchUpload: batchUploadMutation.mutate,
batchUploadAsync: batchUploadMutation.mutateAsync,
isBatchUploading: batchUploadMutation.isPending,
deleteDocument: deleteMutation.mutate,
isDeleting: deleteMutation.isPending,
updateStatus: updateStatusMutation.mutate,
isUpdatingStatus: updateStatusMutation.isPending,
triggerAutoLabel: triggerAutoLabelMutation.mutate,
isTriggeringAutoLabel: triggerAutoLabelMutation.isPending,
}
}

View File

@@ -0,0 +1,83 @@
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { trainingApi } from '../api/endpoints'
import type { TrainingModelsResponse } from '../api/types'
export const useTraining = () => {
const queryClient = useQueryClient()
const { data: modelsData, isLoading: isLoadingModels } =
useQuery<TrainingModelsResponse>({
queryKey: ['training', 'models'],
queryFn: () => trainingApi.getModels(),
staleTime: 30000,
})
const startTrainingMutation = useMutation({
mutationFn: (config: {
name: string
description?: string
document_ids: string[]
epochs?: number
batch_size?: number
model_base?: string
}) => trainingApi.startTraining(config),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['training', 'models'] })
},
})
const cancelTaskMutation = useMutation({
mutationFn: (taskId: string) => trainingApi.cancelTask(taskId),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['training', 'models'] })
},
})
const downloadModelMutation = useMutation({
mutationFn: (taskId: string) => trainingApi.downloadModel(taskId),
onSuccess: (blob, taskId) => {
const url = window.URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = `model-${taskId}.pt`
document.body.appendChild(a)
a.click()
window.URL.revokeObjectURL(url)
document.body.removeChild(a)
},
})
return {
models: modelsData?.models || [],
total: modelsData?.total || 0,
isLoadingModels,
startTraining: startTrainingMutation.mutate,
startTrainingAsync: startTrainingMutation.mutateAsync,
isStartingTraining: startTrainingMutation.isPending,
cancelTask: cancelTaskMutation.mutate,
isCancelling: cancelTaskMutation.isPending,
downloadModel: downloadModelMutation.mutate,
isDownloading: downloadModelMutation.isPending,
}
}
export const useTrainingDocuments = (params?: {
has_annotations?: boolean
min_annotation_count?: number
exclude_used_in_training?: boolean
limit?: number
offset?: number
}) => {
const { data, isLoading, error } = useQuery({
queryKey: ['training', 'documents', params],
queryFn: () => trainingApi.getDocumentsForTraining(params),
staleTime: 30000,
})
return {
documents: data?.documents || [],
total: data?.total || 0,
isLoading,
error,
}
}

23
frontend/src/main.tsx Normal file
View File

@@ -0,0 +1,23 @@
import React from 'react'
import ReactDOM from 'react-dom/client'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import App from './App'
import './styles/index.css'
const queryClient = new QueryClient({
defaultOptions: {
queries: {
retry: 1,
refetchOnWindowFocus: false,
staleTime: 30000,
},
},
})
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<QueryClientProvider client={queryClient}>
<App />
</QueryClientProvider>
</React.StrictMode>
)

View File

@@ -0,0 +1,26 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer base {
body {
@apply bg-warm-bg text-warm-text-primary;
}
/* Custom scrollbar */
::-webkit-scrollbar {
@apply w-2 h-2;
}
::-webkit-scrollbar-track {
@apply bg-transparent;
}
::-webkit-scrollbar-thumb {
@apply bg-warm-divider rounded;
}
::-webkit-scrollbar-thumb:hover {
@apply bg-warm-text-disabled;
}
}

View File

@@ -0,0 +1,48 @@
// Legacy types for backward compatibility with old components
// These will be gradually replaced with API types
export enum DocumentStatus {
PENDING = 'Pending',
LABELED = 'Labeled',
VERIFIED = 'Verified',
PARTIAL = 'Partial'
}
export interface Document {
id: string
name: string
date: string
status: DocumentStatus
exported: boolean
autoLabelProgress?: number
autoLabelStatus?: 'Running' | 'Completed' | 'Failed'
}
export interface Annotation {
id: string
text: string
label: string
x: number
y: number
width: number
height: number
isAuto?: boolean
}
export interface TrainingJob {
id: string
name: string
startDate: string
status: 'Running' | 'Completed' | 'Failed'
progress: number
metrics?: {
accuracy: number
precision: number
recall: number
}
}
export interface ModelMetric {
name: string
value: number
}

View File

@@ -0,0 +1,47 @@
export default {
content: ['./index.html', './src/**/*.{js,ts,jsx,tsx}'],
theme: {
extend: {
fontFamily: {
sans: ['Inter', 'SF Pro', 'system-ui', 'sans-serif'],
mono: ['JetBrains Mono', 'SF Mono', 'monospace'],
},
colors: {
warm: {
bg: '#FAFAF8',
card: '#FFFFFF',
hover: '#F1F0ED',
selected: '#ECEAE6',
border: '#E6E4E1',
divider: '#D8D6D2',
text: {
primary: '#121212',
secondary: '#2A2A2A',
muted: '#6B6B6B',
disabled: '#9A9A9A',
},
state: {
success: '#3E4A3A',
error: '#4A3A3A',
warning: '#4A4A3A',
info: '#3A3A3A',
}
}
},
boxShadow: {
'card': '0 1px 3px rgba(0,0,0,0.08)',
'modal': '0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06)',
},
animation: {
'fade-in': 'fadeIn 0.3s ease-out',
},
keyframes: {
fadeIn: {
'0%': { opacity: '0', transform: 'translateY(10px)' },
'100%': { opacity: '1', transform: 'translateY(0)' },
}
}
}
},
plugins: [],
}

29
frontend/tsconfig.json Normal file
View File

@@ -0,0 +1,29 @@
{
"compilerOptions": {
"target": "ES2022",
"experimentalDecorators": true,
"useDefineForClassFields": false,
"module": "ESNext",
"lib": [
"ES2022",
"DOM",
"DOM.Iterable"
],
"skipLibCheck": true,
"types": [
"node"
],
"moduleResolution": "bundler",
"isolatedModules": true,
"moduleDetection": "force",
"allowJs": true,
"jsx": "react-jsx",
"paths": {
"@/*": [
"./*"
]
},
"allowImportingTsExtensions": true,
"noEmit": true
}
}

16
frontend/vite.config.ts Normal file
View File

@@ -0,0 +1,16 @@
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
export default defineConfig({
server: {
port: 3000,
host: '0.0.0.0',
proxy: {
'/api': {
target: 'http://localhost:8000',
changeOrigin: true,
},
},
},
plugins: [react()],
});