WIP
This commit is contained in:
5
frontend/.env.example
Normal file
5
frontend/.env.example
Normal 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
24
frontend/.gitignore
vendored
Normal 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
20
frontend/README.md
Normal 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`
|
||||
240
frontend/REFACTORING_PLAN.md
Normal file
240
frontend/REFACTORING_PLAN.md
Normal 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
256
frontend/SETUP.md
Normal 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
15
frontend/index.html
Normal 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
5
frontend/metadata.json
Normal 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
3510
frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
32
frontend/package.json
Normal file
32
frontend/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
6
frontend/postcss.config.js
Normal file
6
frontend/postcss.config.js
Normal file
@@ -0,0 +1,6 @@
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
||||
73
frontend/src/App.tsx
Normal file
73
frontend/src/App.tsx
Normal 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
|
||||
41
frontend/src/api/client.ts
Normal file
41
frontend/src/api/client.ts
Normal 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
|
||||
66
frontend/src/api/endpoints/annotations.ts
Normal file
66
frontend/src/api/endpoints/annotations.ts
Normal 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
|
||||
},
|
||||
}
|
||||
80
frontend/src/api/endpoints/documents.ts
Normal file
80
frontend/src/api/endpoints/documents.ts
Normal 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
|
||||
},
|
||||
}
|
||||
4
frontend/src/api/endpoints/index.ts
Normal file
4
frontend/src/api/endpoints/index.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export { documentsApi } from './documents'
|
||||
export { annotationsApi } from './annotations'
|
||||
export { trainingApi } from './training'
|
||||
export { inferenceApi } from './inference'
|
||||
16
frontend/src/api/endpoints/inference.ts
Normal file
16
frontend/src/api/endpoints/inference.ts
Normal 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
|
||||
},
|
||||
}
|
||||
74
frontend/src/api/endpoints/training.ts
Normal file
74
frontend/src/api/endpoints/training.ts
Normal 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
173
frontend/src/api/types.ts
Normal 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
|
||||
}
|
||||
39
frontend/src/components/Badge.tsx
Normal file
39
frontend/src/components/Badge.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
38
frontend/src/components/Button.tsx
Normal file
38
frontend/src/components/Button.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
266
frontend/src/components/Dashboard.tsx
Normal file
266
frontend/src/components/Dashboard.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
148
frontend/src/components/DashboardOverview.tsx
Normal file
148
frontend/src/components/DashboardOverview.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
504
frontend/src/components/DocumentDetail.tsx
Normal file
504
frontend/src/components/DocumentDetail.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
466
frontend/src/components/InferenceDemo.tsx
Normal file
466
frontend/src/components/InferenceDemo.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
102
frontend/src/components/Layout.tsx
Normal file
102
frontend/src/components/Layout.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
188
frontend/src/components/Login.tsx
Normal file
188
frontend/src/components/Login.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
134
frontend/src/components/Models.tsx
Normal file
134
frontend/src/components/Models.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
113
frontend/src/components/Training.tsx
Normal file
113
frontend/src/components/Training.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
210
frontend/src/components/UploadModal.tsx
Normal file
210
frontend/src/components/UploadModal.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
4
frontend/src/hooks/index.ts
Normal file
4
frontend/src/hooks/index.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export { useDocuments } from './useDocuments'
|
||||
export { useDocumentDetail } from './useDocumentDetail'
|
||||
export { useAnnotations } from './useAnnotations'
|
||||
export { useTraining, useTrainingDocuments } from './useTraining'
|
||||
70
frontend/src/hooks/useAnnotations.ts
Normal file
70
frontend/src/hooks/useAnnotations.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
25
frontend/src/hooks/useDocumentDetail.ts
Normal file
25
frontend/src/hooks/useDocumentDetail.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
78
frontend/src/hooks/useDocuments.ts
Normal file
78
frontend/src/hooks/useDocuments.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
83
frontend/src/hooks/useTraining.ts
Normal file
83
frontend/src/hooks/useTraining.ts
Normal 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
23
frontend/src/main.tsx
Normal 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>
|
||||
)
|
||||
26
frontend/src/styles/index.css
Normal file
26
frontend/src/styles/index.css
Normal 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;
|
||||
}
|
||||
}
|
||||
48
frontend/src/types/index.ts
Normal file
48
frontend/src/types/index.ts
Normal 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
|
||||
}
|
||||
47
frontend/tailwind.config.js
Normal file
47
frontend/tailwind.config.js
Normal 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
29
frontend/tsconfig.json
Normal 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
16
frontend/vite.config.ts
Normal 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()],
|
||||
});
|
||||
Reference in New Issue
Block a user