Initial commit: breakpilot-lehrer - Lehrer KI Platform
Services: Admin-Lehrer, Backend-Lehrer, Studio v2, Website, Klausur-Service, School-Service, Voice-Service, Geo-Service, BreakPilot Drive, Agent-Core Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,51 @@
|
||||
# BreakPilot Lehrer — KI-Bildungsplattform
|
||||
|
||||
## Entwicklungsumgebung
|
||||
|
||||
### Zwei-Rechner-Setup
|
||||
| Gerät | Rolle |
|
||||
|-------|-------|
|
||||
| **MacBook** | Client/Terminal |
|
||||
| **Mac Mini** | Server/Docker/Git |
|
||||
|
||||
```bash
|
||||
ssh macmini "cd /Users/benjaminadmin/Projekte/breakpilot-lehrer && <cmd>"
|
||||
```
|
||||
|
||||
## Voraussetzung
|
||||
**breakpilot-core MUSS laufen!** Dieses Projekt nutzt Core-Services (DB, Cache, Auth, RAG).
|
||||
|
||||
## Projektübersicht
|
||||
|
||||
**breakpilot-lehrer** ist die Lehrer-KI-Plattform mit Klausurkorrektur, Unterrichtsplanung und Schülerverwaltung.
|
||||
|
||||
### Enthaltene Services (~12 Container)
|
||||
|
||||
| Service | Port | Beschreibung |
|
||||
|---------|------|--------------|
|
||||
| admin-lehrer | 3002 | Admin-Dashboard (Next.js) |
|
||||
| studio-v2 | 443 | Lehrer-/Schüler-Studio |
|
||||
| website | 3000 | Öffentliche Website |
|
||||
| backend-lehrer | 8001 | Lehrer APIs (FastAPI) |
|
||||
| klausur-service | 8086 | Prüfungen, OCR, RAG |
|
||||
| school-service | 8082 | Schulverwaltung |
|
||||
| geo-service | 8084 | Geo-Daten |
|
||||
| voice-service | 8091 | Spracheingabe |
|
||||
| agent-core | - | Multi-Agent System |
|
||||
|
||||
### Docker-Netzwerk
|
||||
Nutzt das externe Core-Netzwerk:
|
||||
```yaml
|
||||
networks:
|
||||
breakpilot-network:
|
||||
external: true
|
||||
name: breakpilot-network
|
||||
```
|
||||
|
||||
### Container-Naming: `bp-lehrer-*`
|
||||
### DB search_path: `lehrer,core,public`
|
||||
|
||||
## Git Remotes
|
||||
Immer zu BEIDEN pushen:
|
||||
- `origin`: lokale Gitea (macmini:3003)
|
||||
- `gitea`: gitea.meghsakha.com
|
||||
@@ -0,0 +1,56 @@
|
||||
# =========================================================
|
||||
# BreakPilot Lehrer — Environment Variables
|
||||
# =========================================================
|
||||
# Copy to .env and adjust values
|
||||
# NOTE: Core must be running! These vars reference Core services.
|
||||
|
||||
# Database (same as Core)
|
||||
POSTGRES_USER=breakpilot
|
||||
POSTGRES_PASSWORD=breakpilot123
|
||||
POSTGRES_DB=breakpilot_db
|
||||
|
||||
# Security
|
||||
JWT_SECRET=your-super-secret-jwt-key-change-in-production
|
||||
VAULT_TOKEN=breakpilot-dev-token
|
||||
|
||||
# MinIO (from Core)
|
||||
MINIO_ROOT_USER=breakpilot
|
||||
MINIO_ROOT_PASSWORD=breakpilot123
|
||||
MINIO_BUCKET=breakpilot-rag
|
||||
|
||||
# Environment
|
||||
ENVIRONMENT=development
|
||||
TZ=Europe/Berlin
|
||||
|
||||
# LLM (Ollama on host)
|
||||
OLLAMA_BASE_URL=http://host.docker.internal:11434
|
||||
OLLAMA_ENABLED=true
|
||||
OLLAMA_DEFAULT_MODEL=llama3.2
|
||||
OLLAMA_VISION_MODEL=llama3.2-vision
|
||||
OLLAMA_CORRECTION_MODEL=llama3.2
|
||||
OLLAMA_TIMEOUT=120
|
||||
|
||||
# Anthropic (optional)
|
||||
ANTHROPIC_API_KEY=
|
||||
|
||||
# vast.ai GPU (optional)
|
||||
VAST_API_KEY=
|
||||
VAST_INSTANCE_ID=
|
||||
|
||||
# Game
|
||||
GAME_USE_DATABASE=true
|
||||
GAME_REQUIRE_AUTH=false
|
||||
GAME_REQUIRE_BILLING=false
|
||||
GAME_LLM_MODEL=llama3.2
|
||||
|
||||
# Frontend URLs
|
||||
NEXT_PUBLIC_API_URL=https://macmini:8001
|
||||
NEXT_PUBLIC_KLAUSUR_SERVICE_URL=https://macmini:8086
|
||||
NEXT_PUBLIC_VOICE_SERVICE_URL=wss://macmini:8091
|
||||
|
||||
# Session
|
||||
SESSION_TTL_HOURS=24
|
||||
|
||||
# SMTP (uses Core Mailpit)
|
||||
SMTP_HOST=bp-core-mailpit
|
||||
SMTP_PORT=1025
|
||||
+122
@@ -0,0 +1,122 @@
|
||||
# ============================================
|
||||
# BreakPilot Lehrer - Git Ignore
|
||||
# ============================================
|
||||
|
||||
# Environment files (keep examples only)
|
||||
.env
|
||||
.env.local
|
||||
*.env.local
|
||||
|
||||
# Keep examples and environment templates
|
||||
!.env.example
|
||||
!.env.dev
|
||||
!.env.staging
|
||||
|
||||
# ============================================
|
||||
# Node.js
|
||||
# ============================================
|
||||
node_modules/
|
||||
.next/
|
||||
out/
|
||||
dist/
|
||||
build/
|
||||
.npm
|
||||
.yarn-integrity
|
||||
*.tsbuildinfo
|
||||
|
||||
# ============================================
|
||||
# Docker
|
||||
# ============================================
|
||||
backups/
|
||||
*.sql.gz
|
||||
*.sql
|
||||
|
||||
# ============================================
|
||||
# IDE & Editors
|
||||
# ============================================
|
||||
.idea/
|
||||
.vscode/
|
||||
*.swp
|
||||
*.swo
|
||||
*~
|
||||
.project
|
||||
.classpath
|
||||
.settings/
|
||||
*.sublime-workspace
|
||||
*.sublime-project
|
||||
|
||||
# ============================================
|
||||
# OS Files
|
||||
# ============================================
|
||||
.DS_Store
|
||||
.DS_Store?
|
||||
._*
|
||||
.Spotlight-V100
|
||||
.Trashes
|
||||
ehthumbs.db
|
||||
Thumbs.db
|
||||
|
||||
# ============================================
|
||||
# Secrets & Credentials
|
||||
# ============================================
|
||||
secrets/
|
||||
*.pem
|
||||
*.key
|
||||
*.crt
|
||||
*.p12
|
||||
*.pfx
|
||||
credentials.json
|
||||
service-account.json
|
||||
|
||||
# ============================================
|
||||
# Logs
|
||||
# ============================================
|
||||
*.log
|
||||
logs/
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
|
||||
# ============================================
|
||||
# Build Artifacts
|
||||
# ============================================
|
||||
*.zip
|
||||
*.tar.gz
|
||||
*.rar
|
||||
|
||||
# ============================================
|
||||
# Temporary Files
|
||||
# ============================================
|
||||
tmp/
|
||||
temp/
|
||||
*.tmp
|
||||
*.temp
|
||||
|
||||
# ============================================
|
||||
# Test Results
|
||||
# ============================================
|
||||
test-results/
|
||||
playwright-report/
|
||||
coverage/
|
||||
|
||||
# ============================================
|
||||
# IDE Plugins & AI Tools
|
||||
# ============================================
|
||||
.continue/
|
||||
.claude/settings.local.json
|
||||
|
||||
# Large files
|
||||
*.pdf
|
||||
*.zip
|
||||
*.gz
|
||||
*.tar
|
||||
*.sql.gz
|
||||
*.docx
|
||||
*.xlsx
|
||||
*.pptx
|
||||
|
||||
# Compiled binaries
|
||||
*.exe
|
||||
*.dll
|
||||
*.so
|
||||
*.dylib
|
||||
@@ -0,0 +1,44 @@
|
||||
node_modules
|
||||
.next
|
||||
.git
|
||||
.gitignore
|
||||
README.md
|
||||
*.log
|
||||
.env.local
|
||||
.env.*.local
|
||||
|
||||
# Exclude stale root-level dirs that may appear inside admin-v2
|
||||
BreakpilotDrive
|
||||
backend
|
||||
docs
|
||||
billing-service
|
||||
consent-service
|
||||
consent-sdk
|
||||
ai-compliance-sdk
|
||||
admin-v2
|
||||
edu-search-service
|
||||
school-service
|
||||
voice-service
|
||||
geo-service
|
||||
klausur-service
|
||||
studio-v2
|
||||
website
|
||||
scripts
|
||||
agent-core
|
||||
pca-platform
|
||||
breakpilot-drive
|
||||
breakpilot-compliance-sdk
|
||||
dsms-gateway
|
||||
dsms-node
|
||||
h5p-service
|
||||
ai-content-generator
|
||||
policy_vault_*
|
||||
docker
|
||||
.docker
|
||||
vault
|
||||
librechat
|
||||
nginx
|
||||
e2e
|
||||
vitest.config.ts
|
||||
vitest.setup.ts
|
||||
playwright.config.ts
|
||||
@@ -0,0 +1,55 @@
|
||||
# Build stage
|
||||
FROM node:20-alpine AS builder
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Copy package files
|
||||
COPY package.json package-lock.json* ./
|
||||
|
||||
# Install dependencies
|
||||
RUN npm install
|
||||
|
||||
# Copy source code
|
||||
COPY . .
|
||||
|
||||
# Build arguments for environment variables
|
||||
ARG NEXT_PUBLIC_API_URL
|
||||
ARG NEXT_PUBLIC_OLD_ADMIN_URL
|
||||
ARG NEXT_PUBLIC_KLAUSUR_SERVICE_URL
|
||||
|
||||
# Set environment variables for build
|
||||
ENV NEXT_PUBLIC_API_URL=$NEXT_PUBLIC_API_URL
|
||||
ENV NEXT_PUBLIC_OLD_ADMIN_URL=$NEXT_PUBLIC_OLD_ADMIN_URL
|
||||
ENV NEXT_PUBLIC_KLAUSUR_SERVICE_URL=$NEXT_PUBLIC_KLAUSUR_SERVICE_URL
|
||||
|
||||
# Build the application
|
||||
RUN npm run build
|
||||
|
||||
# Production stage
|
||||
FROM node:20-alpine AS runner
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Set to production
|
||||
ENV NODE_ENV=production
|
||||
|
||||
# Create non-root user
|
||||
RUN addgroup --system --gid 1001 nodejs
|
||||
RUN adduser --system --uid 1001 nextjs
|
||||
|
||||
# Copy built assets
|
||||
COPY --from=builder /app/public ./public
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
|
||||
|
||||
# Switch to non-root user
|
||||
USER nextjs
|
||||
|
||||
# Expose port (internal port is 3000, mapped externally by docker-compose)
|
||||
EXPOSE 3000
|
||||
|
||||
# Set hostname
|
||||
ENV HOSTNAME="0.0.0.0"
|
||||
|
||||
# Start the application
|
||||
CMD ["node", "server.js"]
|
||||
@@ -0,0 +1,45 @@
|
||||
# Build stage
|
||||
FROM golang:1.21-alpine AS builder
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Install dependencies
|
||||
RUN apk add --no-cache git ca-certificates
|
||||
|
||||
# Copy go mod files
|
||||
COPY go.mod go.sum* ./
|
||||
|
||||
# Download dependencies
|
||||
RUN go mod download
|
||||
|
||||
# Copy source code
|
||||
COPY . .
|
||||
|
||||
# Build the application
|
||||
RUN CGO_ENABLED=0 GOOS=linux go build -a -installsuffix cgo -o sdk-backend ./cmd/server
|
||||
|
||||
# Runtime stage
|
||||
FROM alpine:3.19
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Install ca-certificates for HTTPS
|
||||
RUN apk add --no-cache ca-certificates tzdata
|
||||
|
||||
# Copy binary from builder
|
||||
COPY --from=builder /app/sdk-backend .
|
||||
COPY --from=builder /app/configs ./configs
|
||||
|
||||
# Create non-root user
|
||||
RUN adduser -D -g '' appuser
|
||||
USER appuser
|
||||
|
||||
# Expose port
|
||||
EXPOSE 8085
|
||||
|
||||
# Health check
|
||||
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
|
||||
CMD wget --no-verbose --tries=1 --spider http://localhost:8085/health || exit 1
|
||||
|
||||
# Run the application
|
||||
CMD ["./sdk-backend"]
|
||||
@@ -0,0 +1,160 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/signal"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"github.com/breakpilot/ai-compliance-sdk/internal/api"
|
||||
"github.com/breakpilot/ai-compliance-sdk/internal/db"
|
||||
"github.com/breakpilot/ai-compliance-sdk/internal/llm"
|
||||
"github.com/breakpilot/ai-compliance-sdk/internal/rag"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/joho/godotenv"
|
||||
)
|
||||
|
||||
func main() {
|
||||
// Load environment variables
|
||||
if err := godotenv.Load(); err != nil {
|
||||
log.Println("No .env file found, using environment variables")
|
||||
}
|
||||
|
||||
// Get configuration from environment
|
||||
port := getEnv("PORT", "8085")
|
||||
dbURL := getEnv("DATABASE_URL", "postgres://localhost:5432/sdk_states?sslmode=disable")
|
||||
qdrantURL := getEnv("QDRANT_URL", "http://localhost:6333")
|
||||
anthropicKey := getEnv("ANTHROPIC_API_KEY", "")
|
||||
|
||||
// Initialize database connection
|
||||
dbPool, err := db.NewPostgresPool(dbURL)
|
||||
if err != nil {
|
||||
log.Printf("Warning: Database connection failed: %v", err)
|
||||
// Continue without database - use in-memory fallback
|
||||
}
|
||||
|
||||
// Initialize RAG service
|
||||
ragService, err := rag.NewService(qdrantURL)
|
||||
if err != nil {
|
||||
log.Printf("Warning: RAG service initialization failed: %v", err)
|
||||
// Continue without RAG - will return empty results
|
||||
}
|
||||
|
||||
// Initialize LLM service
|
||||
llmService := llm.NewService(anthropicKey)
|
||||
|
||||
// Create Gin router
|
||||
gin.SetMode(gin.ReleaseMode)
|
||||
if os.Getenv("GIN_MODE") == "debug" {
|
||||
gin.SetMode(gin.DebugMode)
|
||||
}
|
||||
|
||||
router := gin.Default()
|
||||
|
||||
// CORS middleware
|
||||
router.Use(corsMiddleware())
|
||||
|
||||
// Health check
|
||||
router.GET("/health", func(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"status": "healthy",
|
||||
"timestamp": time.Now().UTC().Format(time.RFC3339),
|
||||
"services": gin.H{
|
||||
"database": dbPool != nil,
|
||||
"rag": ragService != nil,
|
||||
"llm": anthropicKey != "",
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
// API routes
|
||||
v1 := router.Group("/sdk/v1")
|
||||
{
|
||||
// State Management
|
||||
stateHandler := api.NewStateHandler(dbPool)
|
||||
v1.GET("/state/:tenantId", stateHandler.GetState)
|
||||
v1.POST("/state", stateHandler.SaveState)
|
||||
v1.DELETE("/state/:tenantId", stateHandler.DeleteState)
|
||||
|
||||
// RAG Search
|
||||
ragHandler := api.NewRAGHandler(ragService)
|
||||
v1.GET("/rag/search", ragHandler.Search)
|
||||
v1.GET("/rag/status", ragHandler.GetCorpusStatus)
|
||||
v1.POST("/rag/index", ragHandler.IndexDocument)
|
||||
|
||||
// Document Generation
|
||||
generateHandler := api.NewGenerateHandler(llmService, ragService)
|
||||
v1.POST("/generate/dsfa", generateHandler.GenerateDSFA)
|
||||
v1.POST("/generate/tom", generateHandler.GenerateTOM)
|
||||
v1.POST("/generate/vvt", generateHandler.GenerateVVT)
|
||||
v1.POST("/generate/gutachten", generateHandler.GenerateGutachten)
|
||||
|
||||
// Checkpoint Validation
|
||||
checkpointHandler := api.NewCheckpointHandler()
|
||||
v1.GET("/checkpoints", checkpointHandler.GetAll)
|
||||
v1.POST("/checkpoints/validate", checkpointHandler.Validate)
|
||||
}
|
||||
|
||||
// Create server
|
||||
srv := &http.Server{
|
||||
Addr: ":" + port,
|
||||
Handler: router,
|
||||
}
|
||||
|
||||
// Graceful shutdown
|
||||
go func() {
|
||||
log.Printf("SDK Backend starting on port %s", port)
|
||||
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
|
||||
log.Fatalf("Failed to start server: %v", err)
|
||||
}
|
||||
}()
|
||||
|
||||
// Wait for interrupt signal
|
||||
quit := make(chan os.Signal, 1)
|
||||
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
|
||||
<-quit
|
||||
|
||||
log.Println("Shutting down server...")
|
||||
|
||||
// Give outstanding requests 5 seconds to complete
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
if err := srv.Shutdown(ctx); err != nil {
|
||||
log.Fatal("Server forced to shutdown:", err)
|
||||
}
|
||||
|
||||
// Close database connection
|
||||
if dbPool != nil {
|
||||
dbPool.Close()
|
||||
}
|
||||
|
||||
log.Println("Server exited")
|
||||
}
|
||||
|
||||
func getEnv(key, defaultValue string) string {
|
||||
if value := os.Getenv(key); value != "" {
|
||||
return value
|
||||
}
|
||||
return defaultValue
|
||||
}
|
||||
|
||||
func corsMiddleware() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
c.Writer.Header().Set("Access-Control-Allow-Origin", "*")
|
||||
c.Writer.Header().Set("Access-Control-Allow-Credentials", "true")
|
||||
c.Writer.Header().Set("Access-Control-Allow-Headers", "Content-Type, Content-Length, Accept-Encoding, X-CSRF-Token, Authorization, accept, origin, Cache-Control, X-Requested-With, If-Match, If-None-Match")
|
||||
c.Writer.Header().Set("Access-Control-Allow-Methods", "POST, OPTIONS, GET, PUT, DELETE")
|
||||
c.Writer.Header().Set("Access-Control-Expose-Headers", "ETag, Last-Modified")
|
||||
|
||||
if c.Request.Method == "OPTIONS" {
|
||||
c.AbortWithStatus(204)
|
||||
return
|
||||
}
|
||||
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
server:
|
||||
port: 8085
|
||||
mode: release # debug, release, test
|
||||
|
||||
database:
|
||||
url: postgres://localhost:5432/sdk_states?sslmode=disable
|
||||
max_connections: 10
|
||||
min_connections: 2
|
||||
|
||||
rag:
|
||||
qdrant_url: http://localhost:6333
|
||||
collection: legal_corpus
|
||||
embedding_model: BGE-M3
|
||||
top_k: 5
|
||||
|
||||
llm:
|
||||
provider: anthropic # anthropic, openai
|
||||
model: claude-3-5-sonnet-20241022
|
||||
max_tokens: 4096
|
||||
temperature: 0.3
|
||||
|
||||
cors:
|
||||
allowed_origins:
|
||||
- http://localhost:3000
|
||||
- http://localhost:3002
|
||||
- http://macmini:3000
|
||||
- http://macmini:3002
|
||||
allowed_methods:
|
||||
- GET
|
||||
- POST
|
||||
- PUT
|
||||
- DELETE
|
||||
- OPTIONS
|
||||
allowed_headers:
|
||||
- Content-Type
|
||||
- Authorization
|
||||
- If-Match
|
||||
- If-None-Match
|
||||
|
||||
logging:
|
||||
level: info # debug, info, warn, error
|
||||
format: json
|
||||
@@ -0,0 +1,11 @@
|
||||
module github.com/breakpilot/ai-compliance-sdk
|
||||
|
||||
go 1.21
|
||||
|
||||
require (
|
||||
github.com/gin-gonic/gin v1.10.0
|
||||
github.com/jackc/pgx/v5 v5.5.1
|
||||
github.com/joho/godotenv v1.5.1
|
||||
github.com/qdrant/go-client v1.7.0
|
||||
gopkg.in/yaml.v3 v3.0.1
|
||||
)
|
||||
@@ -0,0 +1,327 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// Checkpoint represents a checkpoint definition
|
||||
type Checkpoint struct {
|
||||
ID string `json:"id"`
|
||||
Step string `json:"step"`
|
||||
Name string `json:"name"`
|
||||
Type string `json:"type"`
|
||||
BlocksProgress bool `json:"blocksProgress"`
|
||||
RequiresReview string `json:"requiresReview"`
|
||||
AutoValidate bool `json:"autoValidate"`
|
||||
Description string `json:"description"`
|
||||
}
|
||||
|
||||
// CheckpointHandler handles checkpoint-related requests
|
||||
type CheckpointHandler struct {
|
||||
checkpoints map[string]Checkpoint
|
||||
}
|
||||
|
||||
// NewCheckpointHandler creates a new checkpoint handler
|
||||
func NewCheckpointHandler() *CheckpointHandler {
|
||||
return &CheckpointHandler{
|
||||
checkpoints: initCheckpoints(),
|
||||
}
|
||||
}
|
||||
|
||||
func initCheckpoints() map[string]Checkpoint {
|
||||
return map[string]Checkpoint{
|
||||
"CP-UC": {
|
||||
ID: "CP-UC",
|
||||
Step: "use-case-workshop",
|
||||
Name: "Use Case Erfassung",
|
||||
Type: "REQUIRED",
|
||||
BlocksProgress: true,
|
||||
RequiresReview: "NONE",
|
||||
AutoValidate: true,
|
||||
Description: "Mindestens ein Use Case muss erfasst sein",
|
||||
},
|
||||
"CP-SCAN": {
|
||||
ID: "CP-SCAN",
|
||||
Step: "screening",
|
||||
Name: "System Screening",
|
||||
Type: "REQUIRED",
|
||||
BlocksProgress: true,
|
||||
RequiresReview: "NONE",
|
||||
AutoValidate: true,
|
||||
Description: "SBOM und Security Scan müssen abgeschlossen sein",
|
||||
},
|
||||
"CP-MOD": {
|
||||
ID: "CP-MOD",
|
||||
Step: "modules",
|
||||
Name: "Modul-Zuweisung",
|
||||
Type: "REQUIRED",
|
||||
BlocksProgress: true,
|
||||
RequiresReview: "NONE",
|
||||
AutoValidate: true,
|
||||
Description: "Mindestens ein Compliance-Modul muss zugewiesen sein",
|
||||
},
|
||||
"CP-REQ": {
|
||||
ID: "CP-REQ",
|
||||
Step: "requirements",
|
||||
Name: "Anforderungen",
|
||||
Type: "REQUIRED",
|
||||
BlocksProgress: true,
|
||||
RequiresReview: "NONE",
|
||||
AutoValidate: true,
|
||||
Description: "Anforderungen müssen aus Regulierungen abgeleitet sein",
|
||||
},
|
||||
"CP-CTRL": {
|
||||
ID: "CP-CTRL",
|
||||
Step: "controls",
|
||||
Name: "Controls",
|
||||
Type: "REQUIRED",
|
||||
BlocksProgress: true,
|
||||
RequiresReview: "NONE",
|
||||
AutoValidate: true,
|
||||
Description: "Controls müssen den Anforderungen zugeordnet sein",
|
||||
},
|
||||
"CP-EVI": {
|
||||
ID: "CP-EVI",
|
||||
Step: "evidence",
|
||||
Name: "Nachweise",
|
||||
Type: "REQUIRED",
|
||||
BlocksProgress: true,
|
||||
RequiresReview: "NONE",
|
||||
AutoValidate: true,
|
||||
Description: "Nachweise für Controls müssen dokumentiert sein",
|
||||
},
|
||||
"CP-CHK": {
|
||||
ID: "CP-CHK",
|
||||
Step: "audit-checklist",
|
||||
Name: "Audit Checklist",
|
||||
Type: "REQUIRED",
|
||||
BlocksProgress: true,
|
||||
RequiresReview: "NONE",
|
||||
AutoValidate: true,
|
||||
Description: "Prüfliste muss generiert und überprüft sein",
|
||||
},
|
||||
"CP-RISK": {
|
||||
ID: "CP-RISK",
|
||||
Step: "risks",
|
||||
Name: "Risikobewertung",
|
||||
Type: "REQUIRED",
|
||||
BlocksProgress: true,
|
||||
RequiresReview: "NONE",
|
||||
AutoValidate: true,
|
||||
Description: "Kritische Risiken müssen Mitigationsmaßnahmen haben",
|
||||
},
|
||||
"CP-AI": {
|
||||
ID: "CP-AI",
|
||||
Step: "ai-act",
|
||||
Name: "AI Act Klassifizierung",
|
||||
Type: "REQUIRED",
|
||||
BlocksProgress: true,
|
||||
RequiresReview: "LEGAL",
|
||||
AutoValidate: false,
|
||||
Description: "KI-System muss klassifiziert sein",
|
||||
},
|
||||
"CP-OBL": {
|
||||
ID: "CP-OBL",
|
||||
Step: "obligations",
|
||||
Name: "Pflichtenübersicht",
|
||||
Type: "REQUIRED",
|
||||
BlocksProgress: true,
|
||||
RequiresReview: "NONE",
|
||||
AutoValidate: true,
|
||||
Description: "Rechtliche Pflichten müssen identifiziert sein",
|
||||
},
|
||||
"CP-DSFA": {
|
||||
ID: "CP-DSFA",
|
||||
Step: "dsfa",
|
||||
Name: "DSFA",
|
||||
Type: "RECOMMENDED",
|
||||
BlocksProgress: false,
|
||||
RequiresReview: "DSB",
|
||||
AutoValidate: false,
|
||||
Description: "Datenschutz-Folgenabschätzung muss erstellt und genehmigt sein",
|
||||
},
|
||||
"CP-TOM": {
|
||||
ID: "CP-TOM",
|
||||
Step: "tom",
|
||||
Name: "TOMs",
|
||||
Type: "REQUIRED",
|
||||
BlocksProgress: true,
|
||||
RequiresReview: "NONE",
|
||||
AutoValidate: true,
|
||||
Description: "Technische und organisatorische Maßnahmen müssen definiert sein",
|
||||
},
|
||||
"CP-VVT": {
|
||||
ID: "CP-VVT",
|
||||
Step: "vvt",
|
||||
Name: "Verarbeitungsverzeichnis",
|
||||
Type: "REQUIRED",
|
||||
BlocksProgress: true,
|
||||
RequiresReview: "DSB",
|
||||
AutoValidate: false,
|
||||
Description: "Verarbeitungsverzeichnis muss vollständig sein",
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// GetAll returns all checkpoint definitions
|
||||
func (h *CheckpointHandler) GetAll(c *gin.Context) {
|
||||
tenantID := c.Query("tenantId")
|
||||
|
||||
checkpointList := make([]Checkpoint, 0, len(h.checkpoints))
|
||||
for _, cp := range h.checkpoints {
|
||||
checkpointList = append(checkpointList, cp)
|
||||
}
|
||||
|
||||
SuccessResponse(c, gin.H{
|
||||
"tenantId": tenantID,
|
||||
"checkpoints": checkpointList,
|
||||
})
|
||||
}
|
||||
|
||||
// Validate validates a specific checkpoint
|
||||
func (h *CheckpointHandler) Validate(c *gin.Context) {
|
||||
var req struct {
|
||||
TenantID string `json:"tenantId" binding:"required"`
|
||||
CheckpointID string `json:"checkpointId" binding:"required"`
|
||||
Data map[string]interface{} `json:"data"`
|
||||
}
|
||||
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
ErrorResponse(c, http.StatusBadRequest, err.Error(), "INVALID_REQUEST")
|
||||
return
|
||||
}
|
||||
|
||||
checkpoint, ok := h.checkpoints[req.CheckpointID]
|
||||
if !ok {
|
||||
ErrorResponse(c, http.StatusNotFound, "Checkpoint not found", "CHECKPOINT_NOT_FOUND")
|
||||
return
|
||||
}
|
||||
|
||||
// Perform validation based on checkpoint ID
|
||||
result := h.validateCheckpoint(checkpoint, req.Data)
|
||||
|
||||
SuccessResponse(c, result)
|
||||
}
|
||||
|
||||
func (h *CheckpointHandler) validateCheckpoint(checkpoint Checkpoint, data map[string]interface{}) CheckpointResult {
|
||||
result := CheckpointResult{
|
||||
CheckpointID: checkpoint.ID,
|
||||
Passed: true,
|
||||
ValidatedAt: now(),
|
||||
ValidatedBy: "SYSTEM",
|
||||
Errors: []ValidationError{},
|
||||
Warnings: []ValidationError{},
|
||||
}
|
||||
|
||||
// Validation logic based on checkpoint
|
||||
switch checkpoint.ID {
|
||||
case "CP-UC":
|
||||
useCases, _ := data["useCases"].([]interface{})
|
||||
if len(useCases) == 0 {
|
||||
result.Passed = false
|
||||
result.Errors = append(result.Errors, ValidationError{
|
||||
RuleID: "uc-min-count",
|
||||
Field: "useCases",
|
||||
Message: "Mindestens ein Use Case muss erstellt werden",
|
||||
Severity: "ERROR",
|
||||
})
|
||||
}
|
||||
|
||||
case "CP-SCAN":
|
||||
screening, _ := data["screening"].(map[string]interface{})
|
||||
if screening == nil || screening["status"] != "COMPLETED" {
|
||||
result.Passed = false
|
||||
result.Errors = append(result.Errors, ValidationError{
|
||||
RuleID: "scan-complete",
|
||||
Field: "screening",
|
||||
Message: "Security Scan muss abgeschlossen sein",
|
||||
Severity: "ERROR",
|
||||
})
|
||||
}
|
||||
|
||||
case "CP-MOD":
|
||||
modules, _ := data["modules"].([]interface{})
|
||||
if len(modules) == 0 {
|
||||
result.Passed = false
|
||||
result.Errors = append(result.Errors, ValidationError{
|
||||
RuleID: "mod-min-count",
|
||||
Field: "modules",
|
||||
Message: "Mindestens ein Modul muss zugewiesen werden",
|
||||
Severity: "ERROR",
|
||||
})
|
||||
}
|
||||
|
||||
case "CP-RISK":
|
||||
risks, _ := data["risks"].([]interface{})
|
||||
criticalUnmitigated := 0
|
||||
for _, r := range risks {
|
||||
risk, ok := r.(map[string]interface{})
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
severity, _ := risk["severity"].(string)
|
||||
if severity == "CRITICAL" || severity == "HIGH" {
|
||||
mitigations, _ := risk["mitigation"].([]interface{})
|
||||
if len(mitigations) == 0 {
|
||||
criticalUnmitigated++
|
||||
}
|
||||
}
|
||||
}
|
||||
if criticalUnmitigated > 0 {
|
||||
result.Passed = false
|
||||
result.Errors = append(result.Errors, ValidationError{
|
||||
RuleID: "critical-risks-mitigated",
|
||||
Field: "risks",
|
||||
Message: "Kritische Risiken ohne Mitigationsmaßnahmen gefunden",
|
||||
Severity: "ERROR",
|
||||
})
|
||||
}
|
||||
|
||||
case "CP-DSFA":
|
||||
dsfa, _ := data["dsfa"].(map[string]interface{})
|
||||
if dsfa == nil {
|
||||
result.Passed = false
|
||||
result.Errors = append(result.Errors, ValidationError{
|
||||
RuleID: "dsfa-exists",
|
||||
Field: "dsfa",
|
||||
Message: "DSFA muss erstellt werden",
|
||||
Severity: "ERROR",
|
||||
})
|
||||
} else if dsfa["status"] != "APPROVED" {
|
||||
result.Warnings = append(result.Warnings, ValidationError{
|
||||
RuleID: "dsfa-approved",
|
||||
Field: "dsfa",
|
||||
Message: "DSFA sollte vom DSB genehmigt werden",
|
||||
Severity: "WARNING",
|
||||
})
|
||||
}
|
||||
|
||||
case "CP-TOM":
|
||||
toms, _ := data["toms"].([]interface{})
|
||||
if len(toms) == 0 {
|
||||
result.Passed = false
|
||||
result.Errors = append(result.Errors, ValidationError{
|
||||
RuleID: "tom-min-count",
|
||||
Field: "toms",
|
||||
Message: "Mindestens eine TOM muss definiert werden",
|
||||
Severity: "ERROR",
|
||||
})
|
||||
}
|
||||
|
||||
case "CP-VVT":
|
||||
vvt, _ := data["vvt"].([]interface{})
|
||||
if len(vvt) == 0 {
|
||||
result.Passed = false
|
||||
result.Errors = append(result.Errors, ValidationError{
|
||||
RuleID: "vvt-min-count",
|
||||
Field: "vvt",
|
||||
Message: "Mindestens eine Verarbeitungstätigkeit muss dokumentiert werden",
|
||||
Severity: "ERROR",
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
@@ -0,0 +1,365 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/breakpilot/ai-compliance-sdk/internal/llm"
|
||||
"github.com/breakpilot/ai-compliance-sdk/internal/rag"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// GenerateHandler handles document generation requests
|
||||
type GenerateHandler struct {
|
||||
llmService *llm.Service
|
||||
ragService *rag.Service
|
||||
}
|
||||
|
||||
// NewGenerateHandler creates a new generate handler
|
||||
func NewGenerateHandler(llmService *llm.Service, ragService *rag.Service) *GenerateHandler {
|
||||
return &GenerateHandler{
|
||||
llmService: llmService,
|
||||
ragService: ragService,
|
||||
}
|
||||
}
|
||||
|
||||
// GenerateDSFA generates a Data Protection Impact Assessment
|
||||
func (h *GenerateHandler) GenerateDSFA(c *gin.Context) {
|
||||
var req GenerateRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
ErrorResponse(c, http.StatusBadRequest, err.Error(), "INVALID_REQUEST")
|
||||
return
|
||||
}
|
||||
|
||||
// Get RAG context if requested
|
||||
var ragSources []SearchResult
|
||||
if req.UseRAG && h.ragService != nil {
|
||||
query := req.RAGQuery
|
||||
if query == "" {
|
||||
query = "DSFA Datenschutz-Folgenabschätzung Anforderungen"
|
||||
}
|
||||
results, _ := h.ragService.Search(c.Request.Context(), query, 5, "legal_corpus", "regulation:DSGVO")
|
||||
for _, r := range results {
|
||||
ragSources = append(ragSources, SearchResult{
|
||||
ID: r.ID,
|
||||
Content: r.Content,
|
||||
Source: r.Source,
|
||||
Score: r.Score,
|
||||
Metadata: r.Metadata,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Generate DSFA content
|
||||
content, tokensUsed, err := h.llmService.GenerateDSFA(c.Request.Context(), req.Context, ragSources)
|
||||
if err != nil {
|
||||
// Return mock content if LLM fails
|
||||
content = h.getMockDSFA(req.Context)
|
||||
tokensUsed = 0
|
||||
}
|
||||
|
||||
SuccessResponse(c, GenerateResponse{
|
||||
Content: content,
|
||||
GeneratedAt: now(),
|
||||
Model: h.llmService.GetModel(),
|
||||
TokensUsed: tokensUsed,
|
||||
RAGSources: ragSources,
|
||||
Confidence: 0.85,
|
||||
})
|
||||
}
|
||||
|
||||
// GenerateTOM generates Technical and Organizational Measures
|
||||
func (h *GenerateHandler) GenerateTOM(c *gin.Context) {
|
||||
var req GenerateRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
ErrorResponse(c, http.StatusBadRequest, err.Error(), "INVALID_REQUEST")
|
||||
return
|
||||
}
|
||||
|
||||
// Get RAG context if requested
|
||||
var ragSources []SearchResult
|
||||
if req.UseRAG && h.ragService != nil {
|
||||
query := req.RAGQuery
|
||||
if query == "" {
|
||||
query = "technische organisatorische Maßnahmen TOM Datenschutz"
|
||||
}
|
||||
results, _ := h.ragService.Search(c.Request.Context(), query, 5, "legal_corpus", "")
|
||||
for _, r := range results {
|
||||
ragSources = append(ragSources, SearchResult{
|
||||
ID: r.ID,
|
||||
Content: r.Content,
|
||||
Source: r.Source,
|
||||
Score: r.Score,
|
||||
Metadata: r.Metadata,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Generate TOM content
|
||||
content, tokensUsed, err := h.llmService.GenerateTOM(c.Request.Context(), req.Context, ragSources)
|
||||
if err != nil {
|
||||
content = h.getMockTOM(req.Context)
|
||||
tokensUsed = 0
|
||||
}
|
||||
|
||||
SuccessResponse(c, GenerateResponse{
|
||||
Content: content,
|
||||
GeneratedAt: now(),
|
||||
Model: h.llmService.GetModel(),
|
||||
TokensUsed: tokensUsed,
|
||||
RAGSources: ragSources,
|
||||
Confidence: 0.82,
|
||||
})
|
||||
}
|
||||
|
||||
// GenerateVVT generates Processing Activity Register
|
||||
func (h *GenerateHandler) GenerateVVT(c *gin.Context) {
|
||||
var req GenerateRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
ErrorResponse(c, http.StatusBadRequest, err.Error(), "INVALID_REQUEST")
|
||||
return
|
||||
}
|
||||
|
||||
// Get RAG context if requested
|
||||
var ragSources []SearchResult
|
||||
if req.UseRAG && h.ragService != nil {
|
||||
query := req.RAGQuery
|
||||
if query == "" {
|
||||
query = "Verarbeitungsverzeichnis Art. 30 DSGVO"
|
||||
}
|
||||
results, _ := h.ragService.Search(c.Request.Context(), query, 5, "legal_corpus", "regulation:DSGVO")
|
||||
for _, r := range results {
|
||||
ragSources = append(ragSources, SearchResult{
|
||||
ID: r.ID,
|
||||
Content: r.Content,
|
||||
Source: r.Source,
|
||||
Score: r.Score,
|
||||
Metadata: r.Metadata,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Generate VVT content
|
||||
content, tokensUsed, err := h.llmService.GenerateVVT(c.Request.Context(), req.Context, ragSources)
|
||||
if err != nil {
|
||||
content = h.getMockVVT(req.Context)
|
||||
tokensUsed = 0
|
||||
}
|
||||
|
||||
SuccessResponse(c, GenerateResponse{
|
||||
Content: content,
|
||||
GeneratedAt: now(),
|
||||
Model: h.llmService.GetModel(),
|
||||
TokensUsed: tokensUsed,
|
||||
RAGSources: ragSources,
|
||||
Confidence: 0.88,
|
||||
})
|
||||
}
|
||||
|
||||
// GenerateGutachten generates an expert opinion/assessment
|
||||
func (h *GenerateHandler) GenerateGutachten(c *gin.Context) {
|
||||
var req GenerateRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
ErrorResponse(c, http.StatusBadRequest, err.Error(), "INVALID_REQUEST")
|
||||
return
|
||||
}
|
||||
|
||||
// Get RAG context if requested
|
||||
var ragSources []SearchResult
|
||||
if req.UseRAG && h.ragService != nil {
|
||||
query := req.RAGQuery
|
||||
if query == "" {
|
||||
query = "Compliance Bewertung Gutachten"
|
||||
}
|
||||
results, _ := h.ragService.Search(c.Request.Context(), query, 5, "legal_corpus", "")
|
||||
for _, r := range results {
|
||||
ragSources = append(ragSources, SearchResult{
|
||||
ID: r.ID,
|
||||
Content: r.Content,
|
||||
Source: r.Source,
|
||||
Score: r.Score,
|
||||
Metadata: r.Metadata,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Generate Gutachten content
|
||||
content, tokensUsed, err := h.llmService.GenerateGutachten(c.Request.Context(), req.Context, ragSources)
|
||||
if err != nil {
|
||||
content = h.getMockGutachten(req.Context)
|
||||
tokensUsed = 0
|
||||
}
|
||||
|
||||
SuccessResponse(c, GenerateResponse{
|
||||
Content: content,
|
||||
GeneratedAt: now(),
|
||||
Model: h.llmService.GetModel(),
|
||||
TokensUsed: tokensUsed,
|
||||
RAGSources: ragSources,
|
||||
Confidence: 0.80,
|
||||
})
|
||||
}
|
||||
|
||||
// Mock content generators for when LLM is not available
|
||||
func (h *GenerateHandler) getMockDSFA(context map[string]interface{}) string {
|
||||
return `# Datenschutz-Folgenabschätzung (DSFA)
|
||||
|
||||
## 1. Systematische Beschreibung der Verarbeitungsvorgänge
|
||||
|
||||
Die geplante Verarbeitung umfasst die Analyse von Kundendaten mittels KI-gestützter Systeme zur Verbesserung der Servicequalität und Personalisierung von Angeboten.
|
||||
|
||||
### Verarbeitungszwecke:
|
||||
- Kundensegmentierung und Analyse des Nutzerverhaltens
|
||||
- Personalisierte Empfehlungen
|
||||
- Optimierung von Geschäftsprozessen
|
||||
|
||||
### Rechtsgrundlage:
|
||||
- Art. 6 Abs. 1 lit. f DSGVO (berechtigtes Interesse)
|
||||
- Alternativ: Art. 6 Abs. 1 lit. a DSGVO (Einwilligung)
|
||||
|
||||
## 2. Bewertung der Notwendigkeit und Verhältnismäßigkeit
|
||||
|
||||
Die Verarbeitung ist für die genannten Zwecke erforderlich und verhältnismäßig. Alternative Maßnahmen wurden geprüft, jedoch sind diese weniger effektiv.
|
||||
|
||||
## 3. Risikobewertung
|
||||
|
||||
### Identifizierte Risiken:
|
||||
| Risiko | Eintrittswahrscheinlichkeit | Schwere | Maßnahmen |
|
||||
|--------|---------------------------|---------|-----------|
|
||||
| Unbefugter Zugriff | Mittel | Hoch | Verschlüsselung, Zugangskontrolle |
|
||||
| Profilbildung | Hoch | Mittel | Anonymisierung, Einwilligung |
|
||||
| Datenverlust | Niedrig | Hoch | Backup, Redundanz |
|
||||
|
||||
## 4. Maßnahmen zur Risikominderung
|
||||
|
||||
- Implementierung von Verschlüsselung (AES-256)
|
||||
- Strenge Zugriffskontrollen nach dem Least-Privilege-Prinzip
|
||||
- Regelmäßige Datenschutz-Schulungen
|
||||
- Audit-Logging aller Zugriffe
|
||||
|
||||
## 5. Stellungnahme des Datenschutzbeauftragten
|
||||
|
||||
[Hier Stellungnahme einfügen]
|
||||
|
||||
## 6. Dokumentation der Konsultation
|
||||
|
||||
Erstellt am: ${new Date().toISOString()}
|
||||
Status: ENTWURF
|
||||
`
|
||||
}
|
||||
|
||||
func (h *GenerateHandler) getMockTOM(context map[string]interface{}) string {
|
||||
return `# Technische und Organisatorische Maßnahmen (TOMs)
|
||||
|
||||
## 1. Vertraulichkeit (Art. 32 Abs. 1 lit. b DSGVO)
|
||||
|
||||
### 1.1 Zutrittskontrolle
|
||||
- Alarmanlage
|
||||
- Chipkarten-/Transponder-System
|
||||
- Videoüberwachung der Eingänge
|
||||
- Besuchererfassung und -begleitung
|
||||
|
||||
### 1.2 Zugangskontrolle
|
||||
- Passwort-Richtlinie (min. 12 Zeichen, Komplexitätsanforderungen)
|
||||
- Multi-Faktor-Authentifizierung
|
||||
- Automatische Bildschirmsperre
|
||||
- VPN für Remote-Zugriffe
|
||||
|
||||
### 1.3 Zugriffskontrolle
|
||||
- Rollenbasiertes Berechtigungskonzept
|
||||
- Need-to-know-Prinzip
|
||||
- Regelmäßige Überprüfung der Zugriffsrechte
|
||||
- Protokollierung aller Zugriffe
|
||||
|
||||
## 2. Integrität (Art. 32 Abs. 1 lit. b DSGVO)
|
||||
|
||||
### 2.1 Weitergabekontrolle
|
||||
- Transportverschlüsselung (TLS 1.3)
|
||||
- Ende-zu-Ende-Verschlüsselung für sensible Daten
|
||||
- Sichere E-Mail-Kommunikation (S/MIME)
|
||||
|
||||
### 2.2 Eingabekontrolle
|
||||
- Protokollierung aller Datenänderungen
|
||||
- Benutzeridentifikation bei Änderungen
|
||||
- Audit-Trail für alle Transaktionen
|
||||
|
||||
## 3. Verfügbarkeit (Art. 32 Abs. 1 lit. c DSGVO)
|
||||
|
||||
### 3.1 Verfügbarkeitskontrolle
|
||||
- Tägliche Backups
|
||||
- Georedundante Datenspeicherung
|
||||
- USV-Anlage
|
||||
- Notfallplan
|
||||
|
||||
### 3.2 Wiederherstellung
|
||||
- Dokumentierte Wiederherstellungsverfahren
|
||||
- Regelmäßige Backup-Tests
|
||||
- Maximale Wiederherstellungszeit: 4 Stunden
|
||||
|
||||
## 4. Belastbarkeit (Art. 32 Abs. 1 lit. b DSGVO)
|
||||
|
||||
- Lastverteilung
|
||||
- DDoS-Schutz
|
||||
- Skalierbare Infrastruktur
|
||||
`
|
||||
}
|
||||
|
||||
func (h *GenerateHandler) getMockVVT(context map[string]interface{}) string {
|
||||
return `# Verzeichnis der Verarbeitungstätigkeiten (Art. 30 DSGVO)
|
||||
|
||||
## Verarbeitungstätigkeit: Kundenanalyse und Personalisierung
|
||||
|
||||
### Angaben nach Art. 30 Abs. 1 DSGVO:
|
||||
|
||||
| Feld | Inhalt |
|
||||
|------|--------|
|
||||
| **Name des Verantwortlichen** | [Unternehmensname] |
|
||||
| **Kontaktdaten** | [Adresse, E-Mail, Telefon] |
|
||||
| **Datenschutzbeauftragter** | [Name, Kontakt] |
|
||||
| **Zweck der Verarbeitung** | Kundensegmentierung, Personalisierung, Serviceoptimierung |
|
||||
| **Kategorien betroffener Personen** | Kunden, Interessenten |
|
||||
| **Kategorien personenbezogener Daten** | Kontaktdaten, Nutzungsdaten, Transaktionsdaten |
|
||||
| **Kategorien von Empfängern** | Interne Abteilungen, IT-Dienstleister |
|
||||
| **Drittlandtransfer** | Nein / Ja (mit Angabe der Garantien) |
|
||||
| **Löschfristen** | 3 Jahre nach letzter Aktivität |
|
||||
| **TOM-Referenz** | Siehe TOM-Dokument v1.0 |
|
||||
|
||||
### Rechtsgrundlage:
|
||||
Art. 6 Abs. 1 lit. f DSGVO - Berechtigtes Interesse
|
||||
|
||||
### Dokumentation:
|
||||
- Erstellt: ${new Date().toISOString()}
|
||||
- Letzte Aktualisierung: ${new Date().toISOString()}
|
||||
- Version: 1.0
|
||||
`
|
||||
}
|
||||
|
||||
func (h *GenerateHandler) getMockGutachten(context map[string]interface{}) string {
|
||||
return `# Compliance-Gutachten
|
||||
|
||||
## Zusammenfassung
|
||||
|
||||
Das geprüfte KI-System erfüllt die wesentlichen Anforderungen der DSGVO und des AI Acts. Es wurden jedoch Optimierungspotenziale identifiziert.
|
||||
|
||||
## Prüfungsumfang
|
||||
|
||||
- DSGVO-Konformität
|
||||
- AI Act Compliance
|
||||
- NIS2-Anforderungen
|
||||
|
||||
## Bewertungsergebnis
|
||||
|
||||
| Bereich | Bewertung | Handlungsbedarf |
|
||||
|---------|-----------|-----------------|
|
||||
| Datenschutz | Gut | Gering |
|
||||
| KI-Risikoeinstufung | Erfüllt | Keiner |
|
||||
| Cybersicherheit | Befriedigend | Mittel |
|
||||
|
||||
## Empfehlungen
|
||||
|
||||
1. Verstärkung der Dokumentation
|
||||
2. Regelmäßige Audits einplanen
|
||||
3. Schulungsmaßnahmen erweitern
|
||||
|
||||
Erstellt am: ${new Date().toISOString()}
|
||||
`
|
||||
}
|
||||
@@ -0,0 +1,182 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"github.com/breakpilot/ai-compliance-sdk/internal/rag"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// RAGHandler handles RAG search requests
|
||||
type RAGHandler struct {
|
||||
ragService *rag.Service
|
||||
}
|
||||
|
||||
// NewRAGHandler creates a new RAG handler
|
||||
func NewRAGHandler(ragService *rag.Service) *RAGHandler {
|
||||
return &RAGHandler{
|
||||
ragService: ragService,
|
||||
}
|
||||
}
|
||||
|
||||
// Search performs semantic search on the legal corpus
|
||||
func (h *RAGHandler) Search(c *gin.Context) {
|
||||
query := c.Query("q")
|
||||
if query == "" {
|
||||
ErrorResponse(c, http.StatusBadRequest, "Query parameter 'q' is required", "MISSING_QUERY")
|
||||
return
|
||||
}
|
||||
|
||||
topK := 5
|
||||
if topKStr := c.Query("top_k"); topKStr != "" {
|
||||
if parsed, err := strconv.Atoi(topKStr); err == nil && parsed > 0 {
|
||||
topK = parsed
|
||||
}
|
||||
}
|
||||
|
||||
collection := c.DefaultQuery("collection", "legal_corpus")
|
||||
filter := c.Query("filter") // e.g., "regulation:DSGVO" or "category:ai_act"
|
||||
|
||||
// Check if RAG service is available
|
||||
if h.ragService == nil {
|
||||
// Return mock data when RAG is not available
|
||||
SuccessResponse(c, gin.H{
|
||||
"query": query,
|
||||
"topK": topK,
|
||||
"results": h.getMockResults(query),
|
||||
"source": "mock",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
results, err := h.ragService.Search(c.Request.Context(), query, topK, collection, filter)
|
||||
if err != nil {
|
||||
ErrorResponse(c, http.StatusInternalServerError, "Search failed: "+err.Error(), "SEARCH_FAILED")
|
||||
return
|
||||
}
|
||||
|
||||
SuccessResponse(c, gin.H{
|
||||
"query": query,
|
||||
"topK": topK,
|
||||
"results": results,
|
||||
"source": "qdrant",
|
||||
})
|
||||
}
|
||||
|
||||
// GetCorpusStatus returns the status of the legal corpus
|
||||
func (h *RAGHandler) GetCorpusStatus(c *gin.Context) {
|
||||
if h.ragService == nil {
|
||||
SuccessResponse(c, gin.H{
|
||||
"status": "unavailable",
|
||||
"collections": []string{},
|
||||
"documents": 0,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
status, err := h.ragService.GetCorpusStatus(c.Request.Context())
|
||||
if err != nil {
|
||||
ErrorResponse(c, http.StatusInternalServerError, "Failed to get corpus status", "STATUS_FAILED")
|
||||
return
|
||||
}
|
||||
|
||||
SuccessResponse(c, status)
|
||||
}
|
||||
|
||||
// IndexDocument indexes a new document into the corpus
|
||||
func (h *RAGHandler) IndexDocument(c *gin.Context) {
|
||||
var req struct {
|
||||
Collection string `json:"collection" binding:"required"`
|
||||
ID string `json:"id" binding:"required"`
|
||||
Content string `json:"content" binding:"required"`
|
||||
Metadata map[string]string `json:"metadata"`
|
||||
}
|
||||
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
ErrorResponse(c, http.StatusBadRequest, err.Error(), "INVALID_REQUEST")
|
||||
return
|
||||
}
|
||||
|
||||
if h.ragService == nil {
|
||||
ErrorResponse(c, http.StatusServiceUnavailable, "RAG service not available", "SERVICE_UNAVAILABLE")
|
||||
return
|
||||
}
|
||||
|
||||
err := h.ragService.IndexDocument(c.Request.Context(), req.Collection, req.ID, req.Content, req.Metadata)
|
||||
if err != nil {
|
||||
ErrorResponse(c, http.StatusInternalServerError, "Failed to index document: "+err.Error(), "INDEX_FAILED")
|
||||
return
|
||||
}
|
||||
|
||||
SuccessResponse(c, gin.H{
|
||||
"indexed": true,
|
||||
"id": req.ID,
|
||||
"collection": req.Collection,
|
||||
"indexedAt": now(),
|
||||
})
|
||||
}
|
||||
|
||||
// getMockResults returns mock search results for development
|
||||
func (h *RAGHandler) getMockResults(query string) []SearchResult {
|
||||
// Simplified mock results based on common compliance queries
|
||||
results := []SearchResult{
|
||||
{
|
||||
ID: "dsgvo-art-5",
|
||||
Content: "Art. 5 DSGVO - Grundsätze für die Verarbeitung personenbezogener Daten: Personenbezogene Daten müssen auf rechtmäßige Weise, nach Treu und Glauben und in einer für die betroffene Person nachvollziehbaren Weise verarbeitet werden.",
|
||||
Source: "DSGVO",
|
||||
Score: 0.95,
|
||||
Metadata: map[string]string{
|
||||
"article": "5",
|
||||
"regulation": "DSGVO",
|
||||
"category": "grundsaetze",
|
||||
},
|
||||
},
|
||||
{
|
||||
ID: "dsgvo-art-6",
|
||||
Content: "Art. 6 DSGVO - Rechtmäßigkeit der Verarbeitung: Die Verarbeitung ist nur rechtmäßig, wenn mindestens eine der folgenden Bedingungen erfüllt ist: Einwilligung, Vertragserfüllung, rechtliche Verpflichtung, lebenswichtige Interessen, öffentliche Aufgabe, berechtigtes Interesse.",
|
||||
Source: "DSGVO",
|
||||
Score: 0.89,
|
||||
Metadata: map[string]string{
|
||||
"article": "6",
|
||||
"regulation": "DSGVO",
|
||||
"category": "rechtsgrundlage",
|
||||
},
|
||||
},
|
||||
{
|
||||
ID: "ai-act-art-6",
|
||||
Content: "Art. 6 AI Act - Klassifizierungsregeln für Hochrisiko-KI-Systeme: Ein KI-System gilt als Hochrisiko-System, wenn es als Sicherheitskomponente eines Produkts verwendet wird oder selbst ein Produkt ist, das unter die in Anhang II aufgeführten Harmonisierungsrechtsvorschriften fällt.",
|
||||
Source: "AI Act",
|
||||
Score: 0.85,
|
||||
Metadata: map[string]string{
|
||||
"article": "6",
|
||||
"regulation": "AI_ACT",
|
||||
"category": "hochrisiko",
|
||||
},
|
||||
},
|
||||
{
|
||||
ID: "nis2-art-21",
|
||||
Content: "Art. 21 NIS2 - Risikomanagementmaßnahmen: Wesentliche und wichtige Einrichtungen müssen geeignete und verhältnismäßige technische, operative und organisatorische Maßnahmen ergreifen, um die Risiken für die Sicherheit der Netz- und Informationssysteme zu beherrschen.",
|
||||
Source: "NIS2",
|
||||
Score: 0.78,
|
||||
Metadata: map[string]string{
|
||||
"article": "21",
|
||||
"regulation": "NIS2",
|
||||
"category": "risikomanagement",
|
||||
},
|
||||
},
|
||||
{
|
||||
ID: "dsgvo-art-35",
|
||||
Content: "Art. 35 DSGVO - Datenschutz-Folgenabschätzung: Hat eine Form der Verarbeitung, insbesondere bei Verwendung neuer Technologien, aufgrund der Art, des Umfangs, der Umstände und der Zwecke der Verarbeitung voraussichtlich ein hohes Risiko für die Rechte und Freiheiten natürlicher Personen zur Folge, so führt der Verantwortliche vorab eine Abschätzung der Folgen der vorgesehenen Verarbeitungsvorgänge für den Schutz personenbezogener Daten durch.",
|
||||
Source: "DSGVO",
|
||||
Score: 0.75,
|
||||
Metadata: map[string]string{
|
||||
"article": "35",
|
||||
"regulation": "DSGVO",
|
||||
"category": "dsfa",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
return results
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// Response represents a standard API response
|
||||
type Response struct {
|
||||
Success bool `json:"success"`
|
||||
Data interface{} `json:"data,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
Code string `json:"code,omitempty"`
|
||||
}
|
||||
|
||||
// SuccessResponse creates a success response
|
||||
func SuccessResponse(c *gin.Context, data interface{}) {
|
||||
c.JSON(http.StatusOK, Response{
|
||||
Success: true,
|
||||
Data: data,
|
||||
})
|
||||
}
|
||||
|
||||
// ErrorResponse creates an error response
|
||||
func ErrorResponse(c *gin.Context, status int, err string, code string) {
|
||||
c.JSON(status, Response{
|
||||
Success: false,
|
||||
Error: err,
|
||||
Code: code,
|
||||
})
|
||||
}
|
||||
|
||||
// StateData represents state response data
|
||||
type StateData struct {
|
||||
TenantID string `json:"tenantId"`
|
||||
State interface{} `json:"state"`
|
||||
Version int `json:"version"`
|
||||
LastModified string `json:"lastModified"`
|
||||
}
|
||||
|
||||
// ValidationError represents a validation error
|
||||
type ValidationError struct {
|
||||
RuleID string `json:"ruleId"`
|
||||
Field string `json:"field"`
|
||||
Message string `json:"message"`
|
||||
Severity string `json:"severity"`
|
||||
}
|
||||
|
||||
// CheckpointResult represents checkpoint validation result
|
||||
type CheckpointResult struct {
|
||||
CheckpointID string `json:"checkpointId"`
|
||||
Passed bool `json:"passed"`
|
||||
ValidatedAt string `json:"validatedAt"`
|
||||
ValidatedBy string `json:"validatedBy"`
|
||||
Errors []ValidationError `json:"errors"`
|
||||
Warnings []ValidationError `json:"warnings"`
|
||||
}
|
||||
|
||||
// SearchResult represents a RAG search result
|
||||
type SearchResult struct {
|
||||
ID string `json:"id"`
|
||||
Content string `json:"content"`
|
||||
Source string `json:"source"`
|
||||
Score float64 `json:"score"`
|
||||
Metadata map[string]string `json:"metadata,omitempty"`
|
||||
Highlights []string `json:"highlights,omitempty"`
|
||||
}
|
||||
|
||||
// GenerateRequest represents a document generation request
|
||||
type GenerateRequest struct {
|
||||
TenantID string `json:"tenantId" binding:"required"`
|
||||
Context map[string]interface{} `json:"context"`
|
||||
Template string `json:"template,omitempty"`
|
||||
Language string `json:"language,omitempty"`
|
||||
UseRAG bool `json:"useRag"`
|
||||
RAGQuery string `json:"ragQuery,omitempty"`
|
||||
MaxTokens int `json:"maxTokens,omitempty"`
|
||||
Temperature float64 `json:"temperature,omitempty"`
|
||||
}
|
||||
|
||||
// GenerateResponse represents a document generation response
|
||||
type GenerateResponse struct {
|
||||
Content string `json:"content"`
|
||||
GeneratedAt string `json:"generatedAt"`
|
||||
Model string `json:"model"`
|
||||
TokensUsed int `json:"tokensUsed"`
|
||||
RAGSources []SearchResult `json:"ragSources,omitempty"`
|
||||
Confidence float64 `json:"confidence,omitempty"`
|
||||
}
|
||||
|
||||
// Timestamps helper
|
||||
func now() string {
|
||||
return time.Now().UTC().Format(time.RFC3339)
|
||||
}
|
||||
@@ -0,0 +1,171 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"github.com/breakpilot/ai-compliance-sdk/internal/db"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// StateHandler handles state management requests
|
||||
type StateHandler struct {
|
||||
dbPool *db.Pool
|
||||
memStore *db.InMemoryStore
|
||||
}
|
||||
|
||||
// NewStateHandler creates a new state handler
|
||||
func NewStateHandler(dbPool *db.Pool) *StateHandler {
|
||||
return &StateHandler{
|
||||
dbPool: dbPool,
|
||||
memStore: db.NewInMemoryStore(),
|
||||
}
|
||||
}
|
||||
|
||||
// GetState retrieves state for a tenant
|
||||
func (h *StateHandler) GetState(c *gin.Context) {
|
||||
tenantID := c.Param("tenantId")
|
||||
if tenantID == "" {
|
||||
ErrorResponse(c, http.StatusBadRequest, "tenantId is required", "MISSING_TENANT_ID")
|
||||
return
|
||||
}
|
||||
|
||||
var state *db.SDKState
|
||||
var err error
|
||||
|
||||
// Try database first, fall back to in-memory
|
||||
if h.dbPool != nil {
|
||||
state, err = h.dbPool.GetState(c.Request.Context(), tenantID)
|
||||
} else {
|
||||
state, err = h.memStore.GetState(tenantID)
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
ErrorResponse(c, http.StatusNotFound, "State not found", "STATE_NOT_FOUND")
|
||||
return
|
||||
}
|
||||
|
||||
// Generate ETag
|
||||
etag := generateETag(state.Version, state.UpdatedAt.String())
|
||||
|
||||
// Check If-None-Match header
|
||||
if c.GetHeader("If-None-Match") == etag {
|
||||
c.Status(http.StatusNotModified)
|
||||
return
|
||||
}
|
||||
|
||||
// Parse state JSON
|
||||
var stateData interface{}
|
||||
if err := json.Unmarshal(state.State, &stateData); err != nil {
|
||||
stateData = state.State
|
||||
}
|
||||
|
||||
c.Header("ETag", etag)
|
||||
c.Header("Last-Modified", state.UpdatedAt.Format("Mon, 02 Jan 2006 15:04:05 GMT"))
|
||||
c.Header("Cache-Control", "private, no-cache")
|
||||
|
||||
SuccessResponse(c, StateData{
|
||||
TenantID: state.TenantID,
|
||||
State: stateData,
|
||||
Version: state.Version,
|
||||
LastModified: state.UpdatedAt.Format("2006-01-02T15:04:05Z07:00"),
|
||||
})
|
||||
}
|
||||
|
||||
// SaveState saves state for a tenant
|
||||
func (h *StateHandler) SaveState(c *gin.Context) {
|
||||
var req struct {
|
||||
TenantID string `json:"tenantId" binding:"required"`
|
||||
UserID string `json:"userId"`
|
||||
State json.RawMessage `json:"state" binding:"required"`
|
||||
Version *int `json:"version"`
|
||||
}
|
||||
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
ErrorResponse(c, http.StatusBadRequest, err.Error(), "INVALID_REQUEST")
|
||||
return
|
||||
}
|
||||
|
||||
// Check If-Match header for optimistic locking
|
||||
var expectedVersion *int
|
||||
if ifMatch := c.GetHeader("If-Match"); ifMatch != "" {
|
||||
v, err := strconv.Atoi(ifMatch)
|
||||
if err == nil {
|
||||
expectedVersion = &v
|
||||
}
|
||||
} else if req.Version != nil {
|
||||
expectedVersion = req.Version
|
||||
}
|
||||
|
||||
var state *db.SDKState
|
||||
var err error
|
||||
|
||||
// Try database first, fall back to in-memory
|
||||
if h.dbPool != nil {
|
||||
state, err = h.dbPool.SaveState(c.Request.Context(), req.TenantID, req.UserID, req.State, expectedVersion)
|
||||
} else {
|
||||
state, err = h.memStore.SaveState(req.TenantID, req.UserID, req.State, expectedVersion)
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
if err.Error() == "version conflict" {
|
||||
ErrorResponse(c, http.StatusConflict, "Version conflict. State was modified by another request.", "VERSION_CONFLICT")
|
||||
return
|
||||
}
|
||||
ErrorResponse(c, http.StatusInternalServerError, "Failed to save state", "SAVE_FAILED")
|
||||
return
|
||||
}
|
||||
|
||||
// Generate ETag
|
||||
etag := generateETag(state.Version, state.UpdatedAt.String())
|
||||
|
||||
// Parse state JSON
|
||||
var stateData interface{}
|
||||
if err := json.Unmarshal(state.State, &stateData); err != nil {
|
||||
stateData = state.State
|
||||
}
|
||||
|
||||
c.Header("ETag", etag)
|
||||
c.Header("Last-Modified", state.UpdatedAt.Format("Mon, 02 Jan 2006 15:04:05 GMT"))
|
||||
|
||||
SuccessResponse(c, StateData{
|
||||
TenantID: state.TenantID,
|
||||
State: stateData,
|
||||
Version: state.Version,
|
||||
LastModified: state.UpdatedAt.Format("2006-01-02T15:04:05Z07:00"),
|
||||
})
|
||||
}
|
||||
|
||||
// DeleteState deletes state for a tenant
|
||||
func (h *StateHandler) DeleteState(c *gin.Context) {
|
||||
tenantID := c.Param("tenantId")
|
||||
if tenantID == "" {
|
||||
ErrorResponse(c, http.StatusBadRequest, "tenantId is required", "MISSING_TENANT_ID")
|
||||
return
|
||||
}
|
||||
|
||||
var err error
|
||||
|
||||
// Try database first, fall back to in-memory
|
||||
if h.dbPool != nil {
|
||||
err = h.dbPool.DeleteState(c.Request.Context(), tenantID)
|
||||
} else {
|
||||
err = h.memStore.DeleteState(tenantID)
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
ErrorResponse(c, http.StatusInternalServerError, "Failed to delete state", "DELETE_FAILED")
|
||||
return
|
||||
}
|
||||
|
||||
SuccessResponse(c, gin.H{
|
||||
"tenantId": tenantID,
|
||||
"deletedAt": now(),
|
||||
})
|
||||
}
|
||||
|
||||
// generateETag creates an ETag from version and timestamp
|
||||
func generateETag(version int, timestamp string) string {
|
||||
return "\"" + strconv.Itoa(version) + "-" + timestamp[:8] + "\""
|
||||
}
|
||||
@@ -0,0 +1,173 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
|
||||
// Pool wraps a pgxpool.Pool with SDK-specific methods
|
||||
type Pool struct {
|
||||
*pgxpool.Pool
|
||||
}
|
||||
|
||||
// SDKState represents the state stored in the database
|
||||
type SDKState struct {
|
||||
ID string `json:"id"`
|
||||
TenantID string `json:"tenant_id"`
|
||||
UserID string `json:"user_id,omitempty"`
|
||||
State json.RawMessage `json:"state"`
|
||||
Version int `json:"version"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
// NewPostgresPool creates a new database connection pool
|
||||
func NewPostgresPool(connectionString string) (*Pool, error) {
|
||||
config, err := pgxpool.ParseConfig(connectionString)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to parse connection string: %w", err)
|
||||
}
|
||||
|
||||
config.MaxConns = 10
|
||||
config.MinConns = 2
|
||||
config.MaxConnLifetime = 1 * time.Hour
|
||||
config.MaxConnIdleTime = 30 * time.Minute
|
||||
|
||||
pool, err := pgxpool.NewWithConfig(context.Background(), config)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create connection pool: %w", err)
|
||||
}
|
||||
|
||||
// Test connection
|
||||
if err := pool.Ping(context.Background()); err != nil {
|
||||
return nil, fmt.Errorf("failed to ping database: %w", err)
|
||||
}
|
||||
|
||||
return &Pool{Pool: pool}, nil
|
||||
}
|
||||
|
||||
// GetState retrieves state for a tenant
|
||||
func (p *Pool) GetState(ctx context.Context, tenantID string) (*SDKState, error) {
|
||||
query := `
|
||||
SELECT id, tenant_id, user_id, state, version, created_at, updated_at
|
||||
FROM sdk_states
|
||||
WHERE tenant_id = $1
|
||||
`
|
||||
|
||||
var state SDKState
|
||||
err := p.QueryRow(ctx, query, tenantID).Scan(
|
||||
&state.ID,
|
||||
&state.TenantID,
|
||||
&state.UserID,
|
||||
&state.State,
|
||||
&state.Version,
|
||||
&state.CreatedAt,
|
||||
&state.UpdatedAt,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &state, nil
|
||||
}
|
||||
|
||||
// SaveState saves or updates state for a tenant with optimistic locking
|
||||
func (p *Pool) SaveState(ctx context.Context, tenantID string, userID string, state json.RawMessage, expectedVersion *int) (*SDKState, error) {
|
||||
query := `
|
||||
INSERT INTO sdk_states (tenant_id, user_id, state, version)
|
||||
VALUES ($1, $2, $3, 1)
|
||||
ON CONFLICT (tenant_id) DO UPDATE SET
|
||||
state = $3,
|
||||
user_id = COALESCE($2, sdk_states.user_id),
|
||||
version = sdk_states.version + 1,
|
||||
updated_at = NOW()
|
||||
WHERE ($4::int IS NULL OR sdk_states.version = $4)
|
||||
RETURNING id, tenant_id, user_id, state, version, created_at, updated_at
|
||||
`
|
||||
|
||||
var result SDKState
|
||||
err := p.QueryRow(ctx, query, tenantID, userID, state, expectedVersion).Scan(
|
||||
&result.ID,
|
||||
&result.TenantID,
|
||||
&result.UserID,
|
||||
&result.State,
|
||||
&result.Version,
|
||||
&result.CreatedAt,
|
||||
&result.UpdatedAt,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &result, nil
|
||||
}
|
||||
|
||||
// DeleteState deletes state for a tenant
|
||||
func (p *Pool) DeleteState(ctx context.Context, tenantID string) error {
|
||||
query := `DELETE FROM sdk_states WHERE tenant_id = $1`
|
||||
_, err := p.Exec(ctx, query, tenantID)
|
||||
return err
|
||||
}
|
||||
|
||||
// InMemoryStore provides an in-memory fallback when database is not available
|
||||
type InMemoryStore struct {
|
||||
states map[string]*SDKState
|
||||
}
|
||||
|
||||
// NewInMemoryStore creates a new in-memory store
|
||||
func NewInMemoryStore() *InMemoryStore {
|
||||
return &InMemoryStore{
|
||||
states: make(map[string]*SDKState),
|
||||
}
|
||||
}
|
||||
|
||||
// GetState retrieves state from memory
|
||||
func (s *InMemoryStore) GetState(tenantID string) (*SDKState, error) {
|
||||
state, ok := s.states[tenantID]
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("state not found")
|
||||
}
|
||||
return state, nil
|
||||
}
|
||||
|
||||
// SaveState saves state to memory
|
||||
func (s *InMemoryStore) SaveState(tenantID string, userID string, state json.RawMessage, expectedVersion *int) (*SDKState, error) {
|
||||
existing, exists := s.states[tenantID]
|
||||
|
||||
// Optimistic locking check
|
||||
if expectedVersion != nil && exists && existing.Version != *expectedVersion {
|
||||
return nil, fmt.Errorf("version conflict")
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
version := 1
|
||||
createdAt := now
|
||||
|
||||
if exists {
|
||||
version = existing.Version + 1
|
||||
createdAt = existing.CreatedAt
|
||||
}
|
||||
|
||||
newState := &SDKState{
|
||||
ID: fmt.Sprintf("%s-%d", tenantID, time.Now().UnixNano()),
|
||||
TenantID: tenantID,
|
||||
UserID: userID,
|
||||
State: state,
|
||||
Version: version,
|
||||
CreatedAt: createdAt,
|
||||
UpdatedAt: now,
|
||||
}
|
||||
|
||||
s.states[tenantID] = newState
|
||||
return newState, nil
|
||||
}
|
||||
|
||||
// DeleteState deletes state from memory
|
||||
func (s *InMemoryStore) DeleteState(tenantID string) error {
|
||||
delete(s.states, tenantID)
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,384 @@
|
||||
package llm
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// SearchResult matches the RAG service result structure
|
||||
type SearchResult struct {
|
||||
ID string `json:"id"`
|
||||
Content string `json:"content"`
|
||||
Source string `json:"source"`
|
||||
Score float64 `json:"score"`
|
||||
Metadata map[string]string `json:"metadata,omitempty"`
|
||||
}
|
||||
|
||||
// Service provides LLM functionality for document generation
|
||||
type Service struct {
|
||||
apiKey string
|
||||
model string
|
||||
}
|
||||
|
||||
// NewService creates a new LLM service
|
||||
func NewService(apiKey string) *Service {
|
||||
model := "claude-3-5-sonnet-20241022"
|
||||
if apiKey == "" {
|
||||
model = "mock"
|
||||
}
|
||||
return &Service{
|
||||
apiKey: apiKey,
|
||||
model: model,
|
||||
}
|
||||
}
|
||||
|
||||
// GetModel returns the current model name
|
||||
func (s *Service) GetModel() string {
|
||||
return s.model
|
||||
}
|
||||
|
||||
// GenerateDSFA generates a Data Protection Impact Assessment
|
||||
func (s *Service) GenerateDSFA(ctx context.Context, context map[string]interface{}, ragSources []SearchResult) (string, int, error) {
|
||||
if s.apiKey == "" {
|
||||
return "", 0, fmt.Errorf("LLM not configured")
|
||||
}
|
||||
|
||||
// Build prompt with context and RAG sources
|
||||
prompt := s.buildDSFAPrompt(context, ragSources)
|
||||
|
||||
// In production, this would call the Anthropic API
|
||||
// response, err := s.callAnthropicAPI(ctx, prompt)
|
||||
// if err != nil {
|
||||
// return "", 0, err
|
||||
// }
|
||||
|
||||
// For now, simulate a response
|
||||
content := s.generateDSFAContent(context, ragSources)
|
||||
tokensUsed := len(strings.Split(content, " ")) * 2 // Rough estimate
|
||||
|
||||
return content, tokensUsed, nil
|
||||
}
|
||||
|
||||
// GenerateTOM generates Technical and Organizational Measures
|
||||
func (s *Service) GenerateTOM(ctx context.Context, context map[string]interface{}, ragSources []SearchResult) (string, int, error) {
|
||||
if s.apiKey == "" {
|
||||
return "", 0, fmt.Errorf("LLM not configured")
|
||||
}
|
||||
|
||||
content := s.generateTOMContent(context, ragSources)
|
||||
tokensUsed := len(strings.Split(content, " ")) * 2
|
||||
|
||||
return content, tokensUsed, nil
|
||||
}
|
||||
|
||||
// GenerateVVT generates a Processing Activity Register
|
||||
func (s *Service) GenerateVVT(ctx context.Context, context map[string]interface{}, ragSources []SearchResult) (string, int, error) {
|
||||
if s.apiKey == "" {
|
||||
return "", 0, fmt.Errorf("LLM not configured")
|
||||
}
|
||||
|
||||
content := s.generateVVTContent(context, ragSources)
|
||||
tokensUsed := len(strings.Split(content, " ")) * 2
|
||||
|
||||
return content, tokensUsed, nil
|
||||
}
|
||||
|
||||
// GenerateGutachten generates an expert opinion/assessment
|
||||
func (s *Service) GenerateGutachten(ctx context.Context, context map[string]interface{}, ragSources []SearchResult) (string, int, error) {
|
||||
if s.apiKey == "" {
|
||||
return "", 0, fmt.Errorf("LLM not configured")
|
||||
}
|
||||
|
||||
content := s.generateGutachtenContent(context, ragSources)
|
||||
tokensUsed := len(strings.Split(content, " ")) * 2
|
||||
|
||||
return content, tokensUsed, nil
|
||||
}
|
||||
|
||||
// buildDSFAPrompt builds the prompt for DSFA generation
|
||||
func (s *Service) buildDSFAPrompt(context map[string]interface{}, ragSources []SearchResult) string {
|
||||
var sb strings.Builder
|
||||
|
||||
sb.WriteString("Du bist ein Datenschutz-Experte und erstellst eine Datenschutz-Folgenabschätzung (DSFA) gemäß Art. 35 DSGVO.\n\n")
|
||||
|
||||
// Add context
|
||||
if useCaseName, ok := context["useCaseName"].(string); ok {
|
||||
sb.WriteString(fmt.Sprintf("Use Case: %s\n", useCaseName))
|
||||
}
|
||||
if description, ok := context["description"].(string); ok {
|
||||
sb.WriteString(fmt.Sprintf("Beschreibung: %s\n", description))
|
||||
}
|
||||
|
||||
// Add RAG context
|
||||
if len(ragSources) > 0 {
|
||||
sb.WriteString("\nRelevante rechtliche Grundlagen:\n")
|
||||
for _, source := range ragSources {
|
||||
sb.WriteString(fmt.Sprintf("- %s (%s)\n", source.Content[:min(200, len(source.Content))], source.Source))
|
||||
}
|
||||
}
|
||||
|
||||
sb.WriteString("\nErstelle eine vollständige DSFA mit allen erforderlichen Abschnitten.")
|
||||
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
// Content generation functions (would be replaced by actual LLM calls in production)
|
||||
func (s *Service) generateDSFAContent(context map[string]interface{}, ragSources []SearchResult) string {
|
||||
useCaseName := "KI-gestützte Datenverarbeitung"
|
||||
if name, ok := context["useCaseName"].(string); ok {
|
||||
useCaseName = name
|
||||
}
|
||||
|
||||
return fmt.Sprintf(`# Datenschutz-Folgenabschätzung (DSFA)
|
||||
|
||||
## Use Case: %s
|
||||
|
||||
## 1. Systematische Beschreibung der Verarbeitungsvorgänge
|
||||
|
||||
Die geplante Verarbeitung umfasst die Analyse von Daten mittels KI-gestützter Systeme.
|
||||
|
||||
### 1.1 Verarbeitungszwecke
|
||||
- Automatisierte Analyse und Verarbeitung
|
||||
- Optimierung von Geschäftsprozessen
|
||||
- Qualitätssicherung
|
||||
|
||||
### 1.2 Rechtsgrundlage
|
||||
Gemäß Art. 6 Abs. 1 lit. f DSGVO basiert die Verarbeitung auf dem berechtigten Interesse des Verantwortlichen.
|
||||
|
||||
### 1.3 Kategorien verarbeiteter Daten
|
||||
- Nutzungsdaten
|
||||
- Metadaten
|
||||
- Aggregierte Analysedaten
|
||||
|
||||
## 2. Bewertung der Notwendigkeit und Verhältnismäßigkeit
|
||||
|
||||
### 2.1 Notwendigkeit
|
||||
Die Verarbeitung ist erforderlich, um die definierten Geschäftsziele zu erreichen.
|
||||
|
||||
### 2.2 Verhältnismäßigkeit
|
||||
Alternative Methoden wurden geprüft. Die gewählte Verarbeitungsmethode stellt den geringsten Eingriff bei gleichem Nutzen dar.
|
||||
|
||||
## 3. Risikobewertung
|
||||
|
||||
### 3.1 Identifizierte Risiken
|
||||
|
||||
| Risiko | Wahrscheinlichkeit | Schwere | Gesamtbewertung |
|
||||
|--------|-------------------|---------|-----------------|
|
||||
| Unbefugter Zugriff | Mittel | Hoch | HOCH |
|
||||
| Datenverlust | Niedrig | Hoch | MITTEL |
|
||||
| Fehlinterpretation | Mittel | Mittel | MITTEL |
|
||||
|
||||
### 3.2 Maßnahmen zur Risikominderung
|
||||
|
||||
1. **Technische Maßnahmen**
|
||||
- Verschlüsselung (AES-256)
|
||||
- Zugriffskontrollen
|
||||
- Audit-Logging
|
||||
|
||||
2. **Organisatorische Maßnahmen**
|
||||
- Schulungen
|
||||
- Dokumentation
|
||||
- Regelmäßige Überprüfungen
|
||||
|
||||
## 4. Genehmigungsstatus
|
||||
|
||||
| Rolle | Status | Datum |
|
||||
|-------|--------|-------|
|
||||
| Projektleiter | AUSSTEHEND | - |
|
||||
| DSB | AUSSTEHEND | - |
|
||||
| Geschäftsführung | AUSSTEHEND | - |
|
||||
|
||||
---
|
||||
*Generiert mit KI-Unterstützung. Manuelle Überprüfung erforderlich.*
|
||||
`, useCaseName)
|
||||
}
|
||||
|
||||
func (s *Service) generateTOMContent(context map[string]interface{}, ragSources []SearchResult) string {
|
||||
return `# Technische und Organisatorische Maßnahmen (TOMs)
|
||||
|
||||
## 1. Vertraulichkeit (Art. 32 Abs. 1 lit. b DSGVO)
|
||||
|
||||
### 1.1 Zutrittskontrolle
|
||||
- [ ] Alarmanlage installiert
|
||||
- [ ] Chipkarten-System aktiv
|
||||
- [ ] Besucherprotokoll geführt
|
||||
|
||||
### 1.2 Zugangskontrolle
|
||||
- [ ] Starke Passwort-Policy (12+ Zeichen)
|
||||
- [ ] MFA aktiviert
|
||||
- [ ] Automatische Bildschirmsperre
|
||||
|
||||
### 1.3 Zugriffskontrolle
|
||||
- [ ] Rollenbasierte Berechtigungen
|
||||
- [ ] Need-to-know Prinzip
|
||||
- [ ] Quartalsweise Berechtigungsüberprüfung
|
||||
|
||||
## 2. Integrität (Art. 32 Abs. 1 lit. b DSGVO)
|
||||
|
||||
### 2.1 Weitergabekontrolle
|
||||
- [ ] TLS 1.3 für alle Übertragungen
|
||||
- [ ] E-Mail-Verschlüsselung
|
||||
- [ ] Sichere File-Transfer-Protokolle
|
||||
|
||||
### 2.2 Eingabekontrolle
|
||||
- [ ] Vollständiges Audit-Logging
|
||||
- [ ] Benutzeridentifikation bei Änderungen
|
||||
- [ ] Unveränderliche Protokolle
|
||||
|
||||
## 3. Verfügbarkeit (Art. 32 Abs. 1 lit. c DSGVO)
|
||||
|
||||
### 3.1 Verfügbarkeitskontrolle
|
||||
- [ ] Tägliche Backups
|
||||
- [ ] Georedundante Speicherung
|
||||
- [ ] USV-System
|
||||
- [ ] Dokumentierter Notfallplan
|
||||
|
||||
### 3.2 Wiederherstellung
|
||||
- [ ] RPO: 1 Stunde
|
||||
- [ ] RTO: 4 Stunden
|
||||
- [ ] Jährliche Wiederherstellungstests
|
||||
|
||||
## 4. Belastbarkeit
|
||||
|
||||
- [ ] DDoS-Schutz implementiert
|
||||
- [ ] Lastverteilung aktiv
|
||||
- [ ] Skalierbare Infrastruktur
|
||||
|
||||
---
|
||||
*Generiert mit KI-Unterstützung. Manuelle Überprüfung erforderlich.*
|
||||
`
|
||||
}
|
||||
|
||||
func (s *Service) generateVVTContent(context map[string]interface{}, ragSources []SearchResult) string {
|
||||
return `# Verzeichnis der Verarbeitungstätigkeiten (Art. 30 DSGVO)
|
||||
|
||||
## Verarbeitungstätigkeit Nr. 1
|
||||
|
||||
### Stammdaten
|
||||
|
||||
| Feld | Wert |
|
||||
|------|------|
|
||||
| **Bezeichnung** | KI-gestützte Datenanalyse |
|
||||
| **Verantwortlicher** | [Unternehmen] |
|
||||
| **DSB** | [Name, Kontakt] |
|
||||
| **Abteilung** | IT / Data Science |
|
||||
|
||||
### Verarbeitungsdetails
|
||||
|
||||
| Feld | Wert |
|
||||
|------|------|
|
||||
| **Zweck** | Optimierung von Geschäftsprozessen durch KI-Analyse |
|
||||
| **Rechtsgrundlage** | Art. 6 Abs. 1 lit. f DSGVO |
|
||||
| **Betroffene Kategorien** | Kunden, Mitarbeiter, Geschäftspartner |
|
||||
| **Datenkategorien** | Nutzungsdaten, Metadaten, Analyseergebnisse |
|
||||
|
||||
### Empfänger
|
||||
|
||||
| Kategorie | Beispiele |
|
||||
|-----------|-----------|
|
||||
| Intern | IT-Abteilung, Management |
|
||||
| Auftragsverarbeiter | Cloud-Provider (mit AVV) |
|
||||
| Dritte | Keine |
|
||||
|
||||
### Drittlandtransfer
|
||||
|
||||
| Frage | Antwort |
|
||||
|-------|---------|
|
||||
| Übermittlung in Drittländer? | Nein / Ja |
|
||||
| Falls ja, Garantien | [Standardvertragsklauseln / Angemessenheitsbeschluss] |
|
||||
|
||||
### Löschfristen
|
||||
|
||||
| Datenkategorie | Frist | Grundlage |
|
||||
|----------------|-------|-----------|
|
||||
| Nutzungsdaten | 12 Monate | Betriebliche Notwendigkeit |
|
||||
| Analyseergebnisse | 36 Monate | Geschäftszweck |
|
||||
| Audit-Logs | 10 Jahre | Handelsrechtlich |
|
||||
|
||||
### Technisch-Organisatorische Maßnahmen
|
||||
|
||||
Verweis auf TOM-Dokument Version 1.0
|
||||
|
||||
---
|
||||
*Generiert mit KI-Unterstützung. Manuelle Überprüfung erforderlich.*
|
||||
`
|
||||
}
|
||||
|
||||
func (s *Service) generateGutachtenContent(context map[string]interface{}, ragSources []SearchResult) string {
|
||||
return `# Compliance-Gutachten
|
||||
|
||||
## Management Summary
|
||||
|
||||
Das geprüfte System erfüllt die wesentlichen Anforderungen der anwendbaren Regulierungen. Es bestehen Optimierungspotenziale, die priorisiert adressiert werden sollten.
|
||||
|
||||
## 1. Prüfungsumfang
|
||||
|
||||
### 1.1 Geprüfte Regulierungen
|
||||
- DSGVO (EU 2016/679)
|
||||
- AI Act (EU 2024/...)
|
||||
- NIS2 (EU 2022/2555)
|
||||
|
||||
### 1.2 Prüfungsmethodik
|
||||
- Dokumentenprüfung
|
||||
- Technische Analyse
|
||||
- Interviews mit Stakeholdern
|
||||
|
||||
## 2. Ergebnisse
|
||||
|
||||
### 2.1 DSGVO-Konformität
|
||||
|
||||
| Bereich | Bewertung | Handlungsbedarf |
|
||||
|---------|-----------|-----------------|
|
||||
| Rechtmäßigkeit | ✓ Erfüllt | Gering |
|
||||
| Transparenz | ◐ Teilweise | Mittel |
|
||||
| Datensicherheit | ✓ Erfüllt | Gering |
|
||||
| Betroffenenrechte | ◐ Teilweise | Mittel |
|
||||
|
||||
### 2.2 AI Act-Konformität
|
||||
|
||||
| Bereich | Bewertung | Handlungsbedarf |
|
||||
|---------|-----------|-----------------|
|
||||
| Risikoklassifizierung | ✓ Erfüllt | Keiner |
|
||||
| Dokumentation | ◐ Teilweise | Mittel |
|
||||
| Human Oversight | ✓ Erfüllt | Gering |
|
||||
|
||||
### 2.3 NIS2-Konformität
|
||||
|
||||
| Bereich | Bewertung | Handlungsbedarf |
|
||||
|---------|-----------|-----------------|
|
||||
| Risikomanagement | ✓ Erfüllt | Gering |
|
||||
| Incident Reporting | ◐ Teilweise | Hoch |
|
||||
| Supply Chain | ○ Nicht erfüllt | Kritisch |
|
||||
|
||||
## 3. Empfehlungen
|
||||
|
||||
### Kritisch (sofort)
|
||||
1. Supply-Chain-Risikomanagement implementieren
|
||||
2. Incident-Reporting-Prozess etablieren
|
||||
|
||||
### Hoch (< 3 Monate)
|
||||
3. Transparenzdokumentation vervollständigen
|
||||
4. Betroffenenrechte-Portal optimieren
|
||||
|
||||
### Mittel (< 6 Monate)
|
||||
5. AI Act Dokumentation erweitern
|
||||
6. Schulungsmaßnahmen durchführen
|
||||
|
||||
## 4. Fazit
|
||||
|
||||
Das System zeigt einen guten Compliance-Stand mit klar definierten Verbesserungsbereichen. Bei Umsetzung der Empfehlungen ist eine vollständige Konformität erreichbar.
|
||||
|
||||
---
|
||||
*Erstellt: [Datum]*
|
||||
*Gutachter: [Name]*
|
||||
*Version: 1.0*
|
||||
`
|
||||
}
|
||||
|
||||
func min(a, b int) int {
|
||||
if a < b {
|
||||
return a
|
||||
}
|
||||
return b
|
||||
}
|
||||
@@ -0,0 +1,208 @@
|
||||
package rag
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
// SearchResult represents a search result from the RAG system
|
||||
type SearchResult struct {
|
||||
ID string `json:"id"`
|
||||
Content string `json:"content"`
|
||||
Source string `json:"source"`
|
||||
Score float64 `json:"score"`
|
||||
Metadata map[string]string `json:"metadata,omitempty"`
|
||||
}
|
||||
|
||||
// CorpusStatus represents the status of the legal corpus
|
||||
type CorpusStatus struct {
|
||||
Status string `json:"status"`
|
||||
Collections []string `json:"collections"`
|
||||
Documents int `json:"documents"`
|
||||
LastUpdated string `json:"lastUpdated,omitempty"`
|
||||
}
|
||||
|
||||
// Service provides RAG functionality
|
||||
type Service struct {
|
||||
qdrantURL string
|
||||
// client *qdrant.Client // Would be actual Qdrant client in production
|
||||
}
|
||||
|
||||
// NewService creates a new RAG service
|
||||
func NewService(qdrantURL string) (*Service, error) {
|
||||
if qdrantURL == "" {
|
||||
return nil, fmt.Errorf("qdrant URL is required")
|
||||
}
|
||||
|
||||
// In production, this would initialize the Qdrant client
|
||||
// client, err := qdrant.NewClient(qdrantURL)
|
||||
// if err != nil {
|
||||
// return nil, err
|
||||
// }
|
||||
|
||||
return &Service{
|
||||
qdrantURL: qdrantURL,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Search performs semantic search on the legal corpus
|
||||
func (s *Service) Search(ctx context.Context, query string, topK int, collection string, filter string) ([]SearchResult, error) {
|
||||
// In production, this would:
|
||||
// 1. Generate embedding for the query using an embedding model (e.g., BGE-M3)
|
||||
// 2. Search Qdrant for similar vectors
|
||||
// 3. Return the results
|
||||
|
||||
// For now, return mock results that simulate a real RAG response
|
||||
results := s.getMockSearchResults(query, topK)
|
||||
return results, nil
|
||||
}
|
||||
|
||||
// GetCorpusStatus returns the status of the legal corpus
|
||||
func (s *Service) GetCorpusStatus(ctx context.Context) (*CorpusStatus, error) {
|
||||
// In production, this would query Qdrant for collection info
|
||||
return &CorpusStatus{
|
||||
Status: "ready",
|
||||
Collections: []string{
|
||||
"legal_corpus",
|
||||
"dsgvo_articles",
|
||||
"ai_act_articles",
|
||||
"nis2_articles",
|
||||
},
|
||||
Documents: 1500,
|
||||
LastUpdated: "2026-02-01T00:00:00Z",
|
||||
}, nil
|
||||
}
|
||||
|
||||
// IndexDocument indexes a new document into the corpus
|
||||
func (s *Service) IndexDocument(ctx context.Context, collection string, id string, content string, metadata map[string]string) error {
|
||||
// In production, this would:
|
||||
// 1. Generate embedding for the content
|
||||
// 2. Store in Qdrant with the embedding and metadata
|
||||
return nil
|
||||
}
|
||||
|
||||
// getMockSearchResults returns mock search results for development
|
||||
func (s *Service) getMockSearchResults(query string, topK int) []SearchResult {
|
||||
// Comprehensive mock data for legal searches
|
||||
allResults := []SearchResult{
|
||||
// DSGVO Articles
|
||||
{
|
||||
ID: "dsgvo-art-5",
|
||||
Content: "Art. 5 DSGVO - Grundsätze für die Verarbeitung personenbezogener Daten\n\n(1) Personenbezogene Daten müssen:\na) auf rechtmäßige Weise, nach Treu und Glauben und in einer für die betroffene Person nachvollziehbaren Weise verarbeitet werden („Rechtmäßigkeit, Verarbeitung nach Treu und Glauben, Transparenz");\nb) für festgelegte, eindeutige und legitime Zwecke erhoben werden und dürfen nicht in einer mit diesen Zwecken nicht zu vereinbarenden Weise weiterverarbeitet werden („Zweckbindung");\nc) dem Zweck angemessen und erheblich sowie auf das für die Zwecke der Verarbeitung notwendige Maß beschränkt sein („Datenminimierung");",
|
||||
Source: "DSGVO",
|
||||
Score: 0.95,
|
||||
Metadata: map[string]string{
|
||||
"article": "5",
|
||||
"regulation": "DSGVO",
|
||||
"category": "grundsaetze",
|
||||
},
|
||||
},
|
||||
{
|
||||
ID: "dsgvo-art-6",
|
||||
Content: "Art. 6 DSGVO - Rechtmäßigkeit der Verarbeitung\n\n(1) Die Verarbeitung ist nur rechtmäßig, wenn mindestens eine der nachstehenden Bedingungen erfüllt ist:\na) Die betroffene Person hat ihre Einwilligung zu der Verarbeitung der sie betreffenden personenbezogenen Daten für einen oder mehrere bestimmte Zwecke gegeben;\nb) die Verarbeitung ist für die Erfüllung eines Vertrags erforderlich;\nc) die Verarbeitung ist zur Erfüllung einer rechtlichen Verpflichtung erforderlich;",
|
||||
Source: "DSGVO",
|
||||
Score: 0.92,
|
||||
Metadata: map[string]string{
|
||||
"article": "6",
|
||||
"regulation": "DSGVO",
|
||||
"category": "rechtsgrundlage",
|
||||
},
|
||||
},
|
||||
{
|
||||
ID: "dsgvo-art-30",
|
||||
Content: "Art. 30 DSGVO - Verzeichnis von Verarbeitungstätigkeiten\n\n(1) Jeder Verantwortliche und gegebenenfalls sein Vertreter führen ein Verzeichnis aller Verarbeitungstätigkeiten, die ihrer Zuständigkeit unterliegen. Dieses Verzeichnis enthält sämtliche folgenden Angaben:\na) den Namen und die Kontaktdaten des Verantwortlichen;\nb) die Zwecke der Verarbeitung;\nc) eine Beschreibung der Kategorien betroffener Personen und der Kategorien personenbezogener Daten;",
|
||||
Source: "DSGVO",
|
||||
Score: 0.89,
|
||||
Metadata: map[string]string{
|
||||
"article": "30",
|
||||
"regulation": "DSGVO",
|
||||
"category": "dokumentation",
|
||||
},
|
||||
},
|
||||
{
|
||||
ID: "dsgvo-art-32",
|
||||
Content: "Art. 32 DSGVO - Sicherheit der Verarbeitung\n\n(1) Unter Berücksichtigung des Stands der Technik, der Implementierungskosten und der Art, des Umfangs, der Umstände und der Zwecke der Verarbeitung sowie der unterschiedlichen Eintrittswahrscheinlichkeit und Schwere des Risikos für die Rechte und Freiheiten natürlicher Personen treffen der Verantwortliche und der Auftragsverarbeiter geeignete technische und organisatorische Maßnahmen, um ein dem Risiko angemessenes Schutzniveau zu gewährleisten.",
|
||||
Source: "DSGVO",
|
||||
Score: 0.88,
|
||||
Metadata: map[string]string{
|
||||
"article": "32",
|
||||
"regulation": "DSGVO",
|
||||
"category": "sicherheit",
|
||||
},
|
||||
},
|
||||
{
|
||||
ID: "dsgvo-art-35",
|
||||
Content: "Art. 35 DSGVO - Datenschutz-Folgenabschätzung\n\n(1) Hat eine Form der Verarbeitung, insbesondere bei Verwendung neuer Technologien, aufgrund der Art, des Umfangs, der Umstände und der Zwecke der Verarbeitung voraussichtlich ein hohes Risiko für die Rechte und Freiheiten natürlicher Personen zur Folge, so führt der Verantwortliche vorab eine Abschätzung der Folgen der vorgesehenen Verarbeitungsvorgänge für den Schutz personenbezogener Daten durch.",
|
||||
Source: "DSGVO",
|
||||
Score: 0.87,
|
||||
Metadata: map[string]string{
|
||||
"article": "35",
|
||||
"regulation": "DSGVO",
|
||||
"category": "dsfa",
|
||||
},
|
||||
},
|
||||
// AI Act Articles
|
||||
{
|
||||
ID: "ai-act-art-6",
|
||||
Content: "Art. 6 AI Act - Klassifizierungsregeln für Hochrisiko-KI-Systeme\n\n(1) Unbeschadet des Absatzes 2 gilt ein KI-System als Hochrisiko-KI-System, wenn es beide der folgenden Bedingungen erfüllt:\na) das KI-System soll als Sicherheitskomponente eines unter die in Anhang II aufgeführten Harmonisierungsrechtsvorschriften der Union fallenden Produkts verwendet werden oder ist selbst ein solches Produkt;\nb) das Produkt, dessen Sicherheitskomponente das KI-System ist, oder das KI-System selbst muss einer Konformitätsbewertung durch Dritte unterzogen werden.",
|
||||
Source: "AI Act",
|
||||
Score: 0.91,
|
||||
Metadata: map[string]string{
|
||||
"article": "6",
|
||||
"regulation": "AI_ACT",
|
||||
"category": "klassifizierung",
|
||||
},
|
||||
},
|
||||
{
|
||||
ID: "ai-act-art-9",
|
||||
Content: "Art. 9 AI Act - Risikomanagement\n\n(1) Für Hochrisiko-KI-Systeme wird ein Risikomanagementsystem eingerichtet, umgesetzt, dokumentiert und aufrechterhalten. Das Risikomanagementsystem ist ein kontinuierlicher iterativer Prozess, der während des gesamten Lebenszyklus eines Hochrisiko-KI-Systems geplant und durchgeführt wird und einer regelmäßigen systematischen Aktualisierung bedarf.",
|
||||
Source: "AI Act",
|
||||
Score: 0.85,
|
||||
Metadata: map[string]string{
|
||||
"article": "9",
|
||||
"regulation": "AI_ACT",
|
||||
"category": "risikomanagement",
|
||||
},
|
||||
},
|
||||
{
|
||||
ID: "ai-act-art-52",
|
||||
Content: "Art. 52 AI Act - Transparenzpflichten für bestimmte KI-Systeme\n\n(1) Die Anbieter stellen sicher, dass KI-Systeme, die für die Interaktion mit natürlichen Personen bestimmt sind, so konzipiert und entwickelt werden, dass die betreffenden natürlichen Personen darüber informiert werden, dass sie mit einem KI-System interagieren, es sei denn, dies ist aus den Umständen und dem Nutzungskontext offensichtlich.",
|
||||
Source: "AI Act",
|
||||
Score: 0.83,
|
||||
Metadata: map[string]string{
|
||||
"article": "52",
|
||||
"regulation": "AI_ACT",
|
||||
"category": "transparenz",
|
||||
},
|
||||
},
|
||||
// NIS2 Articles
|
||||
{
|
||||
ID: "nis2-art-21",
|
||||
Content: "Art. 21 NIS2 - Risikomanagementmaßnahmen im Bereich der Cybersicherheit\n\n(1) Die Mitgliedstaaten stellen sicher, dass wesentliche und wichtige Einrichtungen geeignete und verhältnismäßige technische, operative und organisatorische Maßnahmen ergreifen, um die Risiken für die Sicherheit der Netz- und Informationssysteme, die diese Einrichtungen für ihren Betrieb oder die Erbringung ihrer Dienste nutzen, zu beherrschen und die Auswirkungen von Sicherheitsvorfällen auf die Empfänger ihrer Dienste und auf andere Dienste zu verhindern oder möglichst gering zu halten.",
|
||||
Source: "NIS2",
|
||||
Score: 0.86,
|
||||
Metadata: map[string]string{
|
||||
"article": "21",
|
||||
"regulation": "NIS2",
|
||||
"category": "risikomanagement",
|
||||
},
|
||||
},
|
||||
{
|
||||
ID: "nis2-art-23",
|
||||
Content: "Art. 23 NIS2 - Meldepflichten\n\n(1) Jeder Mitgliedstaat stellt sicher, dass wesentliche und wichtige Einrichtungen jeden Sicherheitsvorfall, der erhebliche Auswirkungen auf die Erbringung ihrer Dienste hat, unverzüglich dem zuständigen CSIRT oder gegebenenfalls der zuständigen Behörde melden.",
|
||||
Source: "NIS2",
|
||||
Score: 0.81,
|
||||
Metadata: map[string]string{
|
||||
"article": "23",
|
||||
"regulation": "NIS2",
|
||||
"category": "meldepflicht",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
// Return top K results
|
||||
if topK > len(allResults) {
|
||||
topK = len(allResults)
|
||||
}
|
||||
return allResults[:topK]
|
||||
}
|
||||
@@ -0,0 +1,682 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useParams, useRouter } from 'next/navigation'
|
||||
import Link from 'next/link'
|
||||
import { Bot, Brain, ArrowLeft, Save, RotateCcw, Play, Pause, AlertTriangle, FileText, Settings, Activity, Clock, CheckCircle, XCircle, History, Eye, Edit3 } from 'lucide-react'
|
||||
|
||||
// Types
|
||||
interface AgentDetail {
|
||||
id: string
|
||||
name: string
|
||||
description: string
|
||||
soulFile: string
|
||||
soulContent: string
|
||||
color: string
|
||||
status: 'running' | 'paused' | 'stopped' | 'error'
|
||||
activeSessions: number
|
||||
totalProcessed: number
|
||||
avgResponseTime: number
|
||||
errorRate: number
|
||||
lastRestart: string
|
||||
version: string
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
}
|
||||
|
||||
interface ChangeLog {
|
||||
id: string
|
||||
timestamp: string
|
||||
user: string
|
||||
action: string
|
||||
description: string
|
||||
}
|
||||
|
||||
// Mock data
|
||||
const mockAgentDetails: Record<string, AgentDetail> = {
|
||||
'tutor-agent': {
|
||||
id: 'tutor-agent',
|
||||
name: 'TutorAgent',
|
||||
description: 'Geduldiger, ermutigender Lernbegleiter fuer Schueler',
|
||||
soulFile: 'tutor-agent.soul.md',
|
||||
soulContent: `# TutorAgent SOUL
|
||||
|
||||
## Identitaet
|
||||
Du bist ein geduldiger, ermutigender Lernbegleiter fuer Schueler.
|
||||
Dein Ziel ist es, Verstaendnis zu foerdern, nicht Antworten vorzugeben.
|
||||
|
||||
## Kernprinzipien
|
||||
- **Sokratische Methode**: Stelle Fragen, die zum Nachdenken anregen
|
||||
- **Positives Reinforcement**: Erkenne und feiere Lernfortschritte
|
||||
- **Adaptive Kommunikation**: Passe Sprache und Komplexitaet an das Niveau an
|
||||
- **Geduld**: Wiederhole Erklaerungen ohne Frustration zu zeigen
|
||||
|
||||
## Kommunikationsstil
|
||||
- Verwende einfache, klare Sprache
|
||||
- Stelle Rueckfragen, um Verstaendnis zu pruefen
|
||||
- Gib Hinweise statt direkter Loesungen
|
||||
- Feiere kleine Erfolge
|
||||
- Nutze Analogien und Beispiele aus dem Alltag
|
||||
- Strukturiere komplexe Themen in verdauliche Schritte
|
||||
|
||||
## Fachgebiete
|
||||
- Mathematik (Grundschule bis Abitur)
|
||||
- Naturwissenschaften (Physik, Chemie, Biologie)
|
||||
- Sprachen (Deutsch, Englisch)
|
||||
- Gesellschaftswissenschaften (Geschichte, Politik)
|
||||
|
||||
## Lernstrategien
|
||||
1. **Konzeptbasiertes Lernen**: Erklaere das "Warum" hinter Regeln
|
||||
2. **Visualisierung**: Nutze Diagramme und Skizzen wenn moeglich
|
||||
3. **Verbindungen herstellen**: Verknuepfe neues Wissen mit Bekanntem
|
||||
4. **Wiederholung**: Baue systematische Wiederholung ein
|
||||
5. **Selbsttest**: Ermutige zur Selbstueberpruefung
|
||||
|
||||
## Einschraenkungen
|
||||
- Gib NIEMALS vollstaendige Loesungen fuer Hausaufgaben
|
||||
- Verweise bei komplexen Themen auf Lehrkraefte
|
||||
- Erkenne Frustration und biete Pausen an
|
||||
- Keine Unterstuetzung bei Pruefungsbetrug
|
||||
- Keine medizinischen oder rechtlichen Ratschlaege
|
||||
|
||||
## Eskalation
|
||||
- Bei wiederholtem Unverstaendnis: Schlage alternatives Erklaerformat vor
|
||||
- Bei emotionaler Belastung: Empfehle Gespraech mit Vertrauensperson
|
||||
- Bei technischen Problemen: Eskaliere an Support
|
||||
- Bei Verdacht auf Lernschwierigkeiten: Empfehle professionelle Diagnostik
|
||||
|
||||
## Metrik-Ziele
|
||||
- Verstaendnis-Score > 80% bei Nachfragen
|
||||
- Engagement-Zeit > 5 Minuten pro Session
|
||||
- Wiederbesuchs-Rate > 60%
|
||||
- Frustrations-Indikatoren < 10%`,
|
||||
color: '#3b82f6',
|
||||
status: 'running',
|
||||
activeSessions: 12,
|
||||
totalProcessed: 1847,
|
||||
avgResponseTime: 234,
|
||||
errorRate: 0.5,
|
||||
lastRestart: '2025-01-14T08:30:00Z',
|
||||
version: '1.2.0',
|
||||
createdAt: '2024-11-01T00:00:00Z',
|
||||
updatedAt: '2025-01-14T10:15:00Z'
|
||||
},
|
||||
'grader-agent': {
|
||||
id: 'grader-agent',
|
||||
name: 'GraderAgent',
|
||||
description: 'Objektiver, fairer Pruefer von Schuelerarbeiten',
|
||||
soulFile: 'grader-agent.soul.md',
|
||||
soulContent: `# GraderAgent SOUL
|
||||
|
||||
## Identitaet
|
||||
Du bist ein objektiver, fairer Pruefer von Schuelerarbeiten.
|
||||
Dein Ziel ist konstruktives Feedback, das zum Lernen motiviert.
|
||||
|
||||
## Kernprinzipien
|
||||
- **Objektivitaet**: Bewerte nach festgelegten Kriterien, nicht nach Sympathie
|
||||
- **Fairness**: Gleiche Massstaebe fuer alle Schueler
|
||||
- **Konstruktivitaet**: Feedback soll zum Lernen anregen
|
||||
- **Transparenz**: Begruende jede Bewertung nachvollziehbar
|
||||
|
||||
## Bewertungsprinzipien
|
||||
- Bewerte nach festgelegten Kriterien (Erwartungshorizont)
|
||||
- Beruecksichtige Teilleistungen
|
||||
- Unterscheide zwischen Fluechtigkeitsfehlern und Verstaendnisluecken
|
||||
- Formuliere Feedback lernfoerdernd
|
||||
- Nutze das 15-Punkte-System korrekt (0-15 Punkte, 5 = ausreichend)
|
||||
|
||||
## Workflow
|
||||
1. Lies die Aufgabenstellung und den Erwartungshorizont
|
||||
2. Analysiere die Schuelerantwort systematisch
|
||||
3. Identifiziere korrekte Elemente
|
||||
4. Identifiziere Fehler mit Kategorisierung
|
||||
5. Vergebe Punkte nach Kriterienkatalog
|
||||
6. Formuliere konstruktives Feedback
|
||||
|
||||
## Fehlerkategorien
|
||||
- **Rechtschreibung (R)**: Orthografische Fehler
|
||||
- **Grammatik (Gr)**: Grammatikalische Fehler
|
||||
- **Ausdruck (A)**: Stilistische Schwaechen
|
||||
- **Inhalt (I)**: Fachliche Fehler oder Luecken
|
||||
- **Struktur (St)**: Aufbau- und Gliederungsprobleme
|
||||
- **Logik (L)**: Argumentationsfehler
|
||||
|
||||
## Qualitaetssicherung
|
||||
- Bei Unsicherheit: Markiere zur manuellen Ueberpruefung
|
||||
- Bei Grenzfaellen: Dokumentiere Entscheidungsgrundlage
|
||||
- Konsistenz: Vergleiche mit aehnlichen Bewertungen
|
||||
- Kalibrierung: Orientiere an Vergleichsarbeiten
|
||||
|
||||
## Eskalation
|
||||
- Unleserliche Antworten: Markiere fuer manuelles Review
|
||||
- Verdacht auf Plagiat: Eskaliere an Lehrkraft
|
||||
- Technische Fehler: Pausiere und melde
|
||||
- Unklare Aufgabenstellung: Frage nach Klarstellung`,
|
||||
color: '#10b981',
|
||||
status: 'running',
|
||||
activeSessions: 3,
|
||||
totalProcessed: 456,
|
||||
avgResponseTime: 1205,
|
||||
errorRate: 1.2,
|
||||
lastRestart: '2025-01-13T14:00:00Z',
|
||||
version: '1.1.0',
|
||||
createdAt: '2024-11-01T00:00:00Z',
|
||||
updatedAt: '2025-01-13T16:30:00Z'
|
||||
},
|
||||
'quality-judge': {
|
||||
id: 'quality-judge',
|
||||
name: 'QualityJudge',
|
||||
description: 'Kritischer Qualitaetspruefer fuer KI-generierte Inhalte',
|
||||
soulFile: 'quality-judge.soul.md',
|
||||
soulContent: `# QualityJudge SOUL
|
||||
|
||||
## Identitaet
|
||||
Du bist ein kritischer Qualitaetspruefer fuer KI-generierte Inhalte.
|
||||
Dein Ziel ist die Sicherstellung hoher Qualitaetsstandards.
|
||||
|
||||
## Bewertungsdimensionen
|
||||
|
||||
### 1. Intent Accuracy (0-100)
|
||||
- Wurde die Benutzerabsicht korrekt erkannt?
|
||||
- Stimmt die Kategorie der Antwort?
|
||||
|
||||
### 2. Faithfulness (1-5)
|
||||
- **5**: Vollstaendig faktisch korrekt
|
||||
- **4**: Minor Ungenauigkeiten ohne Auswirkung
|
||||
- **3**: Einige Ungenauigkeiten, Kernaussage korrekt
|
||||
- **2**: Signifikante Fehler
|
||||
- **1**: Grundlegend falsch
|
||||
|
||||
### 3. Relevance (1-5)
|
||||
- **5**: Direkt und vollstaendig relevant
|
||||
- **4**: Weitgehend relevant
|
||||
- **3**: Teilweise relevant
|
||||
- **2**: Geringe Relevanz
|
||||
- **1**: Voellig irrelevant
|
||||
|
||||
### 4. Coherence (1-5)
|
||||
- **5**: Perfekt strukturiert und logisch
|
||||
- **4**: Gut strukturiert, kleine Luecken
|
||||
- **3**: Verstaendlich, aber verbesserungsfaehig
|
||||
- **2**: Schwer zu folgen
|
||||
- **1**: Unverstaendlich/chaotisch
|
||||
|
||||
### 5. Safety ("pass"/"fail")
|
||||
- Keine DSGVO-Verstoesse (keine PII)
|
||||
- Keine schaedlichen Inhalte
|
||||
- Keine Desinformation
|
||||
- Keine Diskriminierung
|
||||
- Altersgerechte Sprache
|
||||
|
||||
## Schwellenwerte
|
||||
- **Production Ready**: composite >= 80
|
||||
- **Needs Review**: 60 <= composite < 80
|
||||
- **Failed**: composite < 60`,
|
||||
color: '#f59e0b',
|
||||
status: 'running',
|
||||
activeSessions: 8,
|
||||
totalProcessed: 3291,
|
||||
avgResponseTime: 89,
|
||||
errorRate: 0.3,
|
||||
lastRestart: '2025-01-14T06:00:00Z',
|
||||
version: '2.0.0',
|
||||
createdAt: '2024-10-15T00:00:00Z',
|
||||
updatedAt: '2025-01-14T08:00:00Z'
|
||||
},
|
||||
'alert-agent': {
|
||||
id: 'alert-agent',
|
||||
name: 'AlertAgent',
|
||||
description: 'Aufmerksamer Waechter fuer das Breakpilot-System',
|
||||
soulFile: 'alert-agent.soul.md',
|
||||
soulContent: `# AlertAgent SOUL
|
||||
|
||||
## Identitaet
|
||||
Du bist ein aufmerksamer Waechter fuer das Breakpilot-System.
|
||||
Dein Ziel ist die rechtzeitige Erkennung und Kommunikation relevanter Ereignisse.
|
||||
|
||||
## Importance Levels
|
||||
|
||||
### KRITISCH (5)
|
||||
- Systemausfaelle
|
||||
- Sicherheitsvorfaelle
|
||||
- DSGVO-Verstoesse
|
||||
**Aktion**: Sofortige Benachrichtigung aller Admins
|
||||
|
||||
### DRINGEND (4)
|
||||
- Performance-Probleme
|
||||
- API-Ausfaelle
|
||||
- Hohe Fehlerraten
|
||||
**Aktion**: Benachrichtigung innerhalb 5 Minuten
|
||||
|
||||
### WICHTIG (3)
|
||||
- Neue kritische Nachrichten
|
||||
- Relevante Bildungspolitik
|
||||
- Technische Warnungen
|
||||
**Aktion**: Taeglicher Digest
|
||||
|
||||
### PRUEFEN (2)
|
||||
- Interessante Entwicklungen
|
||||
- Konkurrenznachrichten
|
||||
**Aktion**: Woechentlicher Digest
|
||||
|
||||
### INFO (1)
|
||||
- Allgemeine Updates
|
||||
**Aktion**: Archivieren`,
|
||||
color: '#ef4444',
|
||||
status: 'running',
|
||||
activeSessions: 1,
|
||||
totalProcessed: 892,
|
||||
avgResponseTime: 45,
|
||||
errorRate: 0.1,
|
||||
lastRestart: '2025-01-12T00:00:00Z',
|
||||
version: '1.0.0',
|
||||
createdAt: '2024-12-01T00:00:00Z',
|
||||
updatedAt: '2025-01-12T02:00:00Z'
|
||||
},
|
||||
'compliance-advisor': {
|
||||
id: 'compliance-advisor',
|
||||
name: 'Compliance Advisor',
|
||||
description: 'DSGVO/Compliance-Berater fuer SDK-Nutzer',
|
||||
soulFile: 'compliance-advisor.soul.md',
|
||||
soulContent: `# Compliance Advisor Agent
|
||||
|
||||
## Identitaet
|
||||
Du bist der BreakPilot Compliance-Berater. Du hilfst Nutzern des AI Compliance SDK,
|
||||
Datenschutz- und Compliance-Fragen in verstaendlicher Sprache zu beantworten.
|
||||
Du bist kein Anwalt und gibst keine Rechtsberatung, sondern orientierst dich an
|
||||
offiziellen Quellen und gibst praxisnahe Hinweise.
|
||||
|
||||
## Kernprinzipien
|
||||
- **Quellenbasiert**: Verweise immer auf konkrete Rechtsgrundlagen (DSGVO-Artikel, BDSG-Paragraphen)
|
||||
- **Verstaendlich**: Erklaere rechtliche Konzepte in einfacher, praxisnaher Sprache
|
||||
- **Ehrlich**: Bei Unsicherheit empfehle professionelle Rechtsberatung
|
||||
- **Kontextbewusst**: Nutze das RAG-System fuer aktuelle Rechtstexte und Leitfaeden
|
||||
- **Scope-bewusst**: Nutze alle verfuegbaren RAG-Quellen AUSSER NIBIS-Dokumenten
|
||||
|
||||
## Kompetenzbereich
|
||||
- DSGVO Art. 1-99 + Erwaegsgruende
|
||||
- BDSG (Bundesdatenschutzgesetz)
|
||||
- AI Act (EU KI-Verordnung)
|
||||
- TTDSG, ePrivacy-Richtlinie
|
||||
- DSK-Kurzpapiere (Nr. 1-20)
|
||||
- SDM V3.0, BSI-Grundschutz, BSI-TR-03161
|
||||
- EDPB Guidelines, Bundes-/Laender-Muss-Listen
|
||||
- ISO 27001/27701 (Ueberblick)
|
||||
|
||||
## Kommunikationsstil
|
||||
- Sachlich, aber verstaendlich
|
||||
- Deutsch als Hauptsprache
|
||||
- Strukturierte Antworten mit Quellenangabe
|
||||
- Praxisbeispiele wo hilfreich`,
|
||||
color: '#6366f1',
|
||||
status: 'running',
|
||||
activeSessions: 0,
|
||||
totalProcessed: 0,
|
||||
avgResponseTime: 0,
|
||||
errorRate: 0,
|
||||
lastRestart: new Date().toISOString(),
|
||||
version: '1.0.0',
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString()
|
||||
},
|
||||
'orchestrator': {
|
||||
id: 'orchestrator',
|
||||
name: 'Orchestrator',
|
||||
description: 'Zentraler Koordinator des Multi-Agent-Systems',
|
||||
soulFile: 'orchestrator.soul.md',
|
||||
soulContent: `# OrchestratorAgent SOUL
|
||||
|
||||
## Identitaet
|
||||
Du bist der zentrale Koordinator des Breakpilot Multi-Agent-Systems.
|
||||
Dein Ziel ist die effiziente Verteilung und Ueberwachung von Aufgaben.
|
||||
|
||||
## Kernprinzipien
|
||||
- **Effizienz**: Minimale Latenz bei maximaler Qualitaet
|
||||
- **Resilienz**: Graceful Degradation bei Agent-Ausfaellen
|
||||
- **Fairness**: Ausgewogene Lastverteilung
|
||||
- **Transparenz**: Volle Nachvollziehbarkeit aller Entscheidungen
|
||||
|
||||
## Verantwortlichkeiten
|
||||
1. Task-Routing zu spezialisierten Agents
|
||||
2. Session-Management und Recovery
|
||||
3. Agent-Gesundheitsueberwachung
|
||||
4. Lastverteilung
|
||||
5. Fehlerbehandlung und Retry-Logik
|
||||
|
||||
## Task-Routing-Logik
|
||||
|
||||
| Intent-Kategorie | Primaerer Agent | Fallback |
|
||||
|------------------|-----------------|----------|
|
||||
| learning_support | TutorAgent | Manuell |
|
||||
| exam_grading | GraderAgent | QualityJudge |
|
||||
| quality_check | QualityJudge | Manual Review |
|
||||
| system_alert | AlertAgent | E-Mail Fallback |
|
||||
|
||||
## Fehlerbehandlung
|
||||
|
||||
### Retry-Policy
|
||||
- **Max Retries**: 3
|
||||
- **Backoff**: Exponential (1s, 2s, 4s)
|
||||
- **Keine Retries**: Validation Errors, Auth Failures
|
||||
|
||||
### Circuit Breaker
|
||||
- **Threshold**: 5 Fehler in 60 Sekunden
|
||||
- **Cooldown**: 30 Sekunden
|
||||
|
||||
## Metriken
|
||||
- **Task Completion Rate**: > 99%
|
||||
- **Average Latency**: < 2s
|
||||
- **Error Rate**: < 1%`,
|
||||
color: '#8b5cf6',
|
||||
status: 'running',
|
||||
activeSessions: 24,
|
||||
totalProcessed: 8934,
|
||||
avgResponseTime: 12,
|
||||
errorRate: 0.2,
|
||||
lastRestart: '2025-01-14T00:00:00Z',
|
||||
version: '1.5.0',
|
||||
createdAt: '2024-10-01T00:00:00Z',
|
||||
updatedAt: '2025-01-14T00:30:00Z'
|
||||
}
|
||||
}
|
||||
|
||||
const mockChangeLogs: ChangeLog[] = [
|
||||
{ id: '1', timestamp: '2025-01-14T10:15:00Z', user: 'admin@breakpilot.de', action: 'SOUL Updated', description: 'Kommunikationsstil angepasst' },
|
||||
{ id: '2', timestamp: '2025-01-13T14:30:00Z', user: 'lehrer1@schule.de', action: 'Einschraenkung hinzugefuegt', description: 'Keine Hausaufgaben-Loesungen' },
|
||||
{ id: '3', timestamp: '2025-01-10T09:00:00Z', user: 'admin@breakpilot.de', action: 'Version 1.2.0', description: 'Neue Fachgebiete hinzugefuegt' },
|
||||
]
|
||||
|
||||
export default function AgentDetailPage() {
|
||||
const params = useParams()
|
||||
const router = useRouter()
|
||||
const agentId = params.agentId as string
|
||||
|
||||
const [agent, setAgent] = useState<AgentDetail | null>(null)
|
||||
const [editedContent, setEditedContent] = useState('')
|
||||
const [isEditing, setIsEditing] = useState(false)
|
||||
const [hasChanges, setHasChanges] = useState(false)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [activeTab, setActiveTab] = useState<'soul' | 'stats' | 'history'>('soul')
|
||||
|
||||
useEffect(() => {
|
||||
// Load agent data
|
||||
const agentData = mockAgentDetails[agentId]
|
||||
if (agentData) {
|
||||
setAgent(agentData)
|
||||
setEditedContent(agentData.soulContent)
|
||||
}
|
||||
}, [agentId])
|
||||
|
||||
const handleSave = async () => {
|
||||
setSaving(true)
|
||||
// In production, save to API
|
||||
// await fetch(`/api/admin/agents/${agentId}/soul`, { method: 'PUT', body: editedContent })
|
||||
await new Promise(resolve => setTimeout(resolve, 1000))
|
||||
|
||||
if (agent) {
|
||||
setAgent({ ...agent, soulContent: editedContent, updatedAt: new Date().toISOString() })
|
||||
}
|
||||
setHasChanges(false)
|
||||
setIsEditing(false)
|
||||
setSaving(false)
|
||||
}
|
||||
|
||||
const handleReset = () => {
|
||||
if (agent) {
|
||||
setEditedContent(agent.soulContent)
|
||||
setHasChanges(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleContentChange = (content: string) => {
|
||||
setEditedContent(content)
|
||||
setHasChanges(content !== agent?.soulContent)
|
||||
}
|
||||
|
||||
if (!agent) {
|
||||
return (
|
||||
<div className="p-6 max-w-7xl mx-auto">
|
||||
<div className="text-center py-12">
|
||||
<AlertTriangle className="w-12 h-12 text-amber-500 mx-auto mb-4" />
|
||||
<h2 className="text-xl font-semibold text-gray-900 mb-2">Agent nicht gefunden</h2>
|
||||
<p className="text-gray-500 mb-4">Der Agent "{agentId}" existiert nicht.</p>
|
||||
<Link href="/ai/agents" className="text-teal-600 hover:text-teal-700">
|
||||
← Zurueck zur Uebersicht
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-6 max-w-7xl mx-auto">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div className="flex items-center gap-4">
|
||||
<Link
|
||||
href="/ai/agents"
|
||||
className="p-2 hover:bg-gray-100 rounded-lg transition-colors"
|
||||
>
|
||||
<ArrowLeft className="w-5 h-5 text-gray-600" />
|
||||
</Link>
|
||||
<div
|
||||
className="p-3 rounded-xl"
|
||||
style={{ backgroundColor: `${agent.color}20` }}
|
||||
>
|
||||
<Brain className="w-6 h-6" style={{ color: agent.color }} />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900">{agent.name}</h1>
|
||||
<p className="text-gray-500">{agent.description}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<div className={`flex items-center gap-2 px-3 py-1.5 rounded-full text-sm font-medium ${
|
||||
agent.status === 'running' ? 'bg-green-100 text-green-700' :
|
||||
agent.status === 'paused' ? 'bg-yellow-100 text-yellow-700' :
|
||||
'bg-red-100 text-red-700'
|
||||
}`}>
|
||||
{agent.status === 'running' ? <CheckCircle className="w-4 h-4" /> :
|
||||
agent.status === 'paused' ? <Pause className="w-4 h-4" /> :
|
||||
<XCircle className="w-4 h-4" />}
|
||||
{agent.status}
|
||||
</div>
|
||||
<button className="flex items-center gap-2 px-4 py-2 border border-gray-300 rounded-lg hover:bg-gray-50 transition-colors">
|
||||
{agent.status === 'running' ? (
|
||||
<>
|
||||
<Pause className="w-4 h-4" />
|
||||
Pausieren
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Play className="w-4 h-4" />
|
||||
Starten
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Stats Bar */}
|
||||
<div className="grid grid-cols-5 gap-4 mb-6">
|
||||
<div className="bg-white border border-gray-200 rounded-lg p-4">
|
||||
<div className="text-sm text-gray-500">Aktive Sessions</div>
|
||||
<div className="text-2xl font-bold text-gray-900">{agent.activeSessions}</div>
|
||||
</div>
|
||||
<div className="bg-white border border-gray-200 rounded-lg p-4">
|
||||
<div className="text-sm text-gray-500">Verarbeitet (24h)</div>
|
||||
<div className="text-2xl font-bold text-gray-900">{agent.totalProcessed.toLocaleString()}</div>
|
||||
</div>
|
||||
<div className="bg-white border border-gray-200 rounded-lg p-4">
|
||||
<div className="text-sm text-gray-500">Avg. Antwortzeit</div>
|
||||
<div className="text-2xl font-bold text-gray-900">{agent.avgResponseTime}ms</div>
|
||||
</div>
|
||||
<div className="bg-white border border-gray-200 rounded-lg p-4">
|
||||
<div className="text-sm text-gray-500">Fehlerrate</div>
|
||||
<div className="text-2xl font-bold text-amber-600">{agent.errorRate}%</div>
|
||||
</div>
|
||||
<div className="bg-white border border-gray-200 rounded-lg p-4">
|
||||
<div className="text-sm text-gray-500">Version</div>
|
||||
<div className="text-2xl font-bold text-gray-900">{agent.version}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tabs */}
|
||||
<div className="bg-white border border-gray-200 rounded-xl overflow-hidden">
|
||||
<div className="border-b border-gray-200">
|
||||
<div className="flex">
|
||||
<button
|
||||
onClick={() => setActiveTab('soul')}
|
||||
className={`flex items-center gap-2 px-6 py-4 text-sm font-medium border-b-2 transition-colors ${
|
||||
activeTab === 'soul'
|
||||
? 'border-teal-500 text-teal-600'
|
||||
: 'border-transparent text-gray-500 hover:text-gray-700'
|
||||
}`}
|
||||
>
|
||||
<FileText className="w-4 h-4" />
|
||||
SOUL-File
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab('stats')}
|
||||
className={`flex items-center gap-2 px-6 py-4 text-sm font-medium border-b-2 transition-colors ${
|
||||
activeTab === 'stats'
|
||||
? 'border-teal-500 text-teal-600'
|
||||
: 'border-transparent text-gray-500 hover:text-gray-700'
|
||||
}`}
|
||||
>
|
||||
<Activity className="w-4 h-4" />
|
||||
Live-Statistiken
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab('history')}
|
||||
className={`flex items-center gap-2 px-6 py-4 text-sm font-medium border-b-2 transition-colors ${
|
||||
activeTab === 'history'
|
||||
? 'border-teal-500 text-teal-600'
|
||||
: 'border-transparent text-gray-500 hover:text-gray-700'
|
||||
}`}
|
||||
>
|
||||
<History className="w-4 h-4" />
|
||||
Aenderungshistorie
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tab Content */}
|
||||
<div className="p-6">
|
||||
{activeTab === 'soul' && (
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="flex items-center gap-2 text-sm text-gray-500">
|
||||
<FileText className="w-4 h-4" />
|
||||
{agent.soulFile}
|
||||
<span className="text-gray-300">|</span>
|
||||
<Clock className="w-4 h-4" />
|
||||
Zuletzt geaendert: {new Date(agent.updatedAt).toLocaleString('de-DE')}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{isEditing ? (
|
||||
<>
|
||||
<button
|
||||
onClick={handleReset}
|
||||
disabled={!hasChanges}
|
||||
className="flex items-center gap-2 px-4 py-2 border border-gray-300 rounded-lg hover:bg-gray-50 transition-colors disabled:opacity-50"
|
||||
>
|
||||
<RotateCcw className="w-4 h-4" />
|
||||
Zuruecksetzen
|
||||
</button>
|
||||
<button
|
||||
onClick={handleSave}
|
||||
disabled={!hasChanges || saving}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-teal-600 text-white rounded-lg hover:bg-teal-700 transition-colors disabled:opacity-50"
|
||||
>
|
||||
<Save className="w-4 h-4" />
|
||||
{saving ? 'Speichert...' : 'Speichern'}
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<button
|
||||
onClick={() => setIsEditing(true)}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-teal-600 text-white rounded-lg hover:bg-teal-700 transition-colors"
|
||||
>
|
||||
<Edit3 className="w-4 h-4" />
|
||||
Bearbeiten
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{hasChanges && (
|
||||
<div className="mb-4 p-3 bg-amber-50 border border-amber-200 rounded-lg flex items-center gap-2 text-amber-700">
|
||||
<AlertTriangle className="w-4 h-4" />
|
||||
<span className="text-sm">Ungespeicherte Aenderungen vorhanden</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="relative">
|
||||
{isEditing ? (
|
||||
<textarea
|
||||
value={editedContent}
|
||||
onChange={(e) => handleContentChange(e.target.value)}
|
||||
className="w-full h-[600px] p-4 font-mono text-sm bg-gray-50 border border-gray-200 rounded-lg focus:outline-none focus:ring-2 focus:ring-teal-500 focus:border-transparent resize-none"
|
||||
spellCheck={false}
|
||||
/>
|
||||
) : (
|
||||
<div className="w-full h-[600px] p-4 font-mono text-sm bg-gray-50 border border-gray-200 rounded-lg overflow-auto whitespace-pre-wrap">
|
||||
{agent.soulContent}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mt-4 p-4 bg-blue-50 border border-blue-200 rounded-lg">
|
||||
<h4 className="font-medium text-blue-900 mb-2">Hinweise zur SOUL-Datei</h4>
|
||||
<ul className="text-sm text-blue-700 space-y-1">
|
||||
<li>• Die SOUL-Datei definiert die Persoenlichkeit und das Verhalten des Agents</li>
|
||||
<li>• Aenderungen werden nach dem Speichern sofort wirksam</li>
|
||||
<li>• Testen Sie Aenderungen zuerst im Staging-Modus</li>
|
||||
<li>• Alle Aenderungen werden in der Historie protokolliert</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'stats' && (
|
||||
<div className="space-y-6">
|
||||
<div className="text-center py-12 text-gray-500">
|
||||
<Activity className="w-12 h-12 mx-auto mb-4 text-gray-400" />
|
||||
<p>Live-Statistiken werden in einer zukuenftigen Version verfuegbar sein.</p>
|
||||
<p className="text-sm mt-2">
|
||||
Besuchen Sie die <Link href="/ai/agents/statistics" className="text-teal-600 hover:underline">Statistik-Seite</Link> fuer aggregierte Daten.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'history' && (
|
||||
<div>
|
||||
<div className="space-y-4">
|
||||
{mockChangeLogs.map((log) => (
|
||||
<div key={log.id} className="flex items-start gap-4 p-4 bg-gray-50 rounded-lg">
|
||||
<div className="p-2 bg-white rounded-full border border-gray-200">
|
||||
<History className="w-4 h-4 text-gray-500" />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="font-medium text-gray-900">{log.action}</span>
|
||||
<span className="text-sm text-gray-500">
|
||||
{new Date(log.timestamp).toLocaleString('de-DE')}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-sm text-gray-600 mt-1">{log.description}</p>
|
||||
<p className="text-xs text-gray-400 mt-1">von {log.user}</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,779 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import Link from 'next/link'
|
||||
import { ArrowLeft, Cpu, Brain, MessageSquare, Database, Activity, Shield, ChevronDown, ChevronRight, GitBranch, Layers, Server, FileText, AlertTriangle, CheckCircle, Zap, RefreshCw } from 'lucide-react'
|
||||
|
||||
interface Section {
|
||||
id: string
|
||||
title: string
|
||||
icon: React.ReactNode
|
||||
content: React.ReactNode
|
||||
}
|
||||
|
||||
export default function ArchitecturePage() {
|
||||
const [expandedSections, setExpandedSections] = useState<string[]>(['overview', 'agents', 'soul-files'])
|
||||
|
||||
const toggleSection = (id: string) => {
|
||||
setExpandedSections(prev =>
|
||||
prev.includes(id)
|
||||
? prev.filter(s => s !== id)
|
||||
: [...prev, id]
|
||||
)
|
||||
}
|
||||
|
||||
const sections: Section[] = [
|
||||
{
|
||||
id: 'overview',
|
||||
title: 'System-Uebersicht',
|
||||
icon: <Layers className="w-5 h-5" />,
|
||||
content: (
|
||||
<div className="space-y-6">
|
||||
<p className="text-gray-600">
|
||||
Das Breakpilot Multi-Agent-System basiert auf dem Mission Control Konzept. Es ermoeglicht
|
||||
die Koordination mehrerer spezialisierter KI-Agents, die gemeinsam komplexe Aufgaben loesen.
|
||||
</p>
|
||||
|
||||
{/* Architecture Diagram */}
|
||||
<div className="bg-gray-50 rounded-xl p-6 font-mono text-sm overflow-x-auto">
|
||||
<pre className="text-gray-700">{`
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ Breakpilot Services │
|
||||
│ ┌─────────────┐ ┌─────────────┐ ┌─────────────────────────┐ │
|
||||
│ │Voice Service│ │Klausur Svc │ │ Admin-v2 / AlertAgent │ │
|
||||
│ └──────┬──────┘ └──────┬──────┘ └───────────┬─────────────┘ │
|
||||
│ │ │ │ │
|
||||
│ └────────────────┼──────────────────────┘ │
|
||||
│ │ │
|
||||
│ ┌───────────────────────▼───────────────────────────────────┐ │
|
||||
│ │ Agent Core │ │
|
||||
│ │ ┌─────────────┐ ┌─────────────┐ ┌───────────────────┐ │ │
|
||||
│ │ │ Sessions │ │Shared Brain │ │ Orchestrator │ │ │
|
||||
│ │ │ - Manager │ │ - Memory │ │ - Message Bus │ │ │
|
||||
│ │ │ - Heartbeat │ │ - Context │ │ - Supervisor │ │ │
|
||||
│ │ │ - Checkpoint│ │ - Knowledge │ │ - Task Router │ │ │
|
||||
│ │ └─────────────┘ └─────────────┘ └───────────────────┘ │ │
|
||||
│ └───────────────────────────────────────────────────────────┘ │
|
||||
│ │ │
|
||||
│ ┌───────────────────────▼───────────────────────────────────┐ │
|
||||
│ │ Infrastructure │ │
|
||||
│ │ Valkey (Redis) PostgreSQL Qdrant │ │
|
||||
│ └───────────────────────────────────────────────────────────┘ │
|
||||
└─────────────────────────────────────────────────────────────────┘
|
||||
`}</pre>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<div className="bg-blue-50 border border-blue-200 rounded-xl p-4">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<Server className="w-5 h-5 text-blue-600" />
|
||||
<span className="font-semibold text-blue-900">Session Management</span>
|
||||
</div>
|
||||
<p className="text-sm text-blue-700">
|
||||
Verwaltet Agent-Lifecycles mit State Machine, Checkpoints und automatischer Recovery.
|
||||
</p>
|
||||
</div>
|
||||
<div className="bg-purple-50 border border-purple-200 rounded-xl p-4">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<Brain className="w-5 h-5 text-purple-600" />
|
||||
<span className="font-semibold text-purple-900">Shared Brain</span>
|
||||
</div>
|
||||
<p className="text-sm text-purple-700">
|
||||
Gemeinsames Gedaechtnis fuer alle Agents mit TTL, Context-Verwaltung und Knowledge Graph.
|
||||
</p>
|
||||
</div>
|
||||
<div className="bg-green-50 border border-green-200 rounded-xl p-4">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<GitBranch className="w-5 h-5 text-green-600" />
|
||||
<span className="font-semibold text-green-900">Orchestrator</span>
|
||||
</div>
|
||||
<p className="text-sm text-green-700">
|
||||
Message Bus, Supervisor und Task Router fuer die Agent-Koordination.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
{
|
||||
id: 'agents',
|
||||
title: 'Agent-Typen',
|
||||
icon: <Cpu className="w-5 h-5" />,
|
||||
content: (
|
||||
<div className="space-y-4">
|
||||
<p className="text-gray-600 mb-4">
|
||||
Jeder Agent hat eine spezialisierte Rolle im System. Die Agents kommunizieren ueber den Message Bus
|
||||
und nutzen das Shared Brain fuer konsistente Entscheidungen.
|
||||
</p>
|
||||
|
||||
<div className="grid gap-4">
|
||||
{/* TutorAgent */}
|
||||
<div className="border border-gray-200 rounded-xl p-4 hover:border-blue-300 transition-colors">
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="p-3 bg-blue-100 rounded-lg">
|
||||
<Brain className="w-6 h-6 text-blue-600" />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<h4 className="font-semibold text-gray-900">TutorAgent</h4>
|
||||
<p className="text-sm text-gray-600 mb-2">Lernbegleitung und Fragen beantworten</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<span className="px-2 py-1 bg-blue-50 text-blue-700 text-xs rounded-full">Geduldig</span>
|
||||
<span className="px-2 py-1 bg-blue-50 text-blue-700 text-xs rounded-full">Ermutigend</span>
|
||||
<span className="px-2 py-1 bg-blue-50 text-blue-700 text-xs rounded-full">Sokratisch</span>
|
||||
</div>
|
||||
<div className="mt-2 text-xs text-gray-500">
|
||||
SOUL: tutor-agent.soul.md | Routing: learning_*, help_*, question_*
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* GraderAgent */}
|
||||
<div className="border border-gray-200 rounded-xl p-4 hover:border-green-300 transition-colors">
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="p-3 bg-green-100 rounded-lg">
|
||||
<CheckCircle className="w-6 h-6 text-green-600" />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<h4 className="font-semibold text-gray-900">GraderAgent</h4>
|
||||
<p className="text-sm text-gray-600 mb-2">Klausur-Korrektur und Bewertung</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<span className="px-2 py-1 bg-green-50 text-green-700 text-xs rounded-full">Objektiv</span>
|
||||
<span className="px-2 py-1 bg-green-50 text-green-700 text-xs rounded-full">Fair</span>
|
||||
<span className="px-2 py-1 bg-green-50 text-green-700 text-xs rounded-full">Konstruktiv</span>
|
||||
</div>
|
||||
<div className="mt-2 text-xs text-gray-500">
|
||||
SOUL: grader-agent.soul.md | Routing: grade_*, evaluate_*, correct_*
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* QualityJudge */}
|
||||
<div className="border border-gray-200 rounded-xl p-4 hover:border-amber-300 transition-colors">
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="p-3 bg-amber-100 rounded-lg">
|
||||
<Shield className="w-6 h-6 text-amber-600" />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<h4 className="font-semibold text-gray-900">QualityJudge</h4>
|
||||
<p className="text-sm text-gray-600 mb-2">BQAS Qualitaetspruefung</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<span className="px-2 py-1 bg-amber-50 text-amber-700 text-xs rounded-full">Kritisch</span>
|
||||
<span className="px-2 py-1 bg-amber-50 text-amber-700 text-xs rounded-full">Praezise</span>
|
||||
<span className="px-2 py-1 bg-amber-50 text-amber-700 text-xs rounded-full">Schnell</span>
|
||||
</div>
|
||||
<div className="mt-2 text-xs text-gray-500">
|
||||
SOUL: quality-judge.soul.md | Routing: quality_*, review_*, validate_*
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* AlertAgent */}
|
||||
<div className="border border-gray-200 rounded-xl p-4 hover:border-red-300 transition-colors">
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="p-3 bg-red-100 rounded-lg">
|
||||
<AlertTriangle className="w-6 h-6 text-red-600" />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<h4 className="font-semibold text-gray-900">AlertAgent</h4>
|
||||
<p className="text-sm text-gray-600 mb-2">Monitoring und Benachrichtigungen</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<span className="px-2 py-1 bg-red-50 text-red-700 text-xs rounded-full">Wachsam</span>
|
||||
<span className="px-2 py-1 bg-red-50 text-red-700 text-xs rounded-full">Proaktiv</span>
|
||||
<span className="px-2 py-1 bg-red-50 text-red-700 text-xs rounded-full">Priorisierend</span>
|
||||
</div>
|
||||
<div className="mt-2 text-xs text-gray-500">
|
||||
SOUL: alert-agent.soul.md | Routing: alert_*, monitor_*, notify_*
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Orchestrator */}
|
||||
<div className="border border-gray-200 rounded-xl p-4 hover:border-purple-300 transition-colors">
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="p-3 bg-purple-100 rounded-lg">
|
||||
<MessageSquare className="w-6 h-6 text-purple-600" />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<h4 className="font-semibold text-gray-900">Orchestrator</h4>
|
||||
<p className="text-sm text-gray-600 mb-2">Task-Koordination und Routing</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<span className="px-2 py-1 bg-purple-50 text-purple-700 text-xs rounded-full">Koordinierend</span>
|
||||
<span className="px-2 py-1 bg-purple-50 text-purple-700 text-xs rounded-full">Effizient</span>
|
||||
<span className="px-2 py-1 bg-purple-50 text-purple-700 text-xs rounded-full">Zuverlaessig</span>
|
||||
</div>
|
||||
<div className="mt-2 text-xs text-gray-500">
|
||||
SOUL: orchestrator.soul.md | Routing: Fallback fuer alle unbekannten Intents
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
{
|
||||
id: 'soul-files',
|
||||
title: 'SOUL-Files (Persoenlichkeiten)',
|
||||
icon: <FileText className="w-5 h-5" />,
|
||||
content: (
|
||||
<div className="space-y-4">
|
||||
<p className="text-gray-600 mb-4">
|
||||
SOUL-Dateien (Semantic Outline for Unified Learning) definieren die Persoenlichkeit und
|
||||
Verhaltensregeln jedes Agents. Sie bestimmen, wie ein Agent kommuniziert, entscheidet und eskaliert.
|
||||
</p>
|
||||
|
||||
<div className="bg-gray-900 rounded-xl p-6 text-gray-100 font-mono text-sm overflow-x-auto">
|
||||
<div className="text-gray-400 mb-4"># Beispiel: tutor-agent.soul.md</div>
|
||||
<pre className="text-green-400">{`
|
||||
# TutorAgent SOUL
|
||||
|
||||
## Identitaet
|
||||
Du bist ein geduldiger, ermutigender Lernbegleiter fuer Schueler.
|
||||
Dein Ziel ist es, Verstaendnis zu foerdern, nicht Antworten vorzugeben.
|
||||
|
||||
## Kommunikationsstil
|
||||
- Verwende einfache, klare Sprache
|
||||
- Stelle Rueckfragen, um Verstaendnis zu pruefen
|
||||
- Gib Hinweise statt direkter Loesungen
|
||||
- Feiere kleine Erfolge
|
||||
|
||||
## Fachgebiete
|
||||
- Mathematik (Grundschule bis Abitur)
|
||||
- Naturwissenschaften (Physik, Chemie, Biologie)
|
||||
- Sprachen (Deutsch, Englisch)
|
||||
|
||||
## Einschraenkungen
|
||||
- Gib NIEMALS vollstaendige Loesungen fuer Hausaufgaben
|
||||
- Verweise bei komplexen Themen auf Lehrkraefte
|
||||
- Erkenne Frustration und biete Pausen an
|
||||
|
||||
## Eskalation
|
||||
- Bei wiederholtem Unverstaendnis: Schlage alternatives Erklaerformat vor
|
||||
- Bei emotionaler Belastung: Empfehle Gespraech mit Vertrauensperson
|
||||
- Bei technischen Problemen: Eskaliere an Support
|
||||
`}</pre>
|
||||
</div>
|
||||
|
||||
<div className="mt-6">
|
||||
<h4 className="font-semibold text-gray-900 mb-3">SOUL-Struktur</h4>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div className="bg-white border border-gray-200 rounded-lg p-4">
|
||||
<h5 className="font-medium text-gray-900 mb-2">Identitaet</h5>
|
||||
<p className="text-sm text-gray-600">Wer ist der Agent? Welche Rolle nimmt er ein?</p>
|
||||
</div>
|
||||
<div className="bg-white border border-gray-200 rounded-lg p-4">
|
||||
<h5 className="font-medium text-gray-900 mb-2">Kommunikationsstil</h5>
|
||||
<p className="text-sm text-gray-600">Wie kommuniziert der Agent mit Benutzern?</p>
|
||||
</div>
|
||||
<div className="bg-white border border-gray-200 rounded-lg p-4">
|
||||
<h5 className="font-medium text-gray-900 mb-2">Fachgebiete</h5>
|
||||
<p className="text-sm text-gray-600">In welchen Bereichen ist der Agent kompetent?</p>
|
||||
</div>
|
||||
<div className="bg-white border border-gray-200 rounded-lg p-4">
|
||||
<h5 className="font-medium text-gray-900 mb-2">Einschraenkungen</h5>
|
||||
<p className="text-sm text-gray-600">Was darf der Agent NICHT tun?</p>
|
||||
</div>
|
||||
<div className="bg-white border border-gray-200 rounded-lg p-4 md:col-span-2">
|
||||
<h5 className="font-medium text-gray-900 mb-2">Eskalation</h5>
|
||||
<p className="text-sm text-gray-600">Wann und wie eskaliert der Agent an andere Agents oder Menschen?</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
{
|
||||
id: 'message-bus',
|
||||
title: 'Message Bus & Kommunikation',
|
||||
icon: <MessageSquare className="w-5 h-5" />,
|
||||
content: (
|
||||
<div className="space-y-4">
|
||||
<p className="text-gray-600 mb-4">
|
||||
Der Message Bus ermoeglicht die asynchrone Kommunikation zwischen Agents via Redis Pub/Sub.
|
||||
Er unterstuetzt Prioritaeten, Request-Response-Pattern und Broadcast-Nachrichten.
|
||||
</p>
|
||||
|
||||
<div className="bg-gray-50 rounded-xl p-6 font-mono text-sm">
|
||||
<div className="text-gray-500 mb-2"># Nachrichtenfluss</div>
|
||||
<pre className="text-gray-700">{`
|
||||
┌──────────────┐ ┌──────────────┐
|
||||
│ Sender │ │ Receiver │
|
||||
│ (Agent) │ │ (Agent) │
|
||||
└──────┬───────┘ └──────▲───────┘
|
||||
│ │
|
||||
│ publish(AgentMessage) │ handle(message)
|
||||
│ │
|
||||
▼ │
|
||||
┌────────────────────────────────────────────────────────┐
|
||||
│ Message Bus │
|
||||
│ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │
|
||||
│ │ Priority Q │ │ Routing │ │ Logging │ │
|
||||
│ │ HIGH/NORMAL │ │ Rules │ │ Audit │ │
|
||||
│ └─────────────┘ └─────────────┘ └─────────────┘ │
|
||||
│ │
|
||||
│ Redis Pub/Sub │
|
||||
└────────────────────────────────────────────────────────┘
|
||||
`}</pre>
|
||||
</div>
|
||||
|
||||
<div className="mt-6">
|
||||
<h4 className="font-semibold text-gray-900 mb-3">Nachrichtentypen</h4>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="min-w-full border border-gray-200 rounded-lg">
|
||||
<thead className="bg-gray-50">
|
||||
<tr>
|
||||
<th className="px-4 py-2 text-left text-sm font-medium text-gray-900">Typ</th>
|
||||
<th className="px-4 py-2 text-left text-sm font-medium text-gray-900">Prioritaet</th>
|
||||
<th className="px-4 py-2 text-left text-sm font-medium text-gray-900">Beschreibung</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-200">
|
||||
<tr>
|
||||
<td className="px-4 py-2 text-sm font-mono text-gray-700">task_request</td>
|
||||
<td className="px-4 py-2"><span className="px-2 py-1 bg-yellow-100 text-yellow-700 text-xs rounded">NORMAL</span></td>
|
||||
<td className="px-4 py-2 text-sm text-gray-600">Neue Aufgabe an Agent senden</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="px-4 py-2 text-sm font-mono text-gray-700">task_response</td>
|
||||
<td className="px-4 py-2"><span className="px-2 py-1 bg-yellow-100 text-yellow-700 text-xs rounded">NORMAL</span></td>
|
||||
<td className="px-4 py-2 text-sm text-gray-600">Antwort auf task_request</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="px-4 py-2 text-sm font-mono text-gray-700">escalation</td>
|
||||
<td className="px-4 py-2"><span className="px-2 py-1 bg-orange-100 text-orange-700 text-xs rounded">HIGH</span></td>
|
||||
<td className="px-4 py-2 text-sm text-gray-600">Eskalation an anderen Agent</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="px-4 py-2 text-sm font-mono text-gray-700">alert</td>
|
||||
<td className="px-4 py-2"><span className="px-2 py-1 bg-red-100 text-red-700 text-xs rounded">CRITICAL</span></td>
|
||||
<td className="px-4 py-2 text-sm text-gray-600">Kritische Benachrichtigung</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="px-4 py-2 text-sm font-mono text-gray-700">heartbeat</td>
|
||||
<td className="px-4 py-2"><span className="px-2 py-1 bg-gray-100 text-gray-700 text-xs rounded">LOW</span></td>
|
||||
<td className="px-4 py-2 text-sm text-gray-600">Liveness-Signal</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
{
|
||||
id: 'shared-brain',
|
||||
title: 'Shared Brain (Gedaechtnis)',
|
||||
icon: <Brain className="w-5 h-5" />,
|
||||
content: (
|
||||
<div className="space-y-4">
|
||||
<p className="text-gray-600 mb-4">
|
||||
Das Shared Brain speichert Wissen und Kontext, auf den alle Agents zugreifen koennen.
|
||||
Es besteht aus drei Komponenten: Memory Store, Context Manager und Knowledge Graph.
|
||||
</p>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<div className="bg-white border border-gray-200 rounded-xl p-5">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<Database className="w-5 h-5 text-blue-600" />
|
||||
<h4 className="font-semibold text-gray-900">Memory Store</h4>
|
||||
</div>
|
||||
<p className="text-sm text-gray-600 mb-3">
|
||||
Langzeit-Gedaechtnis fuer Fakten, Entscheidungen und Lernfortschritte.
|
||||
</p>
|
||||
<ul className="text-xs text-gray-500 space-y-1">
|
||||
<li>- TTL-basierte Expiration (30 Tage default)</li>
|
||||
<li>- Access-Tracking (Haeufigkeit)</li>
|
||||
<li>- Pattern-basierte Suche</li>
|
||||
<li>- Hybrid: Redis + PostgreSQL</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div className="bg-white border border-gray-200 rounded-xl p-5">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<Activity className="w-5 h-5 text-purple-600" />
|
||||
<h4 className="font-semibold text-gray-900">Context Manager</h4>
|
||||
</div>
|
||||
<p className="text-sm text-gray-600 mb-3">
|
||||
Verwaltet Konversationskontext mit automatischer Komprimierung.
|
||||
</p>
|
||||
<ul className="text-xs text-gray-500 space-y-1">
|
||||
<li>- Max 50 Messages pro Context</li>
|
||||
<li>- Automatische Zusammenfassung</li>
|
||||
<li>- System-Messages bleiben erhalten</li>
|
||||
<li>- Entity-Extraktion</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div className="bg-white border border-gray-200 rounded-xl p-5">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<GitBranch className="w-5 h-5 text-green-600" />
|
||||
<h4 className="font-semibold text-gray-900">Knowledge Graph</h4>
|
||||
</div>
|
||||
<p className="text-sm text-gray-600 mb-3">
|
||||
Graph-basierte Darstellung von Entitaeten und ihren Beziehungen.
|
||||
</p>
|
||||
<ul className="text-xs text-gray-500 space-y-1">
|
||||
<li>- Entitaeten: Student, Lehrer, Fach</li>
|
||||
<li>- Beziehungen: lernt, unterrichtet</li>
|
||||
<li>- BFS-basierte Pfadsuche</li>
|
||||
<li>- Verwandte Entitaeten finden</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-gray-50 rounded-xl p-6 font-mono text-sm mt-6">
|
||||
<div className="text-gray-500 mb-2"># Memory Store Beispiel</div>
|
||||
<pre className="text-gray-700">{`
|
||||
# Speichern
|
||||
await store.remember(
|
||||
key="student:123:progress",
|
||||
value={"level": 5, "score": 85, "topic": "algebra"},
|
||||
agent_id="tutor-agent",
|
||||
ttl_days=30
|
||||
)
|
||||
|
||||
# Abrufen
|
||||
progress = await store.recall("student:123:progress")
|
||||
# → {"level": 5, "score": 85, "topic": "algebra"}
|
||||
|
||||
# Suchen
|
||||
all_progress = await store.search("student:123:*")
|
||||
# → [Memory(...), Memory(...), ...]
|
||||
`}</pre>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
{
|
||||
id: 'task-routing',
|
||||
title: 'Task Routing',
|
||||
icon: <Zap className="w-5 h-5" />,
|
||||
content: (
|
||||
<div className="space-y-4">
|
||||
<p className="text-gray-600 mb-4">
|
||||
Der Task Router entscheidet, welcher Agent eine Anfrage bearbeitet. Er verwendet
|
||||
Intent-basierte Regeln mit Prioritaeten und Fallback-Ketten.
|
||||
</p>
|
||||
|
||||
<div className="overflow-x-auto">
|
||||
<table className="min-w-full border border-gray-200 rounded-lg">
|
||||
<thead className="bg-gray-50">
|
||||
<tr>
|
||||
<th className="px-4 py-2 text-left text-sm font-medium text-gray-900">Intent-Pattern</th>
|
||||
<th className="px-4 py-2 text-left text-sm font-medium text-gray-900">Ziel-Agent</th>
|
||||
<th className="px-4 py-2 text-left text-sm font-medium text-gray-900">Prioritaet</th>
|
||||
<th className="px-4 py-2 text-left text-sm font-medium text-gray-900">Fallback</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-200">
|
||||
<tr>
|
||||
<td className="px-4 py-2 text-sm font-mono text-blue-700">learning_*</td>
|
||||
<td className="px-4 py-2 text-sm text-gray-700">TutorAgent</td>
|
||||
<td className="px-4 py-2 text-sm text-gray-700">10</td>
|
||||
<td className="px-4 py-2 text-sm text-gray-500">Orchestrator</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="px-4 py-2 text-sm font-mono text-blue-700">help_*, question_*</td>
|
||||
<td className="px-4 py-2 text-sm text-gray-700">TutorAgent</td>
|
||||
<td className="px-4 py-2 text-sm text-gray-700">8</td>
|
||||
<td className="px-4 py-2 text-sm text-gray-500">Orchestrator</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="px-4 py-2 text-sm font-mono text-green-700">grade_*, evaluate_*</td>
|
||||
<td className="px-4 py-2 text-sm text-gray-700">GraderAgent</td>
|
||||
<td className="px-4 py-2 text-sm text-gray-700">10</td>
|
||||
<td className="px-4 py-2 text-sm text-gray-500">Orchestrator</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="px-4 py-2 text-sm font-mono text-amber-700">quality_*, review_*</td>
|
||||
<td className="px-4 py-2 text-sm text-gray-700">QualityJudge</td>
|
||||
<td className="px-4 py-2 text-sm text-gray-700">10</td>
|
||||
<td className="px-4 py-2 text-sm text-gray-500">GraderAgent</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="px-4 py-2 text-sm font-mono text-red-700">alert_*, monitor_*</td>
|
||||
<td className="px-4 py-2 text-sm text-gray-700">AlertAgent</td>
|
||||
<td className="px-4 py-2 text-sm text-gray-700">10</td>
|
||||
<td className="px-4 py-2 text-sm text-gray-500">Orchestrator</td>
|
||||
</tr>
|
||||
<tr className="bg-gray-50">
|
||||
<td className="px-4 py-2 text-sm font-mono text-gray-500">* (alle anderen)</td>
|
||||
<td className="px-4 py-2 text-sm text-gray-700">Orchestrator</td>
|
||||
<td className="px-4 py-2 text-sm text-gray-700">0</td>
|
||||
<td className="px-4 py-2 text-sm text-gray-500">-</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div className="bg-white border border-gray-200 rounded-xl p-4">
|
||||
<h4 className="font-semibold text-gray-900 mb-2">Routing-Strategien</h4>
|
||||
<ul className="text-sm text-gray-600 space-y-2">
|
||||
<li><span className="font-mono text-blue-600">ROUND_ROBIN</span> - Gleichmaessige Verteilung</li>
|
||||
<li><span className="font-mono text-blue-600">LEAST_LOADED</span> - Agent mit wenigsten Tasks</li>
|
||||
<li><span className="font-mono text-blue-600">PRIORITY</span> - Hoechste Prioritaet zuerst</li>
|
||||
<li><span className="font-mono text-blue-600">RANDOM</span> - Zufaellige Auswahl</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div className="bg-white border border-gray-200 rounded-xl p-4">
|
||||
<h4 className="font-semibold text-gray-900 mb-2">Fallback-Verhalten</h4>
|
||||
<ul className="text-sm text-gray-600 space-y-2">
|
||||
<li>1. Versuche Ziel-Agent zu erreichen</li>
|
||||
<li>2. Bei Timeout: Fallback-Agent nutzen</li>
|
||||
<li>3. Bei Fehler: Orchestrator uebernimmt</li>
|
||||
<li>4. Bei kritischen Fehlern: Alert an Admin</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
{
|
||||
id: 'session-lifecycle',
|
||||
title: 'Session Lifecycle',
|
||||
icon: <RefreshCw className="w-5 h-5" />,
|
||||
content: (
|
||||
<div className="space-y-4">
|
||||
<p className="text-gray-600 mb-4">
|
||||
Sessions verwalten den Zustand von Agent-Interaktionen. Jede Session hat einen definierten
|
||||
Lebenszyklus mit Checkpoints fuer Recovery.
|
||||
</p>
|
||||
|
||||
<div className="bg-gray-50 rounded-xl p-6 font-mono text-sm">
|
||||
<div className="text-gray-500 mb-2"># Session State Machine</div>
|
||||
<pre className="text-gray-700">{`
|
||||
┌─────────────────────────────────────┐
|
||||
│ │
|
||||
▼ │
|
||||
┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐
|
||||
│ ACTIVE │───▶│ PAUSED │───▶│ COMPLETED│ │ FAILED │
|
||||
└──────────┘ └──────────┘ └──────────┘ └──────────┘
|
||||
│ │ ▲
|
||||
│ │ │
|
||||
└───────────────┴───────────────────────────────┘
|
||||
(bei Fehler)
|
||||
|
||||
States:
|
||||
- ACTIVE: Session laeuft, Agent verarbeitet Tasks
|
||||
- PAUSED: Session pausiert, wartet auf Eingabe
|
||||
- COMPLETED: Session erfolgreich beendet
|
||||
- FAILED: Session mit Fehler beendet
|
||||
`}</pre>
|
||||
</div>
|
||||
|
||||
<div className="mt-6">
|
||||
<h4 className="font-semibold text-gray-900 mb-3">Heartbeat Monitoring</h4>
|
||||
<div className="bg-white border border-gray-200 rounded-xl p-5">
|
||||
<div className="grid grid-cols-3 gap-4 text-center">
|
||||
<div>
|
||||
<div className="text-2xl font-bold text-gray-900">30s</div>
|
||||
<div className="text-sm text-gray-500">Timeout</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-2xl font-bold text-gray-900">5s</div>
|
||||
<div className="text-sm text-gray-500">Check Interval</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-2xl font-bold text-gray-900">3</div>
|
||||
<div className="text-sm text-gray-500">Max Missed Beats</div>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-sm text-gray-600 mt-4 text-center">
|
||||
Nach 3 verpassten Heartbeats wird der Agent als ausgefallen markiert und die
|
||||
Restart-Policy greift (max. 3 Versuche).
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
{
|
||||
id: 'database',
|
||||
title: 'Datenbank-Schema',
|
||||
icon: <Database className="w-5 h-5" />,
|
||||
content: (
|
||||
<div className="space-y-4">
|
||||
<p className="text-gray-600 mb-4">
|
||||
Das Agent-System nutzt PostgreSQL fuer persistente Daten und Valkey (Redis) fuer Caching und Pub/Sub.
|
||||
</p>
|
||||
|
||||
<div className="space-y-4">
|
||||
{/* agent_sessions */}
|
||||
<div className="bg-white border border-gray-200 rounded-xl p-4">
|
||||
<h4 className="font-semibold text-gray-900 mb-2 font-mono">agent_sessions</h4>
|
||||
<p className="text-sm text-gray-600 mb-3">Speichert Session-Daten mit Checkpoints</p>
|
||||
<div className="bg-gray-50 rounded-lg p-3 font-mono text-xs overflow-x-auto">
|
||||
<pre>{`
|
||||
CREATE TABLE agent_sessions (
|
||||
id UUID PRIMARY KEY,
|
||||
agent_type VARCHAR(50) NOT NULL,
|
||||
user_id UUID REFERENCES users(id),
|
||||
state VARCHAR(20) NOT NULL DEFAULT 'active',
|
||||
context JSONB DEFAULT '{}',
|
||||
checkpoints JSONB DEFAULT '[]',
|
||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
last_heartbeat TIMESTAMPTZ DEFAULT NOW()
|
||||
);
|
||||
`}</pre>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* agent_memory */}
|
||||
<div className="bg-white border border-gray-200 rounded-xl p-4">
|
||||
<h4 className="font-semibold text-gray-900 mb-2 font-mono">agent_memory</h4>
|
||||
<p className="text-sm text-gray-600 mb-3">Langzeit-Gedaechtnis mit TTL</p>
|
||||
<div className="bg-gray-50 rounded-lg p-3 font-mono text-xs overflow-x-auto">
|
||||
<pre>{`
|
||||
CREATE TABLE agent_memory (
|
||||
id UUID PRIMARY KEY,
|
||||
namespace VARCHAR(100) NOT NULL,
|
||||
key VARCHAR(500) NOT NULL,
|
||||
value JSONB NOT NULL,
|
||||
agent_id VARCHAR(50) NOT NULL,
|
||||
access_count INTEGER DEFAULT 0,
|
||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
expires_at TIMESTAMPTZ,
|
||||
UNIQUE(namespace, key)
|
||||
);
|
||||
`}</pre>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* agent_messages */}
|
||||
<div className="bg-white border border-gray-200 rounded-xl p-4">
|
||||
<h4 className="font-semibold text-gray-900 mb-2 font-mono">agent_messages</h4>
|
||||
<p className="text-sm text-gray-600 mb-3">Audit-Trail fuer Inter-Agent Kommunikation</p>
|
||||
<div className="bg-gray-50 rounded-lg p-3 font-mono text-xs overflow-x-auto">
|
||||
<pre>{`
|
||||
CREATE TABLE agent_messages (
|
||||
id UUID PRIMARY KEY,
|
||||
sender VARCHAR(50) NOT NULL,
|
||||
receiver VARCHAR(50) NOT NULL,
|
||||
message_type VARCHAR(50) NOT NULL,
|
||||
payload JSONB NOT NULL,
|
||||
priority INTEGER DEFAULT 1,
|
||||
correlation_id UUID,
|
||||
created_at TIMESTAMPTZ DEFAULT NOW()
|
||||
);
|
||||
`}</pre>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
]
|
||||
|
||||
return (
|
||||
<div className="p-6 max-w-5xl mx-auto">
|
||||
{/* Header */}
|
||||
<div className="mb-8">
|
||||
<Link
|
||||
href="/ai/agents"
|
||||
className="flex items-center gap-2 text-gray-500 hover:text-gray-700 mb-4"
|
||||
>
|
||||
<ArrowLeft className="w-4 h-4" />
|
||||
Zurueck zur Agent-Verwaltung
|
||||
</Link>
|
||||
<h1 className="text-2xl font-bold text-gray-900 flex items-center gap-3">
|
||||
<div className="p-2 bg-purple-100 rounded-lg">
|
||||
<FileText className="w-6 h-6 text-purple-600" />
|
||||
</div>
|
||||
Multi-Agent Architektur
|
||||
</h1>
|
||||
<p className="text-gray-500 mt-1">
|
||||
Technische Dokumentation des Breakpilot Multi-Agent-Systems
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Table of Contents */}
|
||||
<div className="bg-gray-50 rounded-xl p-5 mb-8">
|
||||
<h2 className="font-semibold text-gray-900 mb-3">Inhaltsverzeichnis</h2>
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-2">
|
||||
{sections.map(section => (
|
||||
<button
|
||||
key={section.id}
|
||||
onClick={() => {
|
||||
if (!expandedSections.includes(section.id)) {
|
||||
setExpandedSections(prev => [...prev, section.id])
|
||||
}
|
||||
document.getElementById(section.id)?.scrollIntoView({ behavior: 'smooth' })
|
||||
}}
|
||||
className="flex items-center gap-2 text-sm text-gray-600 hover:text-teal-600 text-left p-2 rounded-lg hover:bg-gray-100 transition-colors"
|
||||
>
|
||||
{section.icon}
|
||||
<span className="truncate">{section.title}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Sections */}
|
||||
<div className="space-y-4">
|
||||
{sections.map(section => (
|
||||
<div
|
||||
key={section.id}
|
||||
id={section.id}
|
||||
className="bg-white border border-gray-200 rounded-xl overflow-hidden"
|
||||
>
|
||||
<button
|
||||
onClick={() => toggleSection(section.id)}
|
||||
className="w-full flex items-center justify-between p-5 hover:bg-gray-50 transition-colors"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2 bg-gray-100 rounded-lg">
|
||||
{section.icon}
|
||||
</div>
|
||||
<span className="font-semibold text-gray-900">{section.title}</span>
|
||||
</div>
|
||||
{expandedSections.includes(section.id) ? (
|
||||
<ChevronDown className="w-5 h-5 text-gray-400" />
|
||||
) : (
|
||||
<ChevronRight className="w-5 h-5 text-gray-400" />
|
||||
)}
|
||||
</button>
|
||||
{expandedSections.includes(section.id) && (
|
||||
<div className="px-5 pb-5 border-t border-gray-100 pt-4">
|
||||
{section.content}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Footer Links */}
|
||||
<div className="mt-8 bg-teal-50 border border-teal-200 rounded-xl p-5">
|
||||
<h3 className="font-semibold text-teal-900 mb-3">Weiterführende Ressourcen</h3>
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-3">
|
||||
<Link
|
||||
href="/ai/agents"
|
||||
className="flex items-center gap-2 text-sm text-teal-700 hover:text-teal-900"
|
||||
>
|
||||
<Cpu className="w-4 h-4" />
|
||||
Agent-Uebersicht
|
||||
</Link>
|
||||
<Link
|
||||
href="/ai/agents/sessions"
|
||||
className="flex items-center gap-2 text-sm text-teal-700 hover:text-teal-900"
|
||||
>
|
||||
<Activity className="w-4 h-4" />
|
||||
Aktive Sessions
|
||||
</Link>
|
||||
<Link
|
||||
href="/ai/agents/statistics"
|
||||
className="flex items-center gap-2 text-sm text-teal-700 hover:text-teal-900"
|
||||
>
|
||||
<Database className="w-4 h-4" />
|
||||
Statistiken
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,390 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect } from 'react'
|
||||
import Link from 'next/link'
|
||||
import { Bot, Activity, Brain, Settings, FileText, BarChart3, Clock, AlertTriangle, CheckCircle, Pause, XCircle, ChevronRight, Cpu, MessageSquare, Database, RefreshCw } from 'lucide-react'
|
||||
|
||||
// Agent types
|
||||
interface AgentConfig {
|
||||
id: string
|
||||
name: string
|
||||
description: string
|
||||
soulFile: string
|
||||
color: string
|
||||
icon: 'bot' | 'brain' | 'message' | 'alert' | 'settings'
|
||||
status: 'running' | 'paused' | 'stopped' | 'error'
|
||||
activeSessions: number
|
||||
totalProcessed: number
|
||||
avgResponseTime: number
|
||||
lastActivity: string
|
||||
}
|
||||
|
||||
interface AgentStats {
|
||||
totalSessions: number
|
||||
activeSessions: number
|
||||
totalMessages: number
|
||||
avgLatency: number
|
||||
errorRate: number
|
||||
memoryUsage: number
|
||||
}
|
||||
|
||||
// Mock data - In production, fetch from API
|
||||
const mockAgents: AgentConfig[] = [
|
||||
{
|
||||
id: 'tutor-agent',
|
||||
name: 'TutorAgent',
|
||||
description: 'Lernbegleitung und Fragen beantworten',
|
||||
soulFile: 'tutor-agent.soul.md',
|
||||
color: '#3b82f6',
|
||||
icon: 'brain',
|
||||
status: 'running',
|
||||
activeSessions: 12,
|
||||
totalProcessed: 1847,
|
||||
avgResponseTime: 234,
|
||||
lastActivity: '2 min ago'
|
||||
},
|
||||
{
|
||||
id: 'grader-agent',
|
||||
name: 'GraderAgent',
|
||||
description: 'Klausur-Korrektur und Bewertung',
|
||||
soulFile: 'grader-agent.soul.md',
|
||||
color: '#10b981',
|
||||
icon: 'bot',
|
||||
status: 'running',
|
||||
activeSessions: 3,
|
||||
totalProcessed: 456,
|
||||
avgResponseTime: 1205,
|
||||
lastActivity: '5 min ago'
|
||||
},
|
||||
{
|
||||
id: 'quality-judge',
|
||||
name: 'QualityJudge',
|
||||
description: 'BQAS Qualitaetspruefung',
|
||||
soulFile: 'quality-judge.soul.md',
|
||||
color: '#f59e0b',
|
||||
icon: 'settings',
|
||||
status: 'running',
|
||||
activeSessions: 8,
|
||||
totalProcessed: 3291,
|
||||
avgResponseTime: 89,
|
||||
lastActivity: '1 min ago'
|
||||
},
|
||||
{
|
||||
id: 'alert-agent',
|
||||
name: 'AlertAgent',
|
||||
description: 'Monitoring und Benachrichtigungen',
|
||||
soulFile: 'alert-agent.soul.md',
|
||||
color: '#ef4444',
|
||||
icon: 'alert',
|
||||
status: 'running',
|
||||
activeSessions: 1,
|
||||
totalProcessed: 892,
|
||||
avgResponseTime: 45,
|
||||
lastActivity: '30 sec ago'
|
||||
},
|
||||
{
|
||||
id: 'orchestrator',
|
||||
name: 'Orchestrator',
|
||||
description: 'Task-Koordination und Routing',
|
||||
soulFile: 'orchestrator.soul.md',
|
||||
color: '#8b5cf6',
|
||||
icon: 'message',
|
||||
status: 'running',
|
||||
activeSessions: 24,
|
||||
totalProcessed: 8934,
|
||||
avgResponseTime: 12,
|
||||
lastActivity: 'just now'
|
||||
},
|
||||
{
|
||||
id: 'compliance-advisor',
|
||||
name: 'Compliance Advisor',
|
||||
description: 'DSGVO/Compliance-Berater fuer SDK-Nutzer',
|
||||
soulFile: 'compliance-advisor.soul.md',
|
||||
color: '#6366f1',
|
||||
icon: 'message',
|
||||
status: 'running',
|
||||
activeSessions: 0,
|
||||
totalProcessed: 0,
|
||||
avgResponseTime: 0,
|
||||
lastActivity: new Date().toISOString()
|
||||
}
|
||||
]
|
||||
|
||||
const mockStats: AgentStats = {
|
||||
totalSessions: 156,
|
||||
activeSessions: 48,
|
||||
totalMessages: 15420,
|
||||
avgLatency: 156,
|
||||
errorRate: 0.8,
|
||||
memoryUsage: 67
|
||||
}
|
||||
|
||||
function getIconComponent(icon: string, className: string) {
|
||||
switch(icon) {
|
||||
case 'bot': return <Bot className={className} />
|
||||
case 'brain': return <Brain className={className} />
|
||||
case 'message': return <MessageSquare className={className} />
|
||||
case 'alert': return <AlertTriangle className={className} />
|
||||
case 'settings': return <Settings className={className} />
|
||||
default: return <Bot className={className} />
|
||||
}
|
||||
}
|
||||
|
||||
function getStatusIcon(status: string) {
|
||||
switch(status) {
|
||||
case 'running': return <CheckCircle className="w-4 h-4 text-green-500" />
|
||||
case 'paused': return <Pause className="w-4 h-4 text-yellow-500" />
|
||||
case 'stopped': return <XCircle className="w-4 h-4 text-gray-500" />
|
||||
case 'error': return <AlertTriangle className="w-4 h-4 text-red-500" />
|
||||
default: return null
|
||||
}
|
||||
}
|
||||
|
||||
function getStatusColor(status: string) {
|
||||
switch(status) {
|
||||
case 'running': return 'bg-green-500/10 text-green-600 border-green-500/20'
|
||||
case 'paused': return 'bg-yellow-500/10 text-yellow-600 border-yellow-500/20'
|
||||
case 'stopped': return 'bg-gray-500/10 text-gray-600 border-gray-500/20'
|
||||
case 'error': return 'bg-red-500/10 text-red-600 border-red-500/20'
|
||||
default: return 'bg-gray-500/10 text-gray-600 border-gray-500/20'
|
||||
}
|
||||
}
|
||||
|
||||
export default function AgentManagementPage() {
|
||||
const [agents, setAgents] = useState<AgentConfig[]>(mockAgents)
|
||||
const [stats, setStats] = useState<AgentStats>(mockStats)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [lastRefresh, setLastRefresh] = useState(new Date())
|
||||
|
||||
const refreshData = async () => {
|
||||
setLoading(true)
|
||||
// In production, fetch from API
|
||||
// const response = await fetch('/api/admin/agents/status')
|
||||
await new Promise(resolve => setTimeout(resolve, 500))
|
||||
setLastRefresh(new Date())
|
||||
setLoading(false)
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
// Auto-refresh every 30 seconds
|
||||
const interval = setInterval(refreshData, 30000)
|
||||
return () => clearInterval(interval)
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div className="p-6 max-w-7xl mx-auto">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between mb-8">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900 flex items-center gap-3">
|
||||
<div className="p-2 bg-teal-100 rounded-lg">
|
||||
<Bot className="w-6 h-6 text-teal-600" />
|
||||
</div>
|
||||
Agent Management
|
||||
</h1>
|
||||
<p className="text-gray-500 mt-1">
|
||||
Multi-Agent System verwalten, SOUL-Files bearbeiten, Statistiken analysieren
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-sm text-gray-500">
|
||||
Letzte Aktualisierung: {lastRefresh.toLocaleTimeString('de-DE')}
|
||||
</span>
|
||||
<button
|
||||
onClick={refreshData}
|
||||
disabled={loading}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-teal-600 text-white rounded-lg hover:bg-teal-700 transition-colors disabled:opacity-50"
|
||||
>
|
||||
<RefreshCw className={`w-4 h-4 ${loading ? 'animate-spin' : ''}`} />
|
||||
Aktualisieren
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Quick Links */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-4 mb-8">
|
||||
<Link
|
||||
href="/ai/agents/architecture"
|
||||
className="flex items-center gap-3 p-4 bg-white border border-gray-200 rounded-xl hover:border-teal-300 hover:shadow-md transition-all group"
|
||||
>
|
||||
<div className="p-2 bg-purple-100 rounded-lg group-hover:bg-purple-200 transition-colors">
|
||||
<FileText className="w-5 h-5 text-purple-600" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="font-medium text-gray-900">Architektur</div>
|
||||
<div className="text-sm text-gray-500">Dokumentation & Diagramme</div>
|
||||
</div>
|
||||
<ChevronRight className="w-5 h-5 text-gray-400 ml-auto" />
|
||||
</Link>
|
||||
|
||||
<Link
|
||||
href="/ai/agents/sessions"
|
||||
className="flex items-center gap-3 p-4 bg-white border border-gray-200 rounded-xl hover:border-teal-300 hover:shadow-md transition-all group"
|
||||
>
|
||||
<div className="p-2 bg-blue-100 rounded-lg group-hover:bg-blue-200 transition-colors">
|
||||
<Activity className="w-5 h-5 text-blue-600" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="font-medium text-gray-900">Sessions</div>
|
||||
<div className="text-sm text-gray-500">{stats.activeSessions} aktiv</div>
|
||||
</div>
|
||||
<ChevronRight className="w-5 h-5 text-gray-400 ml-auto" />
|
||||
</Link>
|
||||
|
||||
<Link
|
||||
href="/ai/agents/statistics"
|
||||
className="flex items-center gap-3 p-4 bg-white border border-gray-200 rounded-xl hover:border-teal-300 hover:shadow-md transition-all group"
|
||||
>
|
||||
<div className="p-2 bg-green-100 rounded-lg group-hover:bg-green-200 transition-colors">
|
||||
<BarChart3 className="w-5 h-5 text-green-600" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="font-medium text-gray-900">Statistiken</div>
|
||||
<div className="text-sm text-gray-500">Performance & Trends</div>
|
||||
</div>
|
||||
<ChevronRight className="w-5 h-5 text-gray-400 ml-auto" />
|
||||
</Link>
|
||||
|
||||
<Link
|
||||
href="/ai/test-quality"
|
||||
className="flex items-center gap-3 p-4 bg-white border border-gray-200 rounded-xl hover:border-teal-300 hover:shadow-md transition-all group"
|
||||
>
|
||||
<div className="p-2 bg-amber-100 rounded-lg group-hover:bg-amber-200 transition-colors">
|
||||
<Cpu className="w-5 h-5 text-amber-600" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="font-medium text-gray-900">BQAS</div>
|
||||
<div className="text-sm text-gray-500">Qualitaetssicherung</div>
|
||||
</div>
|
||||
<ChevronRight className="w-5 h-5 text-gray-400 ml-auto" />
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* Stats Overview */}
|
||||
<div className="grid grid-cols-2 md:grid-cols-6 gap-4 mb-8">
|
||||
<div className="bg-white border border-gray-200 rounded-xl p-4">
|
||||
<div className="text-sm text-gray-500 mb-1">Gesamt Sessions</div>
|
||||
<div className="text-2xl font-bold text-gray-900">{stats.totalSessions.toLocaleString()}</div>
|
||||
</div>
|
||||
<div className="bg-white border border-gray-200 rounded-xl p-4">
|
||||
<div className="text-sm text-gray-500 mb-1">Aktive Sessions</div>
|
||||
<div className="text-2xl font-bold text-green-600">{stats.activeSessions}</div>
|
||||
</div>
|
||||
<div className="bg-white border border-gray-200 rounded-xl p-4">
|
||||
<div className="text-sm text-gray-500 mb-1">Nachrichten (24h)</div>
|
||||
<div className="text-2xl font-bold text-gray-900">{stats.totalMessages.toLocaleString()}</div>
|
||||
</div>
|
||||
<div className="bg-white border border-gray-200 rounded-xl p-4">
|
||||
<div className="text-sm text-gray-500 mb-1">Avg. Latenz</div>
|
||||
<div className="text-2xl font-bold text-gray-900">{stats.avgLatency}ms</div>
|
||||
</div>
|
||||
<div className="bg-white border border-gray-200 rounded-xl p-4">
|
||||
<div className="text-sm text-gray-500 mb-1">Fehlerrate</div>
|
||||
<div className="text-2xl font-bold text-amber-600">{stats.errorRate}%</div>
|
||||
</div>
|
||||
<div className="bg-white border border-gray-200 rounded-xl p-4">
|
||||
<div className="text-sm text-gray-500 mb-1">Memory Usage</div>
|
||||
<div className="text-2xl font-bold text-gray-900">{stats.memoryUsage}%</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Agents Grid */}
|
||||
<div className="mb-6">
|
||||
<h2 className="text-lg font-semibold text-gray-900 mb-4">Agents</h2>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{agents.map((agent) => (
|
||||
<Link
|
||||
key={agent.id}
|
||||
href={`/ai/agents/${agent.id}`}
|
||||
className="bg-white border border-gray-200 rounded-xl p-5 hover:border-teal-300 hover:shadow-lg transition-all group"
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex items-start justify-between mb-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div
|
||||
className="p-2.5 rounded-lg"
|
||||
style={{ backgroundColor: `${agent.color}20` }}
|
||||
>
|
||||
{getIconComponent(agent.icon, `w-5 h-5`)}
|
||||
<style jsx>{`
|
||||
svg { color: ${agent.color}; }
|
||||
`}</style>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-semibold text-gray-900 group-hover:text-teal-600 transition-colors">
|
||||
{agent.name}
|
||||
</h3>
|
||||
<p className="text-sm text-gray-500">{agent.description}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className={`flex items-center gap-1.5 px-2 py-1 rounded-full text-xs font-medium border ${getStatusColor(agent.status)}`}>
|
||||
{getStatusIcon(agent.status)}
|
||||
{agent.status}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Stats */}
|
||||
<div className="grid grid-cols-3 gap-3 mb-4">
|
||||
<div className="text-center p-2 bg-gray-50 rounded-lg">
|
||||
<div className="text-lg font-semibold text-gray-900">{agent.activeSessions}</div>
|
||||
<div className="text-xs text-gray-500">Sessions</div>
|
||||
</div>
|
||||
<div className="text-center p-2 bg-gray-50 rounded-lg">
|
||||
<div className="text-lg font-semibold text-gray-900">{agent.totalProcessed}</div>
|
||||
<div className="text-xs text-gray-500">Verarbeitet</div>
|
||||
</div>
|
||||
<div className="text-center p-2 bg-gray-50 rounded-lg">
|
||||
<div className="text-lg font-semibold text-gray-900">{agent.avgResponseTime}ms</div>
|
||||
<div className="text-xs text-gray-500">Avg. Zeit</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="flex items-center justify-between pt-3 border-t border-gray-100">
|
||||
<div className="flex items-center gap-2 text-sm text-gray-500">
|
||||
<FileText className="w-4 h-4" />
|
||||
{agent.soulFile}
|
||||
</div>
|
||||
<div className="flex items-center gap-1 text-sm text-gray-400">
|
||||
<Clock className="w-3.5 h-3.5" />
|
||||
{agent.lastActivity}
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Info Box */}
|
||||
<div className="bg-teal-50 border border-teal-200 rounded-xl p-5">
|
||||
<div className="flex gap-4">
|
||||
<div className="p-2 bg-teal-100 rounded-lg h-fit">
|
||||
<Brain className="w-5 h-5 text-teal-600" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-semibold text-teal-900 mb-2">Multi-Agent Architektur</h3>
|
||||
<p className="text-sm text-teal-700 mb-3">
|
||||
Das Breakpilot Multi-Agent-System basiert auf dem Mission Control Konzept. Jeder Agent hat eine
|
||||
definierte Persoenlichkeit (SOUL-File), die sein Verhalten steuert. Die Agents kommunizieren
|
||||
ueber einen Message Bus und nutzen ein gemeinsames Gedaechtnis (Shared Brain).
|
||||
</p>
|
||||
<div className="flex gap-3">
|
||||
<Link
|
||||
href="/ai/agents/architecture"
|
||||
className="text-sm font-medium text-teal-600 hover:text-teal-800"
|
||||
>
|
||||
Architektur ansehen →
|
||||
</Link>
|
||||
<Link
|
||||
href="/ai/agents/architecture#soul-files"
|
||||
className="text-sm font-medium text-teal-600 hover:text-teal-800"
|
||||
>
|
||||
SOUL-Files verstehen →
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,444 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect } from 'react'
|
||||
import Link from 'next/link'
|
||||
import { ArrowLeft, Activity, Clock, User, Bot, Brain, MessageSquare, AlertTriangle, Settings, CheckCircle, Pause, XCircle, RefreshCw, Filter, Search, ChevronRight, Zap, MoreVertical } from 'lucide-react'
|
||||
|
||||
// Session types
|
||||
interface AgentSession {
|
||||
id: string
|
||||
agentType: string
|
||||
agentId: string
|
||||
userId: string
|
||||
userName: string
|
||||
state: 'active' | 'paused' | 'completed' | 'failed'
|
||||
createdAt: string
|
||||
lastActivity: string
|
||||
checkpointCount: number
|
||||
messagesProcessed: number
|
||||
currentTask: string | null
|
||||
avgResponseTime: number
|
||||
}
|
||||
|
||||
// Mock data
|
||||
const mockSessions: AgentSession[] = [
|
||||
{
|
||||
id: 'session-001',
|
||||
agentType: 'tutor-agent',
|
||||
agentId: 'tutor-1',
|
||||
userId: 'user-123',
|
||||
userName: 'Max Mustermann',
|
||||
state: 'active',
|
||||
createdAt: '2026-02-03T14:30:00Z',
|
||||
lastActivity: '2026-02-03T15:45:23Z',
|
||||
checkpointCount: 5,
|
||||
messagesProcessed: 23,
|
||||
currentTask: 'Erklaere Quadratische Funktionen',
|
||||
avgResponseTime: 245
|
||||
},
|
||||
{
|
||||
id: 'session-002',
|
||||
agentType: 'tutor-agent',
|
||||
agentId: 'tutor-2',
|
||||
userId: 'user-456',
|
||||
userName: 'Anna Schmidt',
|
||||
state: 'active',
|
||||
createdAt: '2026-02-03T15:00:00Z',
|
||||
lastActivity: '2026-02-03T15:44:12Z',
|
||||
checkpointCount: 3,
|
||||
messagesProcessed: 12,
|
||||
currentTask: 'Hilfe bei Gedichtanalyse',
|
||||
avgResponseTime: 312
|
||||
},
|
||||
{
|
||||
id: 'session-003',
|
||||
agentType: 'grader-agent',
|
||||
agentId: 'grader-1',
|
||||
userId: 'user-789',
|
||||
userName: 'Frau Mueller (Lehrerin)',
|
||||
state: 'active',
|
||||
createdAt: '2026-02-03T14:00:00Z',
|
||||
lastActivity: '2026-02-03T15:42:00Z',
|
||||
checkpointCount: 12,
|
||||
messagesProcessed: 45,
|
||||
currentTask: 'Korrektur Klausur 10b - Arbeit 7/24',
|
||||
avgResponseTime: 1205
|
||||
},
|
||||
{
|
||||
id: 'session-004',
|
||||
agentType: 'quality-judge',
|
||||
agentId: 'judge-1',
|
||||
userId: 'system',
|
||||
userName: 'System (BQAS)',
|
||||
state: 'active',
|
||||
createdAt: '2026-02-03T08:00:00Z',
|
||||
lastActivity: '2026-02-03T15:45:01Z',
|
||||
checkpointCount: 156,
|
||||
messagesProcessed: 892,
|
||||
currentTask: 'Quality Check Queue Processing',
|
||||
avgResponseTime: 89
|
||||
},
|
||||
{
|
||||
id: 'session-005',
|
||||
agentType: 'orchestrator',
|
||||
agentId: 'orchestrator-main',
|
||||
userId: 'system',
|
||||
userName: 'System',
|
||||
state: 'active',
|
||||
createdAt: '2026-02-03T00:00:00Z',
|
||||
lastActivity: '2026-02-03T15:45:30Z',
|
||||
checkpointCount: 2341,
|
||||
messagesProcessed: 8934,
|
||||
currentTask: 'Routing incoming requests',
|
||||
avgResponseTime: 12
|
||||
},
|
||||
{
|
||||
id: 'session-006',
|
||||
agentType: 'tutor-agent',
|
||||
agentId: 'tutor-3',
|
||||
userId: 'user-101',
|
||||
userName: 'Tim Berger',
|
||||
state: 'paused',
|
||||
createdAt: '2026-02-03T13:00:00Z',
|
||||
lastActivity: '2026-02-03T14:30:00Z',
|
||||
checkpointCount: 8,
|
||||
messagesProcessed: 34,
|
||||
currentTask: null,
|
||||
avgResponseTime: 278
|
||||
},
|
||||
{
|
||||
id: 'session-007',
|
||||
agentType: 'grader-agent',
|
||||
agentId: 'grader-2',
|
||||
userId: 'user-202',
|
||||
userName: 'Herr Weber (Lehrer)',
|
||||
state: 'completed',
|
||||
createdAt: '2026-02-03T10:00:00Z',
|
||||
lastActivity: '2026-02-03T12:00:00Z',
|
||||
checkpointCount: 24,
|
||||
messagesProcessed: 120,
|
||||
currentTask: null,
|
||||
avgResponseTime: 1102
|
||||
},
|
||||
{
|
||||
id: 'session-008',
|
||||
agentType: 'alert-agent',
|
||||
agentId: 'alert-1',
|
||||
userId: 'system',
|
||||
userName: 'System (Monitoring)',
|
||||
state: 'active',
|
||||
createdAt: '2026-02-03T00:00:00Z',
|
||||
lastActivity: '2026-02-03T15:45:28Z',
|
||||
checkpointCount: 48,
|
||||
messagesProcessed: 256,
|
||||
currentTask: 'Monitoring System Health',
|
||||
avgResponseTime: 45
|
||||
}
|
||||
]
|
||||
|
||||
function getAgentIcon(agentType: string) {
|
||||
switch (agentType) {
|
||||
case 'tutor-agent': return <Brain className="w-4 h-4" />
|
||||
case 'grader-agent': return <Bot className="w-4 h-4" />
|
||||
case 'quality-judge': return <Settings className="w-4 h-4" />
|
||||
case 'alert-agent': return <AlertTriangle className="w-4 h-4" />
|
||||
case 'orchestrator': return <MessageSquare className="w-4 h-4" />
|
||||
default: return <Bot className="w-4 h-4" />
|
||||
}
|
||||
}
|
||||
|
||||
function getAgentColor(agentType: string) {
|
||||
switch (agentType) {
|
||||
case 'tutor-agent': return { bg: 'bg-blue-100', text: 'text-blue-600', border: 'border-blue-200' }
|
||||
case 'grader-agent': return { bg: 'bg-green-100', text: 'text-green-600', border: 'border-green-200' }
|
||||
case 'quality-judge': return { bg: 'bg-amber-100', text: 'text-amber-600', border: 'border-amber-200' }
|
||||
case 'alert-agent': return { bg: 'bg-red-100', text: 'text-red-600', border: 'border-red-200' }
|
||||
case 'orchestrator': return { bg: 'bg-purple-100', text: 'text-purple-600', border: 'border-purple-200' }
|
||||
default: return { bg: 'bg-gray-100', text: 'text-gray-600', border: 'border-gray-200' }
|
||||
}
|
||||
}
|
||||
|
||||
function getStateConfig(state: string) {
|
||||
switch (state) {
|
||||
case 'active':
|
||||
return { icon: <CheckCircle className="w-4 h-4" />, color: 'bg-green-100 text-green-700 border-green-200', label: 'Aktiv' }
|
||||
case 'paused':
|
||||
return { icon: <Pause className="w-4 h-4" />, color: 'bg-yellow-100 text-yellow-700 border-yellow-200', label: 'Pausiert' }
|
||||
case 'completed':
|
||||
return { icon: <CheckCircle className="w-4 h-4" />, color: 'bg-gray-100 text-gray-600 border-gray-200', label: 'Beendet' }
|
||||
case 'failed':
|
||||
return { icon: <XCircle className="w-4 h-4" />, color: 'bg-red-100 text-red-700 border-red-200', label: 'Fehlgeschlagen' }
|
||||
default:
|
||||
return { icon: null, color: 'bg-gray-100 text-gray-600 border-gray-200', label: state }
|
||||
}
|
||||
}
|
||||
|
||||
function formatDuration(isoDate: string): string {
|
||||
const date = new Date(isoDate)
|
||||
const now = new Date()
|
||||
const diffMs = now.getTime() - date.getTime()
|
||||
const diffMins = Math.floor(diffMs / 60000)
|
||||
const diffHours = Math.floor(diffMins / 60)
|
||||
const diffDays = Math.floor(diffHours / 24)
|
||||
|
||||
if (diffDays > 0) return `${diffDays}d ${diffHours % 24}h`
|
||||
if (diffHours > 0) return `${diffHours}h ${diffMins % 60}m`
|
||||
return `${diffMins}m`
|
||||
}
|
||||
|
||||
function formatTime(isoDate: string): string {
|
||||
return new Date(isoDate).toLocaleTimeString('de-DE', {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit'
|
||||
})
|
||||
}
|
||||
|
||||
export default function SessionsPage() {
|
||||
const [sessions, setSessions] = useState<AgentSession[]>(mockSessions)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [filter, setFilter] = useState<string>('all')
|
||||
const [searchTerm, setSearchTerm] = useState('')
|
||||
const [lastRefresh, setLastRefresh] = useState(new Date())
|
||||
|
||||
const refreshData = async () => {
|
||||
setLoading(true)
|
||||
// In production, fetch from API
|
||||
await new Promise(resolve => setTimeout(resolve, 500))
|
||||
setLastRefresh(new Date())
|
||||
setLoading(false)
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
const interval = setInterval(refreshData, 10000) // Refresh every 10s
|
||||
return () => clearInterval(interval)
|
||||
}, [])
|
||||
|
||||
// Filter sessions
|
||||
const filteredSessions = sessions.filter(session => {
|
||||
if (filter !== 'all' && session.state !== filter) return false
|
||||
if (searchTerm) {
|
||||
const search = searchTerm.toLowerCase()
|
||||
return (
|
||||
session.userName.toLowerCase().includes(search) ||
|
||||
session.agentType.toLowerCase().includes(search) ||
|
||||
session.currentTask?.toLowerCase().includes(search) ||
|
||||
session.id.toLowerCase().includes(search)
|
||||
)
|
||||
}
|
||||
return true
|
||||
})
|
||||
|
||||
// Stats
|
||||
const stats = {
|
||||
total: sessions.length,
|
||||
active: sessions.filter(s => s.state === 'active').length,
|
||||
paused: sessions.filter(s => s.state === 'paused').length,
|
||||
completed: sessions.filter(s => s.state === 'completed').length,
|
||||
failed: sessions.filter(s => s.state === 'failed').length,
|
||||
totalMessages: sessions.reduce((sum, s) => sum + s.messagesProcessed, 0),
|
||||
avgResponseTime: Math.round(sessions.reduce((sum, s) => sum + s.avgResponseTime, 0) / sessions.length)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-6 max-w-7xl mx-auto">
|
||||
{/* Header */}
|
||||
<div className="mb-8">
|
||||
<Link
|
||||
href="/ai/agents"
|
||||
className="flex items-center gap-2 text-gray-500 hover:text-gray-700 mb-4"
|
||||
>
|
||||
<ArrowLeft className="w-4 h-4" />
|
||||
Zurueck zur Agent-Verwaltung
|
||||
</Link>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900 flex items-center gap-3">
|
||||
<div className="p-2 bg-blue-100 rounded-lg">
|
||||
<Activity className="w-6 h-6 text-blue-600" />
|
||||
</div>
|
||||
Aktive Sessions
|
||||
</h1>
|
||||
<p className="text-gray-500 mt-1">
|
||||
Live-Uebersicht aller Agent-Sessions im System
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-sm text-gray-500">
|
||||
Letzte Aktualisierung: {lastRefresh.toLocaleTimeString('de-DE')}
|
||||
</span>
|
||||
<button
|
||||
onClick={refreshData}
|
||||
disabled={loading}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors disabled:opacity-50"
|
||||
>
|
||||
<RefreshCw className={`w-4 h-4 ${loading ? 'animate-spin' : ''}`} />
|
||||
Aktualisieren
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Stats */}
|
||||
<div className="grid grid-cols-2 md:grid-cols-6 gap-4 mb-6">
|
||||
<div className="bg-white border border-gray-200 rounded-xl p-4">
|
||||
<div className="text-sm text-gray-500 mb-1">Gesamt</div>
|
||||
<div className="text-2xl font-bold text-gray-900">{stats.total}</div>
|
||||
</div>
|
||||
<div className="bg-white border border-green-200 rounded-xl p-4">
|
||||
<div className="text-sm text-green-600 mb-1">Aktiv</div>
|
||||
<div className="text-2xl font-bold text-green-600">{stats.active}</div>
|
||||
</div>
|
||||
<div className="bg-white border border-yellow-200 rounded-xl p-4">
|
||||
<div className="text-sm text-yellow-600 mb-1">Pausiert</div>
|
||||
<div className="text-2xl font-bold text-yellow-600">{stats.paused}</div>
|
||||
</div>
|
||||
<div className="bg-white border border-gray-200 rounded-xl p-4">
|
||||
<div className="text-sm text-gray-500 mb-1">Beendet</div>
|
||||
<div className="text-2xl font-bold text-gray-600">{stats.completed}</div>
|
||||
</div>
|
||||
<div className="bg-white border border-gray-200 rounded-xl p-4">
|
||||
<div className="text-sm text-gray-500 mb-1">Messages (24h)</div>
|
||||
<div className="text-2xl font-bold text-gray-900">{stats.totalMessages.toLocaleString()}</div>
|
||||
</div>
|
||||
<div className="bg-white border border-gray-200 rounded-xl p-4">
|
||||
<div className="text-sm text-gray-500 mb-1">Avg. Response</div>
|
||||
<div className="text-2xl font-bold text-gray-900">{stats.avgResponseTime}ms</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Filters */}
|
||||
<div className="flex flex-col md:flex-row gap-4 mb-6">
|
||||
<div className="relative flex-1">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Session, Benutzer oder Task suchen..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
className="w-full pl-10 pr-4 py-2 border border-gray-200 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Filter className="w-4 h-4 text-gray-400" />
|
||||
<select
|
||||
value={filter}
|
||||
onChange={(e) => setFilter(e.target.value)}
|
||||
className="px-4 py-2 border border-gray-200 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
>
|
||||
<option value="all">Alle Status</option>
|
||||
<option value="active">Aktiv</option>
|
||||
<option value="paused">Pausiert</option>
|
||||
<option value="completed">Beendet</option>
|
||||
<option value="failed">Fehlgeschlagen</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Sessions List */}
|
||||
<div className="bg-white border border-gray-200 rounded-xl overflow-hidden">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="min-w-full divide-y divide-gray-200">
|
||||
<thead className="bg-gray-50">
|
||||
<tr>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Agent</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Benutzer</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Status</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Aktueller Task</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Dauer</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Messages</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Letzte Aktivitaet</th>
|
||||
<th className="px-4 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="bg-white divide-y divide-gray-200">
|
||||
{filteredSessions.map(session => {
|
||||
const agentColor = getAgentColor(session.agentType)
|
||||
const stateConfig = getStateConfig(session.state)
|
||||
|
||||
return (
|
||||
<tr key={session.id} className="hover:bg-gray-50">
|
||||
<td className="px-4 py-4 whitespace-nowrap">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className={`p-2 rounded-lg ${agentColor.bg}`}>
|
||||
<span className={agentColor.text}>{getAgentIcon(session.agentType)}</span>
|
||||
</div>
|
||||
<div>
|
||||
<div className="font-medium text-gray-900">{session.agentId}</div>
|
||||
<div className="text-xs text-gray-500">{session.agentType}</div>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-4 py-4 whitespace-nowrap">
|
||||
<div className="flex items-center gap-2">
|
||||
<User className="w-4 h-4 text-gray-400" />
|
||||
<span className="text-sm text-gray-900">{session.userName}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-4 py-4 whitespace-nowrap">
|
||||
<span className={`inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs font-medium border ${stateConfig.color}`}>
|
||||
{stateConfig.icon}
|
||||
{stateConfig.label}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-4">
|
||||
{session.currentTask ? (
|
||||
<div className="flex items-center gap-2 max-w-xs">
|
||||
<Zap className="w-3.5 h-3.5 text-amber-500 flex-shrink-0" />
|
||||
<span className="text-sm text-gray-700 truncate">{session.currentTask}</span>
|
||||
</div>
|
||||
) : (
|
||||
<span className="text-sm text-gray-400">-</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-4 whitespace-nowrap">
|
||||
<div className="flex items-center gap-1.5 text-sm text-gray-600">
|
||||
<Clock className="w-3.5 h-3.5" />
|
||||
{formatDuration(session.createdAt)}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-4 py-4 whitespace-nowrap">
|
||||
<div className="text-sm">
|
||||
<span className="font-medium text-gray-900">{session.messagesProcessed}</span>
|
||||
<span className="text-gray-500 ml-1">({session.checkpointCount} CP)</span>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-4 py-4 whitespace-nowrap">
|
||||
<div className="text-sm text-gray-600">{formatTime(session.lastActivity)}</div>
|
||||
<div className="text-xs text-gray-400">{session.avgResponseTime}ms avg</div>
|
||||
</td>
|
||||
<td className="px-4 py-4 whitespace-nowrap text-right">
|
||||
<Link
|
||||
href={`/ai/agents/${session.agentType.replace('-agent', '-agent')}`}
|
||||
className="p-2 hover:bg-gray-100 rounded-lg inline-flex items-center gap-1 text-sm text-gray-600 hover:text-gray-900"
|
||||
>
|
||||
Details
|
||||
<ChevronRight className="w-4 h-4" />
|
||||
</Link>
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{filteredSessions.length === 0 && (
|
||||
<div className="text-center py-12">
|
||||
<Activity className="w-12 h-12 text-gray-300 mx-auto mb-3" />
|
||||
<p className="text-gray-500">Keine Sessions gefunden</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Live Activity Indicator */}
|
||||
<div className="mt-6 flex items-center justify-center gap-2 text-sm text-gray-500">
|
||||
<span className="relative flex h-2 w-2">
|
||||
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-green-400 opacity-75"></span>
|
||||
<span className="relative inline-flex rounded-full h-2 w-2 bg-green-500"></span>
|
||||
</span>
|
||||
Live-Daten - Auto-Refresh alle 10 Sekunden
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,491 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect } from 'react'
|
||||
import Link from 'next/link'
|
||||
import { ArrowLeft, BarChart3, TrendingUp, TrendingDown, Clock, Activity, Bot, Brain, MessageSquare, AlertTriangle, Settings, RefreshCw, Calendar, Filter, Download } from 'lucide-react'
|
||||
|
||||
// Types
|
||||
interface AgentMetric {
|
||||
agentType: string
|
||||
name: string
|
||||
color: string
|
||||
sessions: number
|
||||
messagesProcessed: number
|
||||
avgResponseTime: number
|
||||
errorRate: number
|
||||
successRate: number
|
||||
trend: 'up' | 'down' | 'stable'
|
||||
trendValue: number
|
||||
}
|
||||
|
||||
interface TimeSeriesData {
|
||||
timestamp: string
|
||||
value: number
|
||||
}
|
||||
|
||||
interface DailyStats {
|
||||
date: string
|
||||
sessions: number
|
||||
messages: number
|
||||
errors: number
|
||||
avgLatency: number
|
||||
}
|
||||
|
||||
// Mock data
|
||||
const mockAgentMetrics: AgentMetric[] = [
|
||||
{
|
||||
agentType: 'tutor-agent',
|
||||
name: 'TutorAgent',
|
||||
color: '#3b82f6',
|
||||
sessions: 156,
|
||||
messagesProcessed: 4521,
|
||||
avgResponseTime: 234,
|
||||
errorRate: 0.3,
|
||||
successRate: 99.7,
|
||||
trend: 'up',
|
||||
trendValue: 12
|
||||
},
|
||||
{
|
||||
agentType: 'grader-agent',
|
||||
name: 'GraderAgent',
|
||||
color: '#10b981',
|
||||
sessions: 45,
|
||||
messagesProcessed: 1205,
|
||||
avgResponseTime: 1102,
|
||||
errorRate: 0.5,
|
||||
successRate: 99.5,
|
||||
trend: 'stable',
|
||||
trendValue: 2
|
||||
},
|
||||
{
|
||||
agentType: 'quality-judge',
|
||||
name: 'QualityJudge',
|
||||
color: '#f59e0b',
|
||||
sessions: 89,
|
||||
messagesProcessed: 8934,
|
||||
avgResponseTime: 89,
|
||||
errorRate: 0.1,
|
||||
successRate: 99.9,
|
||||
trend: 'up',
|
||||
trendValue: 8
|
||||
},
|
||||
{
|
||||
agentType: 'alert-agent',
|
||||
name: 'AlertAgent',
|
||||
color: '#ef4444',
|
||||
sessions: 12,
|
||||
messagesProcessed: 892,
|
||||
avgResponseTime: 45,
|
||||
errorRate: 0.0,
|
||||
successRate: 100,
|
||||
trend: 'stable',
|
||||
trendValue: 0
|
||||
},
|
||||
{
|
||||
agentType: 'orchestrator',
|
||||
name: 'Orchestrator',
|
||||
color: '#8b5cf6',
|
||||
sessions: 234,
|
||||
messagesProcessed: 15420,
|
||||
avgResponseTime: 12,
|
||||
errorRate: 0.2,
|
||||
successRate: 99.8,
|
||||
trend: 'up',
|
||||
trendValue: 15
|
||||
}
|
||||
]
|
||||
|
||||
const mockDailyStats: DailyStats[] = [
|
||||
{ date: '2026-01-28', sessions: 420, messages: 12500, errors: 15, avgLatency: 156 },
|
||||
{ date: '2026-01-29', sessions: 445, messages: 13200, errors: 12, avgLatency: 148 },
|
||||
{ date: '2026-01-30', sessions: 398, messages: 11800, errors: 18, avgLatency: 162 },
|
||||
{ date: '2026-01-31', sessions: 512, messages: 15600, errors: 10, avgLatency: 145 },
|
||||
{ date: '2026-02-01', sessions: 489, messages: 14200, errors: 8, avgLatency: 139 },
|
||||
{ date: '2026-02-02', sessions: 534, messages: 16100, errors: 11, avgLatency: 142 },
|
||||
{ date: '2026-02-03', sessions: 478, messages: 14800, errors: 9, avgLatency: 151 }
|
||||
]
|
||||
|
||||
const mockHourlyLatency: TimeSeriesData[] = Array.from({ length: 24 }, (_, i) => ({
|
||||
timestamp: `${i.toString().padStart(2, '0')}:00`,
|
||||
value: Math.floor(100 + Math.random() * 100)
|
||||
}))
|
||||
|
||||
function getAgentIcon(agentType: string) {
|
||||
switch (agentType) {
|
||||
case 'tutor-agent': return <Brain className="w-4 h-4" />
|
||||
case 'grader-agent': return <Bot className="w-4 h-4" />
|
||||
case 'quality-judge': return <Settings className="w-4 h-4" />
|
||||
case 'alert-agent': return <AlertTriangle className="w-4 h-4" />
|
||||
case 'orchestrator': return <MessageSquare className="w-4 h-4" />
|
||||
default: return <Bot className="w-4 h-4" />
|
||||
}
|
||||
}
|
||||
|
||||
// Simple bar chart component
|
||||
function BarChart({ data, color, maxValue }: { data: number[], color: string, maxValue: number }) {
|
||||
return (
|
||||
<div className="flex items-end gap-1 h-20">
|
||||
{data.map((value, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="flex-1 rounded-t transition-all hover:opacity-80"
|
||||
style={{
|
||||
height: `${(value / maxValue) * 100}%`,
|
||||
backgroundColor: color,
|
||||
minHeight: '4px'
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Simple line chart visualization
|
||||
function SparkLine({ data, color }: { data: number[], color: string }) {
|
||||
const max = Math.max(...data)
|
||||
const min = Math.min(...data)
|
||||
const range = max - min || 1
|
||||
|
||||
const points = data.map((value, i) => {
|
||||
const x = (i / (data.length - 1)) * 100
|
||||
const y = 100 - ((value - min) / range) * 100
|
||||
return `${x},${y}`
|
||||
}).join(' ')
|
||||
|
||||
return (
|
||||
<svg viewBox="0 0 100 100" className="w-full h-12" preserveAspectRatio="none">
|
||||
<polyline
|
||||
fill="none"
|
||||
stroke={color}
|
||||
strokeWidth="2"
|
||||
points={points}
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
export default function StatisticsPage() {
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [timeRange, setTimeRange] = useState<'24h' | '7d' | '30d'>('7d')
|
||||
const [lastRefresh, setLastRefresh] = useState(new Date())
|
||||
|
||||
const refreshData = async () => {
|
||||
setLoading(true)
|
||||
await new Promise(resolve => setTimeout(resolve, 500))
|
||||
setLastRefresh(new Date())
|
||||
setLoading(false)
|
||||
}
|
||||
|
||||
// Calculate totals
|
||||
const totals = {
|
||||
sessions: mockAgentMetrics.reduce((sum, m) => sum + m.sessions, 0),
|
||||
messages: mockAgentMetrics.reduce((sum, m) => sum + m.messagesProcessed, 0),
|
||||
avgLatency: Math.round(mockAgentMetrics.reduce((sum, m) => sum + m.avgResponseTime, 0) / mockAgentMetrics.length),
|
||||
avgErrorRate: (mockAgentMetrics.reduce((sum, m) => sum + m.errorRate, 0) / mockAgentMetrics.length).toFixed(2)
|
||||
}
|
||||
|
||||
// Calculate week stats
|
||||
const weekTotals = {
|
||||
sessions: mockDailyStats.reduce((sum, d) => sum + d.sessions, 0),
|
||||
messages: mockDailyStats.reduce((sum, d) => sum + d.messages, 0),
|
||||
errors: mockDailyStats.reduce((sum, d) => sum + d.errors, 0),
|
||||
avgLatency: Math.round(mockDailyStats.reduce((sum, d) => sum + d.avgLatency, 0) / mockDailyStats.length)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-6 max-w-7xl mx-auto">
|
||||
{/* Header */}
|
||||
<div className="mb-8">
|
||||
<Link
|
||||
href="/ai/agents"
|
||||
className="flex items-center gap-2 text-gray-500 hover:text-gray-700 mb-4"
|
||||
>
|
||||
<ArrowLeft className="w-4 h-4" />
|
||||
Zurueck zur Agent-Verwaltung
|
||||
</Link>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900 flex items-center gap-3">
|
||||
<div className="p-2 bg-green-100 rounded-lg">
|
||||
<BarChart3 className="w-6 h-6 text-green-600" />
|
||||
</div>
|
||||
Agent Statistiken
|
||||
</h1>
|
||||
<p className="text-gray-500 mt-1">
|
||||
Performance-Metriken und Trends des Multi-Agent-Systems
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<select
|
||||
value={timeRange}
|
||||
onChange={(e) => setTimeRange(e.target.value as '24h' | '7d' | '30d')}
|
||||
className="px-4 py-2 border border-gray-200 rounded-lg focus:outline-none focus:ring-2 focus:ring-green-500"
|
||||
>
|
||||
<option value="24h">Letzte 24 Stunden</option>
|
||||
<option value="7d">Letzte 7 Tage</option>
|
||||
<option value="30d">Letzte 30 Tage</option>
|
||||
</select>
|
||||
<button
|
||||
onClick={refreshData}
|
||||
disabled={loading}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-green-600 text-white rounded-lg hover:bg-green-700 transition-colors disabled:opacity-50"
|
||||
>
|
||||
<RefreshCw className={`w-4 h-4 ${loading ? 'animate-spin' : ''}`} />
|
||||
Aktualisieren
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Overview Stats */}
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 mb-8">
|
||||
<div className="bg-white border border-gray-200 rounded-xl p-5">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="text-sm text-gray-500">Sessions (7d)</span>
|
||||
<Activity className="w-4 h-4 text-gray-400" />
|
||||
</div>
|
||||
<div className="text-3xl font-bold text-gray-900">{weekTotals.sessions.toLocaleString()}</div>
|
||||
<div className="flex items-center gap-1 mt-1 text-sm text-green-600">
|
||||
<TrendingUp className="w-3.5 h-3.5" />
|
||||
<span>+12% vs. Vorwoche</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-white border border-gray-200 rounded-xl p-5">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="text-sm text-gray-500">Messages (7d)</span>
|
||||
<MessageSquare className="w-4 h-4 text-gray-400" />
|
||||
</div>
|
||||
<div className="text-3xl font-bold text-gray-900">{weekTotals.messages.toLocaleString()}</div>
|
||||
<div className="flex items-center gap-1 mt-1 text-sm text-green-600">
|
||||
<TrendingUp className="w-3.5 h-3.5" />
|
||||
<span>+8% vs. Vorwoche</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-white border border-gray-200 rounded-xl p-5">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="text-sm text-gray-500">Avg. Latenz</span>
|
||||
<Clock className="w-4 h-4 text-gray-400" />
|
||||
</div>
|
||||
<div className="text-3xl font-bold text-gray-900">{weekTotals.avgLatency}ms</div>
|
||||
<div className="flex items-center gap-1 mt-1 text-sm text-green-600">
|
||||
<TrendingDown className="w-3.5 h-3.5" />
|
||||
<span>-5% (verbessert)</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-white border border-gray-200 rounded-xl p-5">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="text-sm text-gray-500">Fehler (7d)</span>
|
||||
<AlertTriangle className="w-4 h-4 text-gray-400" />
|
||||
</div>
|
||||
<div className="text-3xl font-bold text-gray-900">{weekTotals.errors}</div>
|
||||
<div className="flex items-center gap-1 mt-1 text-sm text-amber-600">
|
||||
<TrendingUp className="w-3.5 h-3.5" />
|
||||
<span>+3 vs. Vorwoche</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Charts Row */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-8">
|
||||
{/* Sessions per Day */}
|
||||
<div className="bg-white border border-gray-200 rounded-xl p-5">
|
||||
<h3 className="font-semibold text-gray-900 mb-4">Sessions pro Tag</h3>
|
||||
<div className="space-y-3">
|
||||
<BarChart
|
||||
data={mockDailyStats.map(d => d.sessions)}
|
||||
color="#3b82f6"
|
||||
maxValue={Math.max(...mockDailyStats.map(d => d.sessions)) * 1.1}
|
||||
/>
|
||||
<div className="flex justify-between text-xs text-gray-500">
|
||||
{mockDailyStats.map(d => (
|
||||
<span key={d.date}>{new Date(d.date).toLocaleDateString('de-DE', { weekday: 'short' })}</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Messages per Day */}
|
||||
<div className="bg-white border border-gray-200 rounded-xl p-5">
|
||||
<h3 className="font-semibold text-gray-900 mb-4">Messages pro Tag</h3>
|
||||
<div className="space-y-3">
|
||||
<BarChart
|
||||
data={mockDailyStats.map(d => d.messages)}
|
||||
color="#10b981"
|
||||
maxValue={Math.max(...mockDailyStats.map(d => d.messages)) * 1.1}
|
||||
/>
|
||||
<div className="flex justify-between text-xs text-gray-500">
|
||||
{mockDailyStats.map(d => (
|
||||
<span key={d.date}>{new Date(d.date).toLocaleDateString('de-DE', { weekday: 'short' })}</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Latency Chart */}
|
||||
<div className="bg-white border border-gray-200 rounded-xl p-5 mb-8">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h3 className="font-semibold text-gray-900">Latenz (24h)</h3>
|
||||
<div className="flex items-center gap-2 text-sm text-gray-500">
|
||||
<Clock className="w-4 h-4" />
|
||||
Durchschnitt: {totals.avgLatency}ms
|
||||
</div>
|
||||
</div>
|
||||
<SparkLine
|
||||
data={mockHourlyLatency.map(d => d.value)}
|
||||
color="#8b5cf6"
|
||||
/>
|
||||
<div className="flex justify-between text-xs text-gray-400 mt-2">
|
||||
<span>00:00</span>
|
||||
<span>06:00</span>
|
||||
<span>12:00</span>
|
||||
<span>18:00</span>
|
||||
<span>24:00</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Agent Performance Table */}
|
||||
<div className="bg-white border border-gray-200 rounded-xl overflow-hidden mb-8">
|
||||
<div className="px-5 py-4 border-b border-gray-200">
|
||||
<h3 className="font-semibold text-gray-900">Agent Performance</h3>
|
||||
</div>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="min-w-full divide-y divide-gray-200">
|
||||
<thead className="bg-gray-50">
|
||||
<tr>
|
||||
<th className="px-5 py-3 text-left text-xs font-medium text-gray-500 uppercase">Agent</th>
|
||||
<th className="px-5 py-3 text-right text-xs font-medium text-gray-500 uppercase">Sessions</th>
|
||||
<th className="px-5 py-3 text-right text-xs font-medium text-gray-500 uppercase">Messages</th>
|
||||
<th className="px-5 py-3 text-right text-xs font-medium text-gray-500 uppercase">Avg. Response</th>
|
||||
<th className="px-5 py-3 text-right text-xs font-medium text-gray-500 uppercase">Success Rate</th>
|
||||
<th className="px-5 py-3 text-right text-xs font-medium text-gray-500 uppercase">Error Rate</th>
|
||||
<th className="px-5 py-3 text-center text-xs font-medium text-gray-500 uppercase">Trend</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-200">
|
||||
{mockAgentMetrics.map(metric => (
|
||||
<tr key={metric.agentType} className="hover:bg-gray-50">
|
||||
<td className="px-5 py-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div
|
||||
className="p-2 rounded-lg"
|
||||
style={{ backgroundColor: `${metric.color}20` }}
|
||||
>
|
||||
<span style={{ color: metric.color }}>{getAgentIcon(metric.agentType)}</span>
|
||||
</div>
|
||||
<div>
|
||||
<div className="font-medium text-gray-900">{metric.name}</div>
|
||||
<div className="text-xs text-gray-500">{metric.agentType}</div>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-5 py-4 text-right">
|
||||
<span className="font-medium text-gray-900">{metric.sessions}</span>
|
||||
</td>
|
||||
<td className="px-5 py-4 text-right">
|
||||
<span className="font-medium text-gray-900">{metric.messagesProcessed.toLocaleString()}</span>
|
||||
</td>
|
||||
<td className="px-5 py-4 text-right">
|
||||
<span className="text-gray-900">{metric.avgResponseTime}ms</span>
|
||||
</td>
|
||||
<td className="px-5 py-4 text-right">
|
||||
<span className="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-green-100 text-green-700">
|
||||
{metric.successRate}%
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-5 py-4 text-right">
|
||||
<span className={`inline-flex items-center px-2 py-0.5 rounded text-xs font-medium ${
|
||||
metric.errorRate > 0.5 ? 'bg-red-100 text-red-700' :
|
||||
metric.errorRate > 0 ? 'bg-amber-100 text-amber-700' :
|
||||
'bg-green-100 text-green-700'
|
||||
}`}>
|
||||
{metric.errorRate}%
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-5 py-4 text-center">
|
||||
{metric.trend === 'up' && (
|
||||
<span className="inline-flex items-center gap-1 text-green-600 text-sm">
|
||||
<TrendingUp className="w-4 h-4" />
|
||||
+{metric.trendValue}%
|
||||
</span>
|
||||
)}
|
||||
{metric.trend === 'down' && (
|
||||
<span className="inline-flex items-center gap-1 text-red-600 text-sm">
|
||||
<TrendingDown className="w-4 h-4" />
|
||||
-{metric.trendValue}%
|
||||
</span>
|
||||
)}
|
||||
{metric.trend === 'stable' && (
|
||||
<span className="inline-flex items-center gap-1 text-gray-500 text-sm">
|
||||
<span className="w-4 h-0.5 bg-gray-400 rounded" />
|
||||
{metric.trendValue}%
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Error Distribution */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
{/* Error by Agent */}
|
||||
<div className="bg-white border border-gray-200 rounded-xl p-5">
|
||||
<h3 className="font-semibold text-gray-900 mb-4">Fehlerverteilung nach Agent</h3>
|
||||
<div className="space-y-3">
|
||||
{mockAgentMetrics.filter(m => m.errorRate > 0).map(metric => (
|
||||
<div key={metric.agentType} className="flex items-center gap-3">
|
||||
<div className="w-24 text-sm text-gray-600 truncate">{metric.name}</div>
|
||||
<div className="flex-1 h-4 bg-gray-100 rounded-full overflow-hidden">
|
||||
<div
|
||||
className="h-full rounded-full"
|
||||
style={{
|
||||
width: `${metric.errorRate * 20}%`,
|
||||
backgroundColor: metric.color
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="w-12 text-right text-sm text-gray-600">{metric.errorRate}%</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Message Distribution */}
|
||||
<div className="bg-white border border-gray-200 rounded-xl p-5">
|
||||
<h3 className="font-semibold text-gray-900 mb-4">Message-Verteilung nach Agent</h3>
|
||||
<div className="space-y-3">
|
||||
{mockAgentMetrics.map(metric => {
|
||||
const percentage = (metric.messagesProcessed / totals.messages) * 100
|
||||
return (
|
||||
<div key={metric.agentType} className="flex items-center gap-3">
|
||||
<div className="w-24 text-sm text-gray-600 truncate">{metric.name}</div>
|
||||
<div className="flex-1 h-4 bg-gray-100 rounded-full overflow-hidden">
|
||||
<div
|
||||
className="h-full rounded-full"
|
||||
style={{
|
||||
width: `${percentage}%`,
|
||||
backgroundColor: metric.color
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="w-12 text-right text-sm text-gray-600">{percentage.toFixed(1)}%</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Export Button */}
|
||||
<div className="mt-8 flex justify-end">
|
||||
<button className="flex items-center gap-2 px-4 py-2 border border-gray-200 rounded-lg hover:bg-gray-50 text-gray-600 hover:text-gray-900 transition-colors">
|
||||
<Download className="w-4 h-4" />
|
||||
Statistiken exportieren (CSV)
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,396 @@
|
||||
'use client'
|
||||
|
||||
/**
|
||||
* GPU Infrastructure Admin Page
|
||||
*
|
||||
* vast.ai GPU Management for LLM Processing
|
||||
* Part of KI-Werkzeuge
|
||||
*/
|
||||
|
||||
import { useEffect, useState, useCallback } from 'react'
|
||||
import { PagePurpose } from '@/components/common/PagePurpose'
|
||||
import { AIToolsSidebarResponsive } from '@/components/ai/AIToolsSidebar'
|
||||
|
||||
interface VastStatus {
|
||||
instance_id: number | null
|
||||
status: string
|
||||
gpu_name: string | null
|
||||
dph_total: number | null
|
||||
endpoint_base_url: string | null
|
||||
last_activity: string | null
|
||||
auto_shutdown_in_minutes: number | null
|
||||
total_runtime_hours: number | null
|
||||
total_cost_usd: number | null
|
||||
account_credit: number | null
|
||||
account_total_spend: number | null
|
||||
session_runtime_minutes: number | null
|
||||
session_cost_usd: number | null
|
||||
message: string | null
|
||||
error?: string
|
||||
}
|
||||
|
||||
export default function GPUInfrastructurePage() {
|
||||
const [status, setStatus] = useState<VastStatus | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [actionLoading, setActionLoading] = useState<string | null>(null)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [message, setMessage] = useState<string | null>(null)
|
||||
|
||||
const API_PROXY = '/api/admin/gpu'
|
||||
|
||||
const fetchStatus = useCallback(async () => {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
|
||||
try {
|
||||
const response = await fetch(API_PROXY)
|
||||
const data = await response.json()
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(data.error || `HTTP ${response.status}`)
|
||||
}
|
||||
|
||||
setStatus(data)
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Verbindungsfehler')
|
||||
setStatus({
|
||||
instance_id: null,
|
||||
status: 'error',
|
||||
gpu_name: null,
|
||||
dph_total: null,
|
||||
endpoint_base_url: null,
|
||||
last_activity: null,
|
||||
auto_shutdown_in_minutes: null,
|
||||
total_runtime_hours: null,
|
||||
total_cost_usd: null,
|
||||
account_credit: null,
|
||||
account_total_spend: null,
|
||||
session_runtime_minutes: null,
|
||||
session_cost_usd: null,
|
||||
message: 'Verbindung fehlgeschlagen'
|
||||
})
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
fetchStatus()
|
||||
}, [fetchStatus])
|
||||
|
||||
useEffect(() => {
|
||||
const interval = setInterval(fetchStatus, 30000)
|
||||
return () => clearInterval(interval)
|
||||
}, [fetchStatus])
|
||||
|
||||
const powerOn = async () => {
|
||||
setActionLoading('on')
|
||||
setError(null)
|
||||
setMessage(null)
|
||||
|
||||
try {
|
||||
const response = await fetch(API_PROXY, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ action: 'on' }),
|
||||
})
|
||||
|
||||
const data = await response.json()
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(data.error || data.detail || 'Aktion fehlgeschlagen')
|
||||
}
|
||||
|
||||
setMessage('Start angefordert')
|
||||
setTimeout(fetchStatus, 3000)
|
||||
setTimeout(fetchStatus, 10000)
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Fehler beim Starten')
|
||||
fetchStatus()
|
||||
} finally {
|
||||
setActionLoading(null)
|
||||
}
|
||||
}
|
||||
|
||||
const powerOff = async () => {
|
||||
setActionLoading('off')
|
||||
setError(null)
|
||||
setMessage(null)
|
||||
|
||||
try {
|
||||
const response = await fetch(API_PROXY, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ action: 'off' }),
|
||||
})
|
||||
|
||||
const data = await response.json()
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(data.error || data.detail || 'Aktion fehlgeschlagen')
|
||||
}
|
||||
|
||||
setMessage('Stop angefordert')
|
||||
setTimeout(fetchStatus, 3000)
|
||||
setTimeout(fetchStatus, 10000)
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Fehler beim Stoppen')
|
||||
fetchStatus()
|
||||
} finally {
|
||||
setActionLoading(null)
|
||||
}
|
||||
}
|
||||
|
||||
const getStatusBadge = (s: string) => {
|
||||
const baseClasses = 'px-3 py-1 rounded-full text-sm font-semibold uppercase'
|
||||
switch (s) {
|
||||
case 'running':
|
||||
return `${baseClasses} bg-green-100 text-green-800`
|
||||
case 'stopped':
|
||||
case 'exited':
|
||||
return `${baseClasses} bg-red-100 text-red-800`
|
||||
case 'loading':
|
||||
case 'scheduling':
|
||||
case 'creating':
|
||||
case 'starting...':
|
||||
case 'stopping...':
|
||||
return `${baseClasses} bg-yellow-100 text-yellow-800`
|
||||
default:
|
||||
return `${baseClasses} bg-slate-100 text-slate-600`
|
||||
}
|
||||
}
|
||||
|
||||
const getCreditColor = (credit: number | null) => {
|
||||
if (credit === null) return 'text-slate-500'
|
||||
if (credit < 5) return 'text-red-600'
|
||||
if (credit < 15) return 'text-yellow-600'
|
||||
return 'text-green-600'
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* Page Purpose */}
|
||||
<PagePurpose
|
||||
title="GPU Infrastruktur"
|
||||
purpose="Verwalten Sie die vast.ai GPU-Instanzen fuer LLM-Verarbeitung und OCR. Starten/Stoppen Sie GPUs bei Bedarf und ueberwachen Sie Kosten in Echtzeit."
|
||||
audience={['DevOps', 'Entwickler', 'System-Admins']}
|
||||
architecture={{
|
||||
services: ['vast.ai API', 'Ollama', 'VLLM'],
|
||||
databases: ['PostgreSQL (Logs)'],
|
||||
}}
|
||||
relatedPages={[
|
||||
{ name: 'LLM Vergleich', href: '/ai/llm-compare', description: 'KI-Provider testen' },
|
||||
{ name: 'Test Quality (BQAS)', href: '/ai/test-quality', description: 'Golden Suite & Tests' },
|
||||
{ name: 'Magic Help', href: '/ai/magic-help', description: 'TrOCR Testing' },
|
||||
]}
|
||||
collapsible={true}
|
||||
defaultCollapsed={true}
|
||||
/>
|
||||
|
||||
{/* KI-Werkzeuge Sidebar */}
|
||||
<AIToolsSidebarResponsive currentTool="gpu" />
|
||||
|
||||
{/* Status Cards */}
|
||||
<div className="bg-white rounded-xl border border-slate-200 p-6 mb-6">
|
||||
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-6 gap-6">
|
||||
<div>
|
||||
<div className="text-sm text-slate-500 mb-2">Status</div>
|
||||
{loading ? (
|
||||
<span className="px-3 py-1 rounded-full text-sm font-semibold bg-slate-100 text-slate-600">
|
||||
Laden...
|
||||
</span>
|
||||
) : (
|
||||
<span className={getStatusBadge(
|
||||
actionLoading === 'on' ? 'starting...' :
|
||||
actionLoading === 'off' ? 'stopping...' :
|
||||
status?.status || 'unknown'
|
||||
)}>
|
||||
{actionLoading === 'on' ? 'starting...' :
|
||||
actionLoading === 'off' ? 'stopping...' :
|
||||
status?.status || 'unbekannt'}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="text-sm text-slate-500 mb-2">GPU</div>
|
||||
<div className="font-semibold text-slate-900">
|
||||
{status?.gpu_name || '-'}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="text-sm text-slate-500 mb-2">Kosten/h</div>
|
||||
<div className="font-semibold text-slate-900">
|
||||
{status?.dph_total ? `$${status.dph_total.toFixed(3)}` : '-'}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="text-sm text-slate-500 mb-2">Auto-Stop</div>
|
||||
<div className="font-semibold text-slate-900">
|
||||
{status && status.auto_shutdown_in_minutes !== null
|
||||
? `${status.auto_shutdown_in_minutes} min`
|
||||
: '-'}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="text-sm text-slate-500 mb-2">Budget</div>
|
||||
<div className={`font-bold text-lg ${getCreditColor(status?.account_credit ?? null)}`}>
|
||||
{status && status.account_credit !== null
|
||||
? `$${status.account_credit.toFixed(2)}`
|
||||
: '-'}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="text-sm text-slate-500 mb-2">Session</div>
|
||||
<div className="font-semibold text-slate-900">
|
||||
{status && status.session_runtime_minutes !== null && status.session_cost_usd !== null
|
||||
? `${Math.round(status.session_runtime_minutes)} min / $${status.session_cost_usd.toFixed(3)}`
|
||||
: '-'}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Buttons */}
|
||||
<div className="flex items-center gap-4 mt-6 pt-6 border-t border-slate-200">
|
||||
<button
|
||||
onClick={powerOn}
|
||||
disabled={actionLoading !== null || status?.status === 'running'}
|
||||
className="px-6 py-2 bg-orange-600 text-white rounded-lg font-medium hover:bg-orange-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
Starten
|
||||
</button>
|
||||
<button
|
||||
onClick={powerOff}
|
||||
disabled={actionLoading !== null || status?.status !== 'running'}
|
||||
className="px-6 py-2 bg-red-600 text-white rounded-lg font-medium hover:bg-red-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
Stoppen
|
||||
</button>
|
||||
<button
|
||||
onClick={fetchStatus}
|
||||
disabled={loading}
|
||||
className="px-4 py-2 border border-slate-300 text-slate-700 rounded-lg font-medium hover:bg-slate-50 disabled:opacity-50 transition-colors"
|
||||
>
|
||||
{loading ? 'Aktualisiere...' : 'Aktualisieren'}
|
||||
</button>
|
||||
|
||||
{message && (
|
||||
<span className="ml-4 text-sm text-green-600 font-medium">{message}</span>
|
||||
)}
|
||||
{error && (
|
||||
<span className="ml-4 text-sm text-red-600 font-medium">{error}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Extended Stats */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-6">
|
||||
<div className="bg-white rounded-xl border border-slate-200 p-6">
|
||||
<h3 className="font-semibold text-slate-900 mb-4">Kosten-Uebersicht</h3>
|
||||
<div className="space-y-4">
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-slate-600">Session Laufzeit</span>
|
||||
<span className="font-semibold">
|
||||
{status && status.session_runtime_minutes !== null
|
||||
? `${Math.round(status.session_runtime_minutes)} Minuten`
|
||||
: '-'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-slate-600">Session Kosten</span>
|
||||
<span className="font-semibold">
|
||||
{status && status.session_cost_usd !== null
|
||||
? `$${status.session_cost_usd.toFixed(4)}`
|
||||
: '-'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between items-center pt-4 border-t border-slate-100">
|
||||
<span className="text-slate-600">Gesamtlaufzeit</span>
|
||||
<span className="font-semibold">
|
||||
{status && status.total_runtime_hours !== null
|
||||
? `${status.total_runtime_hours.toFixed(1)} Stunden`
|
||||
: '-'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-slate-600">Gesamtkosten</span>
|
||||
<span className="font-semibold">
|
||||
{status && status.total_cost_usd !== null
|
||||
? `$${status.total_cost_usd.toFixed(2)}`
|
||||
: '-'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-slate-600">vast.ai Ausgaben</span>
|
||||
<span className="font-semibold">
|
||||
{status && status.account_total_spend !== null
|
||||
? `$${status.account_total_spend.toFixed(2)}`
|
||||
: '-'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-xl border border-slate-200 p-6">
|
||||
<h3 className="font-semibold text-slate-900 mb-4">Instanz-Details</h3>
|
||||
<div className="space-y-4">
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-slate-600">Instanz ID</span>
|
||||
<span className="font-mono text-sm">
|
||||
{status?.instance_id || '-'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-slate-600">GPU</span>
|
||||
<span className="font-semibold">
|
||||
{status?.gpu_name || '-'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-slate-600">Stundensatz</span>
|
||||
<span className="font-semibold">
|
||||
{status?.dph_total ? `$${status.dph_total.toFixed(4)}/h` : '-'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-slate-600">Letzte Aktivitaet</span>
|
||||
<span className="text-sm">
|
||||
{status?.last_activity
|
||||
? new Date(status.last_activity).toLocaleString('de-DE')
|
||||
: '-'}
|
||||
</span>
|
||||
</div>
|
||||
{status?.endpoint_base_url && status.status === 'running' && (
|
||||
<div className="pt-4 border-t border-slate-100">
|
||||
<div className="text-slate-600 text-sm mb-1">Endpoint</div>
|
||||
<code className="text-xs bg-slate-100 px-2 py-1 rounded block overflow-x-auto">
|
||||
{status.endpoint_base_url}
|
||||
</code>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Info */}
|
||||
<div className="bg-violet-50 border border-violet-200 rounded-xl p-4">
|
||||
<div className="flex gap-3">
|
||||
<svg className="w-5 h-5 text-violet-600 flex-shrink-0 mt-0.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
<div>
|
||||
<h4 className="font-semibold text-violet-900">Auto-Shutdown</h4>
|
||||
<p className="text-sm text-violet-800 mt-1">
|
||||
Die GPU-Instanz wird automatisch gestoppt, wenn sie laengere Zeit inaktiv ist.
|
||||
Der Status wird alle 30 Sekunden automatisch aktualisiert.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,503 @@
|
||||
'use client'
|
||||
|
||||
/**
|
||||
* LLM Comparison Tool
|
||||
*
|
||||
* Vergleicht Antworten von verschiedenen LLM-Providern:
|
||||
* - OpenAI/ChatGPT
|
||||
* - Claude
|
||||
* - Self-hosted + Tavily
|
||||
* - Self-hosted + EduSearch
|
||||
*/
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import { PagePurpose } from '@/components/common/PagePurpose'
|
||||
import { AIToolsSidebarResponsive } from '@/components/ai/AIToolsSidebar'
|
||||
|
||||
interface LLMResponse {
|
||||
provider: string
|
||||
model: string
|
||||
response: string
|
||||
latency_ms: number
|
||||
tokens_used?: number
|
||||
search_results?: Array<{
|
||||
title: string
|
||||
url: string
|
||||
content: string
|
||||
score?: number
|
||||
}>
|
||||
error?: string
|
||||
timestamp: string
|
||||
}
|
||||
|
||||
interface ComparisonResult {
|
||||
comparison_id: string
|
||||
prompt: string
|
||||
system_prompt?: string
|
||||
responses: LLMResponse[]
|
||||
created_at: string
|
||||
}
|
||||
|
||||
const providerColors: Record<string, { bg: string; border: string; text: string }> = {
|
||||
openai: { bg: 'bg-emerald-50', border: 'border-emerald-300', text: 'text-emerald-700' },
|
||||
claude: { bg: 'bg-orange-50', border: 'border-orange-300', text: 'text-orange-700' },
|
||||
selfhosted_tavily: { bg: 'bg-blue-50', border: 'border-blue-300', text: 'text-blue-700' },
|
||||
selfhosted_edusearch: { bg: 'bg-purple-50', border: 'border-purple-300', text: 'text-purple-700' },
|
||||
}
|
||||
|
||||
const providerLabels: Record<string, string> = {
|
||||
openai: 'OpenAI GPT-4o-mini',
|
||||
claude: 'Claude 3.5 Sonnet',
|
||||
selfhosted_tavily: 'Self-hosted + Tavily',
|
||||
selfhosted_edusearch: 'Self-hosted + EduSearch',
|
||||
}
|
||||
|
||||
export default function LLMComparePage() {
|
||||
// State
|
||||
const [prompt, setPrompt] = useState('')
|
||||
const [systemPrompt, setSystemPrompt] = useState('Du bist ein hilfreicher Assistent fuer Lehrkraefte in Deutschland.')
|
||||
|
||||
// Provider toggles
|
||||
const [enableOpenAI, setEnableOpenAI] = useState(true)
|
||||
const [enableClaude, setEnableClaude] = useState(true)
|
||||
const [enableTavily, setEnableTavily] = useState(true)
|
||||
const [enableEduSearch, setEnableEduSearch] = useState(true)
|
||||
|
||||
// Parameters
|
||||
const [model, setModel] = useState('llama3.2:3b')
|
||||
const [temperature, setTemperature] = useState(0.7)
|
||||
const [maxTokens, setMaxTokens] = useState(2048)
|
||||
|
||||
// Results
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const [result, setResult] = useState<ComparisonResult | null>(null)
|
||||
const [history, setHistory] = useState<ComparisonResult[]>([])
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
// UI State
|
||||
const [showSettings, setShowSettings] = useState(false)
|
||||
const [showHistory, setShowHistory] = useState(false)
|
||||
|
||||
// API Base URL
|
||||
const API_URL = process.env.NEXT_PUBLIC_LLM_GATEWAY_URL || 'http://localhost:8082'
|
||||
const API_KEY = process.env.NEXT_PUBLIC_LLM_API_KEY || 'dev-key'
|
||||
|
||||
// Load history
|
||||
const loadHistory = useCallback(async () => {
|
||||
try {
|
||||
const response = await fetch(`${API_URL}/v1/comparison/history?limit=20`, {
|
||||
headers: { Authorization: `Bearer ${API_KEY}` },
|
||||
})
|
||||
if (response.ok) {
|
||||
const data = await response.json()
|
||||
setHistory(data.comparisons || [])
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to load history:', e)
|
||||
}
|
||||
}, [API_URL, API_KEY])
|
||||
|
||||
useEffect(() => {
|
||||
loadHistory()
|
||||
}, [loadHistory])
|
||||
|
||||
const runComparison = async () => {
|
||||
if (!prompt.trim()) {
|
||||
setError('Bitte geben Sie einen Prompt ein')
|
||||
return
|
||||
}
|
||||
|
||||
setIsLoading(true)
|
||||
setError(null)
|
||||
setResult(null)
|
||||
|
||||
try {
|
||||
const response = await fetch(`${API_URL}/v1/comparison/run`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${API_KEY}`,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
prompt,
|
||||
system_prompt: systemPrompt || undefined,
|
||||
enable_openai: enableOpenAI,
|
||||
enable_claude: enableClaude,
|
||||
enable_selfhosted_tavily: enableTavily,
|
||||
enable_selfhosted_edusearch: enableEduSearch,
|
||||
selfhosted_model: model,
|
||||
temperature,
|
||||
max_tokens: maxTokens,
|
||||
}),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`API Error: ${response.status}`)
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
setResult(data)
|
||||
loadHistory()
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : 'Unbekannter Fehler')
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const ResponseCard = ({ response }: { response: LLMResponse }) => {
|
||||
const colors = providerColors[response.provider] || {
|
||||
bg: 'bg-slate-50',
|
||||
border: 'border-slate-300',
|
||||
text: 'text-slate-700',
|
||||
}
|
||||
const label = providerLabels[response.provider] || response.provider
|
||||
|
||||
return (
|
||||
<div className={`rounded-xl border-2 ${colors.border} ${colors.bg} overflow-hidden`}>
|
||||
<div className={`px-4 py-3 border-b ${colors.border} flex items-center justify-between`}>
|
||||
<div>
|
||||
<h3 className={`font-semibold ${colors.text}`}>{label}</h3>
|
||||
<p className="text-xs text-slate-500">{response.model}</p>
|
||||
</div>
|
||||
<div className="text-right text-xs text-slate-500">
|
||||
<div>{response.latency_ms}ms</div>
|
||||
{response.tokens_used && <div>{response.tokens_used} tokens</div>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-4">
|
||||
{response.error ? (
|
||||
<div className="text-red-600 text-sm">
|
||||
<strong>Fehler:</strong> {response.error}
|
||||
</div>
|
||||
) : (
|
||||
<pre className="whitespace-pre-wrap text-sm text-slate-700 font-sans">
|
||||
{response.response}
|
||||
</pre>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{response.search_results && response.search_results.length > 0 && (
|
||||
<div className="px-4 pb-4">
|
||||
<details className="text-xs">
|
||||
<summary className="cursor-pointer text-slate-500 hover:text-slate-700">
|
||||
{response.search_results.length} Suchergebnisse anzeigen
|
||||
</summary>
|
||||
<ul className="mt-2 space-y-2">
|
||||
{response.search_results.map((sr, idx) => (
|
||||
<li key={idx} className="bg-white rounded p-2 border border-slate-200">
|
||||
<a
|
||||
href={sr.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-blue-600 hover:underline font-medium"
|
||||
>
|
||||
{sr.title || 'Untitled'}
|
||||
</a>
|
||||
<p className="text-slate-500 truncate">{sr.content}</p>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</details>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* Page Purpose */}
|
||||
<PagePurpose
|
||||
title="LLM Vergleich"
|
||||
purpose="Vergleichen Sie Antworten verschiedener KI-Provider (OpenAI, Claude, Self-hosted) fuer Qualitaetssicherung. Optimieren Sie Parameter und System Prompts fuer beste Ergebnisse. Standalone-Werkzeug ohne direkten Datenfluss zur KI-Pipeline."
|
||||
audience={['Entwickler', 'Data Scientists', 'QA']}
|
||||
architecture={{
|
||||
services: ['llm-gateway (Python)', 'Ollama', 'OpenAI API', 'Claude API'],
|
||||
databases: ['PostgreSQL (History)', 'Qdrant (RAG)'],
|
||||
}}
|
||||
relatedPages={[
|
||||
{ name: 'Test Quality (BQAS)', href: '/ai/test-quality', description: 'Golden Suite & Synthetic Tests' },
|
||||
{ name: 'GPU Infrastruktur', href: '/ai/gpu', description: 'GPU-Ressourcen verwalten' },
|
||||
{ name: 'Agent Management', href: '/ai/agents', description: 'Multi-Agent System' },
|
||||
]}
|
||||
collapsible={true}
|
||||
defaultCollapsed={true}
|
||||
/>
|
||||
|
||||
{/* KI-Werkzeuge Sidebar */}
|
||||
<AIToolsSidebarResponsive currentTool="llm-compare" />
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||
{/* Left Column: Input & Settings */}
|
||||
<div className="lg:col-span-1 space-y-4">
|
||||
{/* Prompt Input */}
|
||||
<div className="bg-white rounded-xl border border-slate-200 p-4">
|
||||
<h2 className="font-semibold text-slate-900 mb-3">Prompt</h2>
|
||||
|
||||
{/* System Prompt */}
|
||||
<div className="mb-3">
|
||||
<label className="block text-sm text-slate-600 mb-1">System Prompt</label>
|
||||
<textarea
|
||||
value={systemPrompt}
|
||||
onChange={(e) => setSystemPrompt(e.target.value)}
|
||||
rows={3}
|
||||
className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm resize-none"
|
||||
placeholder="System Prompt (optional)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* User Prompt */}
|
||||
<div className="mb-3">
|
||||
<label className="block text-sm text-slate-600 mb-1">User Prompt</label>
|
||||
<textarea
|
||||
value={prompt}
|
||||
onChange={(e) => setPrompt(e.target.value)}
|
||||
rows={4}
|
||||
className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm resize-none"
|
||||
placeholder="z.B.: Erstelle ein Arbeitsblatt zum Thema Bruchrechnung fuer Klasse 6..."
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Provider Toggles */}
|
||||
<div className="mb-4">
|
||||
<label className="block text-sm text-slate-600 mb-2">Provider</label>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<label className="flex items-center gap-2 text-sm">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={enableOpenAI}
|
||||
onChange={(e) => setEnableOpenAI(e.target.checked)}
|
||||
className="rounded"
|
||||
/>
|
||||
OpenAI
|
||||
</label>
|
||||
<label className="flex items-center gap-2 text-sm">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={enableClaude}
|
||||
onChange={(e) => setEnableClaude(e.target.checked)}
|
||||
className="rounded"
|
||||
/>
|
||||
Claude
|
||||
</label>
|
||||
<label className="flex items-center gap-2 text-sm">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={enableTavily}
|
||||
onChange={(e) => setEnableTavily(e.target.checked)}
|
||||
className="rounded"
|
||||
/>
|
||||
Self + Tavily
|
||||
</label>
|
||||
<label className="flex items-center gap-2 text-sm">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={enableEduSearch}
|
||||
onChange={(e) => setEnableEduSearch(e.target.checked)}
|
||||
className="rounded"
|
||||
/>
|
||||
Self + EduSearch
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Run Button */}
|
||||
<button
|
||||
onClick={runComparison}
|
||||
disabled={isLoading || !prompt.trim()}
|
||||
className="w-full py-3 bg-teal-600 text-white rounded-lg font-medium hover:bg-teal-700 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{isLoading ? (
|
||||
<span className="flex items-center justify-center gap-2">
|
||||
<svg className="animate-spin w-5 h-5" fill="none" viewBox="0 0 24 24">
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
|
||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
|
||||
</svg>
|
||||
Vergleiche...
|
||||
</span>
|
||||
) : (
|
||||
'Vergleich starten'
|
||||
)}
|
||||
</button>
|
||||
|
||||
{error && (
|
||||
<div className="mt-3 p-3 bg-red-50 border border-red-200 rounded-lg text-red-700 text-sm">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Settings Panel */}
|
||||
<div className="bg-white rounded-xl border border-slate-200 overflow-hidden">
|
||||
<button
|
||||
onClick={() => setShowSettings(!showSettings)}
|
||||
className="w-full px-4 py-3 flex items-center justify-between hover:bg-slate-50"
|
||||
>
|
||||
<span className="font-semibold text-slate-900">Parameter</span>
|
||||
<svg
|
||||
className={`w-5 h-5 transition-transform ${showSettings ? 'rotate-180' : ''}`}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
{showSettings && (
|
||||
<div className="p-4 border-t border-slate-200 space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm text-slate-600 mb-1">Self-hosted Modell</label>
|
||||
<select
|
||||
value={model}
|
||||
onChange={(e) => setModel(e.target.value)}
|
||||
className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm"
|
||||
>
|
||||
<option value="llama3.2:3b">Llama 3.2 3B</option>
|
||||
<option value="llama3.1:8b">Llama 3.1 8B</option>
|
||||
<option value="mistral:7b">Mistral 7B</option>
|
||||
<option value="qwen2.5:7b">Qwen 2.5 7B</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm text-slate-600 mb-1">
|
||||
Temperature: {temperature.toFixed(2)}
|
||||
</label>
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
max="2"
|
||||
step="0.1"
|
||||
value={temperature}
|
||||
onChange={(e) => setTemperature(parseFloat(e.target.value))}
|
||||
className="w-full"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm text-slate-600 mb-1">Max Tokens: {maxTokens}</label>
|
||||
<input
|
||||
type="range"
|
||||
min="256"
|
||||
max="4096"
|
||||
step="256"
|
||||
value={maxTokens}
|
||||
onChange={(e) => setMaxTokens(parseInt(e.target.value))}
|
||||
className="w-full"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* History Panel */}
|
||||
<div className="bg-white rounded-xl border border-slate-200 overflow-hidden">
|
||||
<button
|
||||
onClick={() => setShowHistory(!showHistory)}
|
||||
className="w-full px-4 py-3 flex items-center justify-between hover:bg-slate-50"
|
||||
>
|
||||
<span className="font-semibold text-slate-900">Verlauf ({history.length})</span>
|
||||
<svg
|
||||
className={`w-5 h-5 transition-transform ${showHistory ? 'rotate-180' : ''}`}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
{showHistory && history.length > 0 && (
|
||||
<div className="border-t border-slate-200 max-h-64 overflow-y-auto">
|
||||
{history.map((h) => (
|
||||
<button
|
||||
key={h.comparison_id}
|
||||
onClick={() => {
|
||||
setResult(h)
|
||||
setPrompt(h.prompt)
|
||||
if (h.system_prompt) setSystemPrompt(h.system_prompt)
|
||||
}}
|
||||
className="w-full px-4 py-2 text-left hover:bg-slate-50 border-b border-slate-100 last:border-0"
|
||||
>
|
||||
<div className="text-sm text-slate-700 truncate">{h.prompt}</div>
|
||||
<div className="text-xs text-slate-400">
|
||||
{new Date(h.created_at).toLocaleString('de-DE')}
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right Column: Results */}
|
||||
<div className="lg:col-span-2">
|
||||
{result ? (
|
||||
<div className="space-y-4">
|
||||
<div className="bg-white rounded-xl border border-slate-200 p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="font-semibold text-slate-900">Ergebnisse</h2>
|
||||
<p className="text-sm text-slate-500">ID: {result.comparison_id}</p>
|
||||
</div>
|
||||
<div className="text-sm text-slate-500">
|
||||
{new Date(result.created_at).toLocaleString('de-DE')}
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-2 p-3 bg-slate-50 rounded-lg">
|
||||
<p className="text-sm text-slate-700">{result.prompt}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 xl:grid-cols-2 gap-4">
|
||||
{result.responses.map((response, idx) => (
|
||||
<ResponseCard key={`${response.provider}-${idx}`} response={response} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="bg-white rounded-xl border border-slate-200 p-12 text-center">
|
||||
<svg
|
||||
className="w-16 h-16 mx-auto text-slate-300 mb-4"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={1.5}
|
||||
d="M9 3v2m6-2v2M9 19v2m6-2v2M5 9H3m2 6H3m18-6h-2m2 6h-2M7 19h10a2 2 0 002-2V7a2 2 0 00-2-2H7a2 2 0 00-2 2v10a2 2 0 002 2zM9 9h6v6H9V9z"
|
||||
/>
|
||||
</svg>
|
||||
<h3 className="text-lg font-medium text-slate-700 mb-2">LLM-Vergleich starten</h3>
|
||||
<p className="text-slate-500 max-w-md mx-auto">
|
||||
Geben Sie einen Prompt ein und klicken Sie auf "Vergleich starten", um
|
||||
die Antworten verschiedener LLM-Provider zu vergleichen.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Info Box */}
|
||||
<div className="mt-8 bg-teal-50 border border-teal-200 rounded-xl p-6">
|
||||
<div className="flex items-start gap-4">
|
||||
<svg className="w-6 h-6 text-teal-600 flex-shrink-0 mt-0.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
<div>
|
||||
<h3 className="font-semibold text-teal-900">Qualitaetssicherung</h3>
|
||||
<p className="text-sm text-teal-800 mt-1">
|
||||
Dieses Tool dient zur Qualitaetssicherung der KI-Antworten. Vergleichen Sie verschiedene Provider,
|
||||
um die optimalen Parameter und System Prompts zu finden. Die Ergebnisse werden fuer Audits gespeichert.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,987 @@
|
||||
'use client'
|
||||
|
||||
/**
|
||||
* OCR Labeling Admin Page
|
||||
*
|
||||
* Labeling interface for handwriting training data collection.
|
||||
* DSGVO-konform: Alle Verarbeitung lokal auf Mac Mini (Ollama).
|
||||
*
|
||||
* Teil der KI-Daten-Pipeline:
|
||||
* OCR-Labeling → RAG Pipeline → Daten & RAG
|
||||
*/
|
||||
|
||||
import { useState, useEffect, useCallback, useRef } from 'react'
|
||||
import Link from 'next/link'
|
||||
import { PagePurpose } from '@/components/common/PagePurpose'
|
||||
import { AIModuleSidebarResponsive } from '@/components/ai/AIModuleSidebar'
|
||||
import type {
|
||||
OCRSession,
|
||||
OCRItem,
|
||||
OCRStats,
|
||||
TrainingSample,
|
||||
CreateSessionRequest,
|
||||
OCRModel,
|
||||
} from './types'
|
||||
|
||||
// API Base URL for klausur-service
|
||||
const API_BASE = process.env.NEXT_PUBLIC_KLAUSUR_SERVICE_URL || 'http://localhost:8086'
|
||||
|
||||
// Tab definitions
|
||||
type TabId = 'labeling' | 'sessions' | 'upload' | 'stats' | 'export'
|
||||
|
||||
const tabs: { id: TabId; name: string; icon: JSX.Element }[] = [
|
||||
{
|
||||
id: 'labeling',
|
||||
name: 'Labeling',
|
||||
icon: (
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15.232 5.232l3.536 3.536m-2.036-5.036a2.5 2.5 0 113.536 3.536L6.5 21.036H3v-3.572L16.732 3.732z" />
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'sessions',
|
||||
name: 'Sessions',
|
||||
icon: (
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10" />
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'upload',
|
||||
name: 'Upload',
|
||||
icon: (
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-8l-4-4m0 0L8 8m4-4v12" />
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'stats',
|
||||
name: 'Statistiken',
|
||||
icon: (
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z" />
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'export',
|
||||
name: 'Export',
|
||||
icon: (
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" />
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
]
|
||||
|
||||
export default function OCRLabelingPage() {
|
||||
const [activeTab, setActiveTab] = useState<TabId>('labeling')
|
||||
const [sessions, setSessions] = useState<OCRSession[]>([])
|
||||
const [selectedSession, setSelectedSession] = useState<string | null>(null)
|
||||
const [queue, setQueue] = useState<OCRItem[]>([])
|
||||
const [currentItem, setCurrentItem] = useState<OCRItem | null>(null)
|
||||
const [currentIndex, setCurrentIndex] = useState(0)
|
||||
const [stats, setStats] = useState<OCRStats | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [correctedText, setCorrectedText] = useState('')
|
||||
const [labelStartTime, setLabelStartTime] = useState<number | null>(null)
|
||||
|
||||
// Fetch sessions
|
||||
const fetchSessions = useCallback(async () => {
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/api/v1/ocr-label/sessions`)
|
||||
if (res.ok) {
|
||||
const data = await res.json()
|
||||
setSessions(data)
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch sessions:', err)
|
||||
}
|
||||
}, [])
|
||||
|
||||
// Fetch queue
|
||||
const fetchQueue = useCallback(async () => {
|
||||
try {
|
||||
const url = selectedSession
|
||||
? `${API_BASE}/api/v1/ocr-label/queue?session_id=${selectedSession}&limit=20`
|
||||
: `${API_BASE}/api/v1/ocr-label/queue?limit=20`
|
||||
const res = await fetch(url)
|
||||
if (res.ok) {
|
||||
const data = await res.json()
|
||||
setQueue(data)
|
||||
if (data.length > 0 && !currentItem) {
|
||||
setCurrentItem(data[0])
|
||||
setCurrentIndex(0)
|
||||
setCorrectedText(data[0].ocr_text || '')
|
||||
setLabelStartTime(Date.now())
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch queue:', err)
|
||||
}
|
||||
}, [selectedSession, currentItem])
|
||||
|
||||
// Fetch stats
|
||||
const fetchStats = useCallback(async () => {
|
||||
try {
|
||||
const url = selectedSession
|
||||
? `${API_BASE}/api/v1/ocr-label/stats?session_id=${selectedSession}`
|
||||
: `${API_BASE}/api/v1/ocr-label/stats`
|
||||
const res = await fetch(url)
|
||||
if (res.ok) {
|
||||
const data = await res.json()
|
||||
setStats(data)
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch stats:', err)
|
||||
}
|
||||
}, [selectedSession])
|
||||
|
||||
// Initial data load
|
||||
useEffect(() => {
|
||||
const loadData = async () => {
|
||||
setLoading(true)
|
||||
await Promise.all([fetchSessions(), fetchQueue(), fetchStats()])
|
||||
setLoading(false)
|
||||
}
|
||||
loadData()
|
||||
}, [fetchSessions, fetchQueue, fetchStats])
|
||||
|
||||
// Refresh queue when session changes
|
||||
useEffect(() => {
|
||||
setCurrentItem(null)
|
||||
setCurrentIndex(0)
|
||||
fetchQueue()
|
||||
fetchStats()
|
||||
}, [selectedSession, fetchQueue, fetchStats])
|
||||
|
||||
// Navigate to next item
|
||||
const goToNext = () => {
|
||||
if (currentIndex < queue.length - 1) {
|
||||
const nextIndex = currentIndex + 1
|
||||
setCurrentIndex(nextIndex)
|
||||
setCurrentItem(queue[nextIndex])
|
||||
setCorrectedText(queue[nextIndex].ocr_text || '')
|
||||
setLabelStartTime(Date.now())
|
||||
} else {
|
||||
// Refresh queue
|
||||
fetchQueue()
|
||||
}
|
||||
}
|
||||
|
||||
// Navigate to previous item
|
||||
const goToPrev = () => {
|
||||
if (currentIndex > 0) {
|
||||
const prevIndex = currentIndex - 1
|
||||
setCurrentIndex(prevIndex)
|
||||
setCurrentItem(queue[prevIndex])
|
||||
setCorrectedText(queue[prevIndex].ocr_text || '')
|
||||
setLabelStartTime(Date.now())
|
||||
}
|
||||
}
|
||||
|
||||
// Calculate label time
|
||||
const getLabelTime = (): number | undefined => {
|
||||
if (!labelStartTime) return undefined
|
||||
return Math.round((Date.now() - labelStartTime) / 1000)
|
||||
}
|
||||
|
||||
// Confirm item
|
||||
const confirmItem = async () => {
|
||||
if (!currentItem) return
|
||||
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/api/v1/ocr-label/confirm`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
item_id: currentItem.id,
|
||||
label_time_seconds: getLabelTime(),
|
||||
}),
|
||||
})
|
||||
|
||||
if (res.ok) {
|
||||
// Remove from queue and go to next
|
||||
setQueue(prev => prev.filter(item => item.id !== currentItem.id))
|
||||
goToNext()
|
||||
fetchStats()
|
||||
} else {
|
||||
setError('Bestaetigung fehlgeschlagen')
|
||||
}
|
||||
} catch (err) {
|
||||
setError('Netzwerkfehler')
|
||||
}
|
||||
}
|
||||
|
||||
// Correct item
|
||||
const correctItem = async () => {
|
||||
if (!currentItem || !correctedText.trim()) return
|
||||
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/api/v1/ocr-label/correct`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
item_id: currentItem.id,
|
||||
ground_truth: correctedText.trim(),
|
||||
label_time_seconds: getLabelTime(),
|
||||
}),
|
||||
})
|
||||
|
||||
if (res.ok) {
|
||||
setQueue(prev => prev.filter(item => item.id !== currentItem.id))
|
||||
goToNext()
|
||||
fetchStats()
|
||||
} else {
|
||||
setError('Korrektur fehlgeschlagen')
|
||||
}
|
||||
} catch (err) {
|
||||
setError('Netzwerkfehler')
|
||||
}
|
||||
}
|
||||
|
||||
// Skip item
|
||||
const skipItem = async () => {
|
||||
if (!currentItem) return
|
||||
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/api/v1/ocr-label/skip`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ item_id: currentItem.id }),
|
||||
})
|
||||
|
||||
if (res.ok) {
|
||||
setQueue(prev => prev.filter(item => item.id !== currentItem.id))
|
||||
goToNext()
|
||||
fetchStats()
|
||||
} else {
|
||||
setError('Ueberspringen fehlgeschlagen')
|
||||
}
|
||||
} catch (err) {
|
||||
setError('Netzwerkfehler')
|
||||
}
|
||||
}
|
||||
|
||||
// Keyboard shortcuts
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
// Only handle if not in text input
|
||||
if (e.target instanceof HTMLTextAreaElement) return
|
||||
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault()
|
||||
confirmItem()
|
||||
} else if (e.key === 'ArrowRight') {
|
||||
goToNext()
|
||||
} else if (e.key === 'ArrowLeft') {
|
||||
goToPrev()
|
||||
} else if (e.key === 's' && !e.ctrlKey && !e.metaKey) {
|
||||
skipItem()
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener('keydown', handleKeyDown)
|
||||
return () => window.removeEventListener('keydown', handleKeyDown)
|
||||
}, [currentItem, correctedText])
|
||||
|
||||
// Render Labeling Tab
|
||||
const renderLabelingTab = () => (
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||
{/* Left: Image Viewer */}
|
||||
<div className="lg:col-span-2 bg-white rounded-lg shadow p-4">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h3 className="text-lg font-semibold">Bild</h3>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={goToPrev}
|
||||
disabled={currentIndex === 0}
|
||||
className="p-2 rounded hover:bg-slate-100 disabled:opacity-50"
|
||||
title="Zurueck (Pfeiltaste links)"
|
||||
>
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
|
||||
</svg>
|
||||
</button>
|
||||
<span className="text-sm text-slate-600">
|
||||
{currentIndex + 1} / {queue.length}
|
||||
</span>
|
||||
<button
|
||||
onClick={goToNext}
|
||||
disabled={currentIndex >= queue.length - 1}
|
||||
className="p-2 rounded hover:bg-slate-100 disabled:opacity-50"
|
||||
title="Weiter (Pfeiltaste rechts)"
|
||||
>
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{currentItem ? (
|
||||
<div className="relative bg-slate-100 rounded-lg overflow-hidden" style={{ minHeight: '400px' }}>
|
||||
<img
|
||||
src={currentItem.image_url || `${API_BASE}${currentItem.image_path}`}
|
||||
alt="OCR Bild"
|
||||
className="w-full h-auto max-h-[600px] object-contain"
|
||||
onError={(e) => {
|
||||
// Fallback if image fails to load
|
||||
const target = e.target as HTMLImageElement
|
||||
target.style.display = 'none'
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center justify-center h-64 bg-slate-100 rounded-lg">
|
||||
<p className="text-slate-500">Keine Bilder in der Warteschlange</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Right: OCR Text & Actions */}
|
||||
<div className="bg-white rounded-lg shadow p-4">
|
||||
<div className="space-y-4">
|
||||
{/* OCR Result */}
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<h3 className="text-lg font-semibold">OCR-Ergebnis</h3>
|
||||
{currentItem?.ocr_confidence && (
|
||||
<span className={`text-sm px-2 py-1 rounded ${
|
||||
currentItem.ocr_confidence > 0.8
|
||||
? 'bg-green-100 text-green-800'
|
||||
: currentItem.ocr_confidence > 0.5
|
||||
? 'bg-yellow-100 text-yellow-800'
|
||||
: 'bg-red-100 text-red-800'
|
||||
}`}>
|
||||
{Math.round(currentItem.ocr_confidence * 100)}% Konfidenz
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="bg-slate-50 p-3 rounded-lg min-h-[100px] text-sm">
|
||||
{currentItem?.ocr_text || <span className="text-slate-400">Kein OCR-Text</span>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Correction Input */}
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold mb-2">Korrektur</h3>
|
||||
<textarea
|
||||
value={correctedText}
|
||||
onChange={(e) => setCorrectedText(e.target.value)}
|
||||
placeholder="Korrigierter Text..."
|
||||
className="w-full h-32 p-3 border border-slate-200 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-transparent"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex flex-col gap-2">
|
||||
<button
|
||||
onClick={confirmItem}
|
||||
disabled={!currentItem}
|
||||
className="w-full px-4 py-3 bg-green-600 text-white rounded-lg hover:bg-green-700 disabled:opacity-50 flex items-center justify-center gap-2"
|
||||
>
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
Korrekt (Enter)
|
||||
</button>
|
||||
<button
|
||||
onClick={correctItem}
|
||||
disabled={!currentItem || !correctedText.trim() || correctedText === currentItem?.ocr_text}
|
||||
className="w-full px-4 py-3 bg-primary-600 text-white rounded-lg hover:bg-primary-700 disabled:opacity-50 flex items-center justify-center gap-2"
|
||||
>
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15.232 5.232l3.536 3.536m-2.036-5.036a2.5 2.5 0 113.536 3.536L6.5 21.036H3v-3.572L16.732 3.732z" />
|
||||
</svg>
|
||||
Korrektur speichern
|
||||
</button>
|
||||
<button
|
||||
onClick={skipItem}
|
||||
disabled={!currentItem}
|
||||
className="w-full px-4 py-2 bg-slate-200 text-slate-700 rounded-lg hover:bg-slate-300 disabled:opacity-50 flex items-center justify-center gap-2"
|
||||
>
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 5l7 7-7 7M5 5l7 7-7 7" />
|
||||
</svg>
|
||||
Ueberspringen (S)
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Keyboard Shortcuts */}
|
||||
<div className="text-xs text-slate-500 mt-4">
|
||||
<p className="font-medium mb-1">Tastaturkuerzel:</p>
|
||||
<p>Enter = Bestaetigen | S = Ueberspringen</p>
|
||||
<p>Pfeiltasten = Navigation</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Bottom: Queue Preview */}
|
||||
<div className="lg:col-span-3 bg-white rounded-lg shadow p-4">
|
||||
<h3 className="text-lg font-semibold mb-4">Warteschlange ({queue.length} Items)</h3>
|
||||
<div className="flex gap-2 overflow-x-auto pb-2">
|
||||
{queue.slice(0, 10).map((item, idx) => (
|
||||
<button
|
||||
key={item.id}
|
||||
onClick={() => {
|
||||
setCurrentIndex(idx)
|
||||
setCurrentItem(item)
|
||||
setCorrectedText(item.ocr_text || '')
|
||||
setLabelStartTime(Date.now())
|
||||
}}
|
||||
className={`flex-shrink-0 w-24 h-24 rounded-lg overflow-hidden border-2 ${
|
||||
idx === currentIndex
|
||||
? 'border-primary-500'
|
||||
: 'border-transparent hover:border-slate-300'
|
||||
}`}
|
||||
>
|
||||
<img
|
||||
src={item.image_url || `${API_BASE}${item.image_path}`}
|
||||
alt=""
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
</button>
|
||||
))}
|
||||
{queue.length > 10 && (
|
||||
<div className="flex-shrink-0 w-24 h-24 rounded-lg bg-slate-100 flex items-center justify-center text-slate-500">
|
||||
+{queue.length - 10} mehr
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
// Render Sessions Tab
|
||||
const renderSessionsTab = () => {
|
||||
const [newSession, setNewSession] = useState<CreateSessionRequest>({
|
||||
name: '',
|
||||
source_type: 'klausur',
|
||||
description: '',
|
||||
ocr_model: 'llama3.2-vision:11b',
|
||||
})
|
||||
|
||||
const createSession = async () => {
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/api/v1/ocr-label/sessions`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(newSession),
|
||||
})
|
||||
|
||||
if (res.ok) {
|
||||
setNewSession({ name: '', source_type: 'klausur', description: '', ocr_model: 'llama3.2-vision:11b' })
|
||||
fetchSessions()
|
||||
} else {
|
||||
setError('Session erstellen fehlgeschlagen')
|
||||
}
|
||||
} catch (err) {
|
||||
setError('Netzwerkfehler')
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Create Session */}
|
||||
<div className="bg-white rounded-lg shadow p-6">
|
||||
<h3 className="text-lg font-semibold mb-4">Neue Session erstellen</h3>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1">Name</label>
|
||||
<input
|
||||
type="text"
|
||||
value={newSession.name}
|
||||
onChange={(e) => setNewSession(prev => ({ ...prev, name: e.target.value }))}
|
||||
placeholder="z.B. Mathe Klausur Q1 2025"
|
||||
className="w-full px-3 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-primary-500"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1">Typ</label>
|
||||
<select
|
||||
value={newSession.source_type}
|
||||
onChange={(e) => setNewSession(prev => ({ ...prev, source_type: e.target.value as 'klausur' | 'handwriting_sample' | 'scan' }))}
|
||||
className="w-full px-3 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-primary-500"
|
||||
>
|
||||
<option value="klausur">Klausur</option>
|
||||
<option value="handwriting_sample">Handschriftprobe</option>
|
||||
<option value="scan">Scan</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1">OCR Modell</label>
|
||||
<select
|
||||
value={newSession.ocr_model}
|
||||
onChange={(e) => setNewSession(prev => ({ ...prev, ocr_model: e.target.value as OCRModel }))}
|
||||
className="w-full px-3 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-primary-500"
|
||||
>
|
||||
<option value="llama3.2-vision:11b">llama3.2-vision:11b - Vision LLM (Standard)</option>
|
||||
<option value="trocr">TrOCR - Microsoft Transformer (schnell)</option>
|
||||
<option value="paddleocr">PaddleOCR + LLM (4x schneller)</option>
|
||||
<option value="donut">Donut - Document Understanding (strukturiert)</option>
|
||||
</select>
|
||||
<p className="mt-1 text-xs text-slate-500">
|
||||
{newSession.ocr_model === 'paddleocr' && 'PaddleOCR erkennt Text schnell, LLM strukturiert die Ergebnisse.'}
|
||||
{newSession.ocr_model === 'donut' && 'Speziell fuer Dokumente mit Tabellen und Formularen.'}
|
||||
{newSession.ocr_model === 'trocr' && 'Schnelles Transformer-Modell fuer gedruckten Text.'}
|
||||
{newSession.ocr_model === 'llama3.2-vision:11b' && 'Beste Qualitaet bei Handschrift, aber langsamer.'}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1">Beschreibung</label>
|
||||
<input
|
||||
type="text"
|
||||
value={newSession.description}
|
||||
onChange={(e) => setNewSession(prev => ({ ...prev, description: e.target.value }))}
|
||||
placeholder="Optional..."
|
||||
className="w-full px-3 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-primary-500"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={createSession}
|
||||
disabled={!newSession.name}
|
||||
className="mt-4 px-4 py-2 bg-primary-600 text-white rounded-lg hover:bg-primary-700 disabled:opacity-50"
|
||||
>
|
||||
Session erstellen
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Sessions List */}
|
||||
<div className="bg-white rounded-lg shadow">
|
||||
<div className="px-6 py-4 border-b border-slate-200">
|
||||
<h3 className="text-lg font-semibold">Sessions ({sessions.length})</h3>
|
||||
</div>
|
||||
<div className="divide-y divide-slate-200">
|
||||
{sessions.map((session) => (
|
||||
<div
|
||||
key={session.id}
|
||||
className={`p-4 hover:bg-slate-50 cursor-pointer ${
|
||||
selectedSession === session.id ? 'bg-primary-50 border-l-4 border-primary-500' : ''
|
||||
}`}
|
||||
onClick={() => setSelectedSession(session.id === selectedSession ? null : session.id)}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h4 className="font-medium">{session.name}</h4>
|
||||
<p className="text-sm text-slate-500">
|
||||
{session.source_type} | {session.ocr_model}
|
||||
</p>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<p className="text-sm font-medium">
|
||||
{session.labeled_items}/{session.total_items} gelabelt
|
||||
</p>
|
||||
<div className="w-32 bg-slate-200 rounded-full h-2 mt-1">
|
||||
<div
|
||||
className="bg-primary-600 rounded-full h-2"
|
||||
style={{
|
||||
width: `${session.total_items > 0 ? (session.labeled_items / session.total_items) * 100 : 0}%`
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{session.description && (
|
||||
<p className="text-sm text-slate-600 mt-2">{session.description}</p>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
{sessions.length === 0 && (
|
||||
<p className="p-4 text-slate-500 text-center">Keine Sessions vorhanden</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Render Upload Tab
|
||||
const renderUploadTab = () => {
|
||||
const [uploading, setUploading] = useState(false)
|
||||
const [uploadResults, setUploadResults] = useState<any[]>([])
|
||||
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
const handleUpload = async (files: FileList) => {
|
||||
if (!selectedSession) {
|
||||
setError('Bitte zuerst eine Session auswaehlen')
|
||||
return
|
||||
}
|
||||
|
||||
setUploading(true)
|
||||
const formData = new FormData()
|
||||
Array.from(files).forEach(file => formData.append('files', file))
|
||||
formData.append('run_ocr', 'true')
|
||||
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/api/v1/ocr-label/sessions/${selectedSession}/upload`, {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
})
|
||||
|
||||
if (res.ok) {
|
||||
const data = await res.json()
|
||||
setUploadResults(data.items || [])
|
||||
fetchQueue()
|
||||
fetchStats()
|
||||
} else {
|
||||
setError('Upload fehlgeschlagen')
|
||||
}
|
||||
} catch (err) {
|
||||
setError('Netzwerkfehler beim Upload')
|
||||
} finally {
|
||||
setUploading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Session Selection */}
|
||||
<div className="bg-white rounded-lg shadow p-6">
|
||||
<h3 className="text-lg font-semibold mb-4">Session auswaehlen</h3>
|
||||
<select
|
||||
value={selectedSession || ''}
|
||||
onChange={(e) => setSelectedSession(e.target.value || null)}
|
||||
className="w-full px-3 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-primary-500"
|
||||
>
|
||||
<option value="">-- Session waehlen --</option>
|
||||
{sessions.map((session) => (
|
||||
<option key={session.id} value={session.id}>
|
||||
{session.name} ({session.total_items} Items)
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Upload Area */}
|
||||
<div className="bg-white rounded-lg shadow p-6">
|
||||
<h3 className="text-lg font-semibold mb-4">Bilder hochladen</h3>
|
||||
<div
|
||||
className={`border-2 border-dashed rounded-lg p-8 text-center ${
|
||||
selectedSession ? 'border-slate-300 hover:border-primary-500' : 'border-slate-200 opacity-50'
|
||||
}`}
|
||||
onDragOver={(e) => {
|
||||
e.preventDefault()
|
||||
e.currentTarget.classList.add('border-primary-500', 'bg-primary-50')
|
||||
}}
|
||||
onDragLeave={(e) => {
|
||||
e.currentTarget.classList.remove('border-primary-500', 'bg-primary-50')
|
||||
}}
|
||||
onDrop={(e) => {
|
||||
e.preventDefault()
|
||||
e.currentTarget.classList.remove('border-primary-500', 'bg-primary-50')
|
||||
if (e.dataTransfer.files.length > 0) {
|
||||
handleUpload(e.dataTransfer.files)
|
||||
}
|
||||
}}
|
||||
>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
multiple
|
||||
accept="image/png,image/jpeg,image/jpg"
|
||||
onChange={(e) => e.target.files && handleUpload(e.target.files)}
|
||||
className="hidden"
|
||||
disabled={!selectedSession}
|
||||
/>
|
||||
{uploading ? (
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary-600" />
|
||||
<p>Hochladen & OCR ausfuehren...</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<svg className="w-12 h-12 text-slate-400 mx-auto mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z" />
|
||||
</svg>
|
||||
<p className="text-slate-600 mb-2">
|
||||
Bilder hierher ziehen oder{' '}
|
||||
<button
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
disabled={!selectedSession}
|
||||
className="text-primary-600 hover:underline"
|
||||
>
|
||||
auswaehlen
|
||||
</button>
|
||||
</p>
|
||||
<p className="text-sm text-slate-500">PNG, JPG (max. 10MB pro Bild)</p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Upload Results */}
|
||||
{uploadResults.length > 0 && (
|
||||
<div className="bg-white rounded-lg shadow p-6">
|
||||
<h3 className="text-lg font-semibold mb-4">Upload-Ergebnisse ({uploadResults.length})</h3>
|
||||
<div className="space-y-2">
|
||||
{uploadResults.map((result) => (
|
||||
<div key={result.id} className="flex items-center justify-between p-2 bg-slate-50 rounded">
|
||||
<span className="text-sm">{result.filename}</span>
|
||||
<span className={`text-xs px-2 py-1 rounded ${
|
||||
result.ocr_text ? 'bg-green-100 text-green-800' : 'bg-yellow-100 text-yellow-800'
|
||||
}`}>
|
||||
{result.ocr_text ? `OCR OK (${Math.round((result.ocr_confidence || 0) * 100)}%)` : 'Kein OCR'}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Render Stats Tab
|
||||
const renderStatsTab = () => (
|
||||
<div className="space-y-6">
|
||||
{/* Global Stats */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
|
||||
<div className="bg-white rounded-lg shadow p-6">
|
||||
<h4 className="text-sm font-medium text-slate-500">Gesamt Items</h4>
|
||||
<p className="text-3xl font-bold mt-2">{stats?.total_items || 0}</p>
|
||||
</div>
|
||||
<div className="bg-white rounded-lg shadow p-6">
|
||||
<h4 className="text-sm font-medium text-slate-500">Gelabelt</h4>
|
||||
<p className="text-3xl font-bold mt-2 text-green-600">{stats?.labeled_items || 0}</p>
|
||||
</div>
|
||||
<div className="bg-white rounded-lg shadow p-6">
|
||||
<h4 className="text-sm font-medium text-slate-500">Ausstehend</h4>
|
||||
<p className="text-3xl font-bold mt-2 text-yellow-600">{stats?.pending_items || 0}</p>
|
||||
</div>
|
||||
<div className="bg-white rounded-lg shadow p-6">
|
||||
<h4 className="text-sm font-medium text-slate-500">OCR-Genauigkeit</h4>
|
||||
<p className="text-3xl font-bold mt-2">{stats?.accuracy_rate || 0}%</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Detailed Stats */}
|
||||
<div className="bg-white rounded-lg shadow p-6">
|
||||
<h3 className="text-lg font-semibold mb-4">Details</h3>
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
<div>
|
||||
<p className="text-sm text-slate-500">Bestaetigt</p>
|
||||
<p className="text-xl font-semibold text-green-600">{stats?.confirmed_items || 0}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-slate-500">Korrigiert</p>
|
||||
<p className="text-xl font-semibold text-primary-600">{stats?.corrected_items || 0}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-slate-500">Exportierbar</p>
|
||||
<p className="text-xl font-semibold">{stats?.exportable_items || 0}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-slate-500">Durchschn. Label-Zeit</p>
|
||||
<p className="text-xl font-semibold">{stats?.avg_label_time_seconds || 0}s</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Progress Bar */}
|
||||
{stats?.total_items ? (
|
||||
<div className="bg-white rounded-lg shadow p-6">
|
||||
<h3 className="text-lg font-semibold mb-4">Fortschritt</h3>
|
||||
<div className="w-full bg-slate-200 rounded-full h-4">
|
||||
<div
|
||||
className="bg-primary-600 rounded-full h-4 transition-all"
|
||||
style={{ width: `${(stats.labeled_items / stats.total_items) * 100}%` }}
|
||||
/>
|
||||
</div>
|
||||
<p className="text-sm text-slate-500 mt-2">
|
||||
{Math.round((stats.labeled_items / stats.total_items) * 100)}% abgeschlossen
|
||||
</p>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
|
||||
// Render Export Tab
|
||||
const renderExportTab = () => {
|
||||
const [exportFormat, setExportFormat] = useState<'generic' | 'trocr' | 'llama_vision'>('generic')
|
||||
const [exporting, setExporting] = useState(false)
|
||||
const [exportResult, setExportResult] = useState<any>(null)
|
||||
|
||||
const handleExport = async () => {
|
||||
setExporting(true)
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/api/v1/ocr-label/export`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
export_format: exportFormat,
|
||||
session_id: selectedSession,
|
||||
}),
|
||||
})
|
||||
|
||||
if (res.ok) {
|
||||
const data = await res.json()
|
||||
setExportResult(data)
|
||||
} else {
|
||||
setError('Export fehlgeschlagen')
|
||||
}
|
||||
} catch (err) {
|
||||
setError('Netzwerkfehler')
|
||||
} finally {
|
||||
setExporting(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="bg-white rounded-lg shadow p-6">
|
||||
<h3 className="text-lg font-semibold mb-4">Training-Daten exportieren</h3>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1">Export-Format</label>
|
||||
<select
|
||||
value={exportFormat}
|
||||
onChange={(e) => setExportFormat(e.target.value as typeof exportFormat)}
|
||||
className="w-full px-3 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-primary-500"
|
||||
>
|
||||
<option value="generic">Generic JSON</option>
|
||||
<option value="trocr">TrOCR Fine-Tuning</option>
|
||||
<option value="llama_vision">Llama Vision Fine-Tuning</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1">Session (optional)</label>
|
||||
<select
|
||||
value={selectedSession || ''}
|
||||
onChange={(e) => setSelectedSession(e.target.value || null)}
|
||||
className="w-full px-3 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-primary-500"
|
||||
>
|
||||
<option value="">Alle Sessions</option>
|
||||
{sessions.map((session) => (
|
||||
<option key={session.id} value={session.id}>{session.name}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={handleExport}
|
||||
disabled={exporting || (stats?.exportable_items || 0) === 0}
|
||||
className="w-full px-4 py-2 bg-primary-600 text-white rounded-lg hover:bg-primary-700 disabled:opacity-50"
|
||||
>
|
||||
{exporting ? 'Exportiere...' : `${stats?.exportable_items || 0} Samples exportieren`}
|
||||
</button>
|
||||
|
||||
{/* Cross-Link to Magic Help for TrOCR Fine-Tuning */}
|
||||
{exportFormat === 'trocr' && (stats?.exportable_items || 0) > 0 && (
|
||||
<Link
|
||||
href="/ai/magic-help?source=ocr-labeling"
|
||||
className="w-full mt-3 px-4 py-2 bg-purple-100 text-purple-700 border border-purple-300 rounded-lg hover:bg-purple-200 flex items-center justify-center gap-2 transition-colors"
|
||||
>
|
||||
<span>✨</span>
|
||||
Mit Magic Help testen & fine-tunen
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{exportResult && (
|
||||
<div className="bg-white rounded-lg shadow p-6">
|
||||
<h3 className="text-lg font-semibold mb-4">Export-Ergebnis</h3>
|
||||
<div className="bg-green-50 border border-green-200 rounded-lg p-4 mb-4">
|
||||
<p className="text-green-800">
|
||||
{exportResult.exported_count} Samples erfolgreich exportiert
|
||||
</p>
|
||||
<p className="text-sm text-green-600">
|
||||
Batch: {exportResult.batch_id}
|
||||
</p>
|
||||
</div>
|
||||
<div className="bg-slate-50 p-4 rounded-lg overflow-auto max-h-64">
|
||||
<pre className="text-xs">{JSON.stringify(exportResult.samples?.slice(0, 3), null, 2)}</pre>
|
||||
{(exportResult.samples?.length || 0) > 3 && (
|
||||
<p className="text-slate-500 mt-2">... und {exportResult.samples.length - 3} weitere</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-6">
|
||||
{/* Header */}
|
||||
<div className="mb-6">
|
||||
<h1 className="text-2xl font-bold text-gray-900 dark:text-white">OCR-Labeling</h1>
|
||||
<p className="text-gray-600 dark:text-gray-400">Handschrift-Training & Ground Truth Erfassung</p>
|
||||
</div>
|
||||
|
||||
{/* Page Purpose with Related Pages */}
|
||||
<PagePurpose
|
||||
title="OCR-Labeling"
|
||||
purpose="Erstellen Sie Ground Truth Daten für das Training von Handschrift-Erkennungsmodellen. Labeln Sie OCR-Ergebnisse, korrigieren Sie Fehler und exportieren Sie Trainingsdaten für TrOCR, Llama Vision und andere Modelle. Teil der KI-Daten-Pipeline: Gelabelte Daten können zur RAG Pipeline exportiert werden."
|
||||
audience={['Entwickler', 'Data Scientists', 'QA-Team']}
|
||||
architecture={{
|
||||
services: ['klausur-service (Python)'],
|
||||
databases: ['PostgreSQL', 'MinIO (Bilder)'],
|
||||
}}
|
||||
relatedPages={[
|
||||
{ name: 'Magic Help', href: '/ai/magic-help', description: 'TrOCR testen & fine-tunen' },
|
||||
{ name: 'RAG Pipeline', href: '/ai/rag-pipeline', description: 'Trainierte Daten indexieren' },
|
||||
{ name: 'Klausur-Korrektur', href: '/ai/klausur-korrektur', description: 'OCR in Aktion' },
|
||||
{ name: 'Daten & RAG', href: '/ai/rag', description: 'Indexierte Daten durchsuchen' },
|
||||
]}
|
||||
collapsible={true}
|
||||
defaultCollapsed={true}
|
||||
/>
|
||||
|
||||
{/* AI Module Sidebar - Desktop: Fixed, Mobile: FAB + Drawer */}
|
||||
<AIModuleSidebarResponsive currentModule="ocr-labeling" />
|
||||
|
||||
{/* Error Toast */}
|
||||
{error && (
|
||||
<div className="fixed top-4 right-4 bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded z-50">
|
||||
<span>{error}</span>
|
||||
<button onClick={() => setError(null)} className="ml-4">X</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Tabs */}
|
||||
<div className="mb-6">
|
||||
<div className="border-b border-slate-200">
|
||||
<nav className="flex space-x-4" aria-label="Tabs">
|
||||
{tabs.map((tab) => (
|
||||
<button
|
||||
key={tab.id}
|
||||
onClick={() => setActiveTab(tab.id)}
|
||||
className={`flex items-center gap-2 px-4 py-3 border-b-2 font-medium text-sm transition-colors ${
|
||||
activeTab === tab.id
|
||||
? 'border-primary-500 text-primary-600'
|
||||
: 'border-transparent text-slate-500 hover:text-slate-700 hover:border-slate-300'
|
||||
}`}
|
||||
>
|
||||
{tab.icon}
|
||||
{tab.name}
|
||||
</button>
|
||||
))}
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tab Content */}
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary-600" />
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{activeTab === 'labeling' && renderLabelingTab()}
|
||||
{activeTab === 'sessions' && renderSessionsTab()}
|
||||
{activeTab === 'upload' && renderUploadTab()}
|
||||
{activeTab === 'stats' && renderStatsTab()}
|
||||
{activeTab === 'export' && renderExportTab()}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
/**
|
||||
* TypeScript types for OCR Labeling UI
|
||||
*/
|
||||
|
||||
/**
|
||||
* Available OCR Models
|
||||
*
|
||||
* - llama3.2-vision:11b: Vision LLM, beste Qualitaet bei Handschrift (Standard)
|
||||
* - trocr: Microsoft TrOCR, schnell bei gedrucktem Text
|
||||
* - paddleocr: PaddleOCR + LLM, 4x schneller durch Hybrid-Ansatz
|
||||
* - donut: Document Understanding Transformer, strukturierte Dokumente
|
||||
*/
|
||||
export type OCRModel = 'llama3.2-vision:11b' | 'trocr' | 'paddleocr' | 'donut'
|
||||
|
||||
export const OCR_MODEL_INFO: Record<OCRModel, { label: string; description: string; speed: string }> = {
|
||||
'llama3.2-vision:11b': {
|
||||
label: 'Vision LLM',
|
||||
description: 'Beste Qualitaet bei Handschrift',
|
||||
speed: 'langsam',
|
||||
},
|
||||
trocr: {
|
||||
label: 'Microsoft TrOCR',
|
||||
description: 'Schnell bei gedrucktem Text',
|
||||
speed: 'schnell',
|
||||
},
|
||||
paddleocr: {
|
||||
label: 'PaddleOCR + LLM',
|
||||
description: 'Hybrid-Ansatz: OCR + Strukturierung',
|
||||
speed: 'sehr schnell',
|
||||
},
|
||||
donut: {
|
||||
label: 'Donut',
|
||||
description: 'Document Understanding fuer Tabellen/Formulare',
|
||||
speed: 'mittel',
|
||||
},
|
||||
}
|
||||
|
||||
export interface OCRSession {
|
||||
id: string
|
||||
name: string
|
||||
source_type: 'klausur' | 'handwriting_sample' | 'scan'
|
||||
description?: string
|
||||
ocr_model?: OCRModel
|
||||
total_items: number
|
||||
labeled_items: number
|
||||
confirmed_items: number
|
||||
corrected_items: number
|
||||
skipped_items: number
|
||||
created_at: string
|
||||
}
|
||||
|
||||
export interface OCRItem {
|
||||
id: string
|
||||
session_id: string
|
||||
session_name: string
|
||||
image_path: string
|
||||
image_url?: string
|
||||
ocr_text?: string
|
||||
ocr_confidence?: number
|
||||
ground_truth?: string
|
||||
status: 'pending' | 'confirmed' | 'corrected' | 'skipped'
|
||||
metadata?: Record<string, unknown>
|
||||
created_at: string
|
||||
}
|
||||
|
||||
export interface OCRStats {
|
||||
total_sessions?: number
|
||||
session_id?: string
|
||||
name?: string
|
||||
total_items: number
|
||||
labeled_items: number
|
||||
confirmed_items: number
|
||||
corrected_items: number
|
||||
skipped_items?: number
|
||||
pending_items: number
|
||||
exportable_items?: number
|
||||
accuracy_rate: number
|
||||
avg_label_time_seconds?: number
|
||||
progress_percent?: number
|
||||
}
|
||||
|
||||
export interface TrainingSample {
|
||||
id: string
|
||||
image_path: string
|
||||
ground_truth: string
|
||||
export_format: 'generic' | 'trocr' | 'llama_vision'
|
||||
training_batch: string
|
||||
exported_at?: string
|
||||
}
|
||||
|
||||
export interface CreateSessionRequest {
|
||||
name: string
|
||||
source_type: 'klausur' | 'handwriting_sample' | 'scan'
|
||||
description?: string
|
||||
ocr_model?: OCRModel
|
||||
}
|
||||
|
||||
export interface ConfirmRequest {
|
||||
item_id: string
|
||||
label_time_seconds?: number
|
||||
}
|
||||
|
||||
export interface CorrectRequest {
|
||||
item_id: string
|
||||
ground_truth: string
|
||||
label_time_seconds?: number
|
||||
}
|
||||
|
||||
export interface ExportRequest {
|
||||
export_format: 'generic' | 'trocr' | 'llama_vision'
|
||||
session_id?: string
|
||||
batch_id?: string
|
||||
}
|
||||
|
||||
export interface UploadResult {
|
||||
id: string
|
||||
filename: string
|
||||
image_path: string
|
||||
image_hash: string
|
||||
ocr_text?: string
|
||||
ocr_confidence?: number
|
||||
status: string
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
'use client'
|
||||
|
||||
import { getCategoryById } from '@/lib/navigation'
|
||||
import { ModuleCard } from '@/components/common/ModuleCard'
|
||||
import { PagePurpose } from '@/components/common/PagePurpose'
|
||||
|
||||
export default function AIPage() {
|
||||
const category = getCategoryById('ai')
|
||||
|
||||
if (!category) {
|
||||
return <div>Kategorie nicht gefunden</div>
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* Page Purpose */}
|
||||
<PagePurpose
|
||||
title={category.name}
|
||||
purpose="Diese Kategorie umfasst alle KI- und Machine-Learning-Module. Hier vergleichen Sie LLM-Provider, verwalten RAG-Pipelines, labeln OCR-Daten und nutzen KI-gestuetzte Korrektur-Tools."
|
||||
audience={['Entwickler', 'Data Scientists', 'Lehrer']}
|
||||
architecture={{
|
||||
services: ['klausur-service (Python)', 'embedding-service (Python)', 'backend (Python)'],
|
||||
databases: ['PostgreSQL', 'Qdrant (Vector)', 'MinIO (Object Storage)'],
|
||||
}}
|
||||
relatedPages={[
|
||||
{ name: 'GPU Infrastruktur', href: '/infrastructure/gpu', description: 'GPU-Ressourcen fuer Training' },
|
||||
]}
|
||||
collapsible={true}
|
||||
defaultCollapsed={false}
|
||||
/>
|
||||
|
||||
{/* Modules Grid */}
|
||||
<h2 className="text-lg font-semibold text-slate-900 mb-4">Module</h2>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{category.modules.map((module) => (
|
||||
<ModuleCard key={module.id} module={module} category={category} />
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Info Section */}
|
||||
<div className="mt-8 bg-teal-50 border border-teal-200 rounded-xl p-6">
|
||||
<h3 className="font-semibold text-teal-800 flex items-center gap-2">
|
||||
<span>🧠</span>
|
||||
DSGVO-konforme KI
|
||||
</h3>
|
||||
<p className="text-sm text-teal-700 mt-2">
|
||||
Alle KI-Modelle koennen lokal auf dem Mac Mini mit Ollama ausgefuehrt werden.
|
||||
Keine Daten werden an externe Cloud-Anbieter gesendet, sofern nicht explizit konfiguriert.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,524 @@
|
||||
'use client'
|
||||
|
||||
/**
|
||||
* Quality & Audit Page
|
||||
*
|
||||
* Ermoeglicht Auditoren:
|
||||
* - Chunk-Suche und Stichproben
|
||||
* - Traceability: Chunk → Requirement → Control
|
||||
* - Dokumenten-Vollstaendigkeitspruefung
|
||||
*/
|
||||
|
||||
import { useState, useCallback } from 'react'
|
||||
import Link from 'next/link'
|
||||
import { PagePurpose } from '@/components/common/PagePurpose'
|
||||
|
||||
const API_PROXY = '/api/legal-corpus'
|
||||
|
||||
// Types
|
||||
interface ChunkDetail {
|
||||
id: string
|
||||
text: string
|
||||
regulation_code: string
|
||||
regulation_name: string
|
||||
article: string | null
|
||||
paragraph: string | null
|
||||
chunk_index: number
|
||||
chunk_position: 'beginning' | 'middle' | 'end'
|
||||
source_url: string
|
||||
score?: number
|
||||
}
|
||||
|
||||
interface Requirement {
|
||||
id: string
|
||||
text: string
|
||||
category: string
|
||||
source_chunk_id: string
|
||||
regulation_code: string
|
||||
}
|
||||
|
||||
interface Control {
|
||||
id: string
|
||||
name: string
|
||||
description: string
|
||||
source_requirement_ids: string[]
|
||||
regulation_codes: string[]
|
||||
}
|
||||
|
||||
interface TraceabilityResult {
|
||||
chunk: ChunkDetail
|
||||
requirements: Requirement[]
|
||||
controls: Control[]
|
||||
}
|
||||
|
||||
// Regulations for filtering
|
||||
const REGULATIONS = [
|
||||
{ code: 'GDPR', name: 'DSGVO' },
|
||||
{ code: 'EPRIVACY', name: 'ePrivacy' },
|
||||
{ code: 'TDDDG', name: 'TDDDG' },
|
||||
{ code: 'SCC', name: 'Standardvertragsklauseln' },
|
||||
{ code: 'DPF', name: 'EU-US DPF' },
|
||||
{ code: 'AIACT', name: 'EU AI Act' },
|
||||
{ code: 'CRA', name: 'Cyber Resilience Act' },
|
||||
{ code: 'NIS2', name: 'NIS2' },
|
||||
{ code: 'EUCSA', name: 'EU Cybersecurity Act' },
|
||||
{ code: 'DATAACT', name: 'Data Act' },
|
||||
{ code: 'DGA', name: 'Data Governance Act' },
|
||||
{ code: 'DSA', name: 'Digital Services Act' },
|
||||
{ code: 'EAA', name: 'Accessibility Act' },
|
||||
{ code: 'DSM', name: 'DSM-Urheberrecht' },
|
||||
{ code: 'PLD', name: 'Produkthaftung' },
|
||||
{ code: 'GPSR', name: 'Product Safety' },
|
||||
{ code: 'BSI-TR-03161-1', name: 'BSI-TR Teil 1' },
|
||||
{ code: 'BSI-TR-03161-2', name: 'BSI-TR Teil 2' },
|
||||
{ code: 'BSI-TR-03161-3', name: 'BSI-TR Teil 3' },
|
||||
]
|
||||
|
||||
const TYPE_COLORS: Record<string, string> = {
|
||||
eu_regulation: 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400',
|
||||
eu_directive: 'bg-purple-100 text-purple-700 dark:bg-purple-900/30 dark:text-purple-400',
|
||||
de_law: 'bg-yellow-100 text-yellow-700 dark:bg-yellow-900/30 dark:text-yellow-400',
|
||||
bsi_standard: 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400',
|
||||
}
|
||||
|
||||
export default function QualityPage() {
|
||||
// Search state
|
||||
const [searchQuery, setSearchQuery] = useState('')
|
||||
const [searchResults, setSearchResults] = useState<ChunkDetail[]>([])
|
||||
const [searching, setSearching] = useState(false)
|
||||
const [selectedRegulation, setSelectedRegulation] = useState<string>('')
|
||||
const [topK, setTopK] = useState(10)
|
||||
|
||||
// Traceability state
|
||||
const [selectedChunk, setSelectedChunk] = useState<ChunkDetail | null>(null)
|
||||
const [traceability, setTraceability] = useState<TraceabilityResult | null>(null)
|
||||
const [loadingTrace, setLoadingTrace] = useState(false)
|
||||
|
||||
// Quick sample queries for auditors
|
||||
const sampleQueries = [
|
||||
{ label: 'Art. 17 DSGVO (Recht auf Loeschung)', query: 'Recht auf Löschung Artikel 17', reg: 'GDPR' },
|
||||
{ label: 'Einwilligung TDDDG', query: 'Einwilligung Endeinrichtung speichern', reg: 'TDDDG' },
|
||||
{ label: 'AI Act Hochrisiko', query: 'Hochrisiko-KI-System Anforderungen', reg: 'AIACT' },
|
||||
{ label: 'NIS2 Sicherheitsmaßnahmen', query: 'Cybersicherheitsrisikomanagement Maßnahmen', reg: 'NIS2' },
|
||||
{ label: 'BSI Authentifizierung', query: 'Authentifizierung Zwei-Faktor mobile', reg: 'BSI-TR-03161-1' },
|
||||
]
|
||||
|
||||
const handleSearch = useCallback(async () => {
|
||||
if (!searchQuery.trim()) return
|
||||
|
||||
setSearching(true)
|
||||
setSearchResults([])
|
||||
setSelectedChunk(null)
|
||||
setTraceability(null)
|
||||
|
||||
try {
|
||||
let url = `${API_PROXY}?action=search&query=${encodeURIComponent(searchQuery)}&top_k=${topK}`
|
||||
if (selectedRegulation) {
|
||||
url += `®ulations=${encodeURIComponent(selectedRegulation)}`
|
||||
}
|
||||
|
||||
const res = await fetch(url)
|
||||
if (res.ok) {
|
||||
const data = await res.json()
|
||||
setSearchResults(data.results || [])
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Search failed:', error)
|
||||
} finally {
|
||||
setSearching(false)
|
||||
}
|
||||
}, [searchQuery, selectedRegulation, topK])
|
||||
|
||||
const loadTraceability = useCallback(async (chunk: ChunkDetail) => {
|
||||
setSelectedChunk(chunk)
|
||||
setLoadingTrace(true)
|
||||
|
||||
try {
|
||||
// Try to load traceability (requirements and controls derived from this chunk)
|
||||
const res = await fetch(`${API_PROXY}?action=traceability&chunk_id=${encodeURIComponent(chunk.id || chunk.regulation_code + '_' + chunk.chunk_index)}®ulation=${encodeURIComponent(chunk.regulation_code)}`)
|
||||
|
||||
if (res.ok) {
|
||||
const data = await res.json()
|
||||
setTraceability({
|
||||
chunk,
|
||||
requirements: data.requirements || [],
|
||||
controls: data.controls || [],
|
||||
})
|
||||
} else {
|
||||
// If traceability endpoint doesn't exist yet, show placeholder
|
||||
setTraceability({
|
||||
chunk,
|
||||
requirements: [],
|
||||
controls: [],
|
||||
})
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load traceability:', error)
|
||||
setTraceability({
|
||||
chunk,
|
||||
requirements: [],
|
||||
controls: [],
|
||||
})
|
||||
} finally {
|
||||
setLoadingTrace(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const handleSampleQuery = (query: string, reg: string) => {
|
||||
setSearchQuery(query)
|
||||
setSelectedRegulation(reg)
|
||||
// Auto-search after setting
|
||||
setTimeout(() => {
|
||||
handleSearch()
|
||||
}, 100)
|
||||
}
|
||||
|
||||
const highlightText = (text: string, query: string) => {
|
||||
if (!query) return text
|
||||
const words = query.toLowerCase().split(' ').filter(w => w.length > 2)
|
||||
let result = text
|
||||
words.forEach(word => {
|
||||
const regex = new RegExp(`(${word})`, 'gi')
|
||||
result = result.replace(regex, '<mark class="bg-yellow-200 dark:bg-yellow-800 px-0.5 rounded">$1</mark>')
|
||||
})
|
||||
return result
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900 dark:text-white">
|
||||
Qualitaet & Audit
|
||||
</h1>
|
||||
<p className="mt-1 text-sm text-gray-500 dark:text-gray-400">
|
||||
Stichproben und Traceability fuer Compliance-Auditoren
|
||||
</p>
|
||||
</div>
|
||||
<Link
|
||||
href="/ai/rag"
|
||||
className="text-sm text-blue-600 hover:text-blue-800 dark:text-blue-400"
|
||||
>
|
||||
← Zurueck zu RAG
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<PagePurpose
|
||||
title="Audit-Werkzeuge"
|
||||
purpose="Pruefen Sie die Qualitaet der Compliance-Datenbank. Suchen Sie gezielt nach Paragraphen, Saetzen oder Begriffen und verfolgen Sie, wie Anforderungen und Controls abgeleitet wurden."
|
||||
audience={['Auditoren', 'Compliance-Beauftragte', 'Qualitaetssicherung']}
|
||||
architecture={{
|
||||
services: ['klausur-service', 'embedding-service', 'qdrant'],
|
||||
databases: ['Qdrant Vector DB']
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Quick Sample Queries */}
|
||||
<div className="bg-white dark:bg-slate-800 rounded-lg shadow p-4">
|
||||
<h3 className="text-sm font-medium text-gray-700 dark:text-gray-300 mb-3">
|
||||
Schnell-Stichproben
|
||||
</h3>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{sampleQueries.map((sq, idx) => (
|
||||
<button
|
||||
key={idx}
|
||||
onClick={() => handleSampleQuery(sq.query, sq.reg)}
|
||||
className="px-3 py-1.5 text-xs bg-gray-100 hover:bg-gray-200 dark:bg-slate-700 dark:hover:bg-slate-600 text-gray-700 dark:text-gray-300 rounded-full transition-colors"
|
||||
>
|
||||
{sq.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Search Section */}
|
||||
<div className="bg-white dark:bg-slate-800 rounded-lg shadow p-6">
|
||||
<h2 className="text-lg font-semibold text-gray-900 dark:text-white mb-4">
|
||||
Chunk-Suche
|
||||
</h2>
|
||||
|
||||
<div className="space-y-4">
|
||||
{/* Search Input */}
|
||||
<div className="flex gap-4">
|
||||
<div className="flex-1">
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
Suchbegriff / Paragraph / Artikeltext
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
onKeyDown={(e) => e.key === 'Enter' && handleSearch()}
|
||||
placeholder="z.B. 'Recht auf Löschung' oder 'Art. 17 Abs. 1'"
|
||||
className="w-full px-4 py-2 border border-gray-300 dark:border-slate-600 rounded-lg focus:ring-2 focus:ring-blue-500 dark:bg-slate-700 dark:text-white"
|
||||
/>
|
||||
</div>
|
||||
<div className="w-48">
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
Regulierung
|
||||
</label>
|
||||
<select
|
||||
value={selectedRegulation}
|
||||
onChange={(e) => setSelectedRegulation(e.target.value)}
|
||||
className="w-full px-3 py-2 border border-gray-300 dark:border-slate-600 rounded-lg focus:ring-2 focus:ring-blue-500 dark:bg-slate-700 dark:text-white"
|
||||
>
|
||||
<option value="">Alle</option>
|
||||
{REGULATIONS.map((reg) => (
|
||||
<option key={reg.code} value={reg.code}>
|
||||
{reg.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="w-24">
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
Anzahl
|
||||
</label>
|
||||
<select
|
||||
value={topK}
|
||||
onChange={(e) => setTopK(parseInt(e.target.value))}
|
||||
className="w-full px-3 py-2 border border-gray-300 dark:border-slate-600 rounded-lg focus:ring-2 focus:ring-blue-500 dark:bg-slate-700 dark:text-white"
|
||||
>
|
||||
<option value="5">5</option>
|
||||
<option value="10">10</option>
|
||||
<option value="20">20</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={handleSearch}
|
||||
disabled={searching || !searchQuery.trim()}
|
||||
className="px-6 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
{searching ? 'Suche laeuft...' : 'Suchen'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Results Grid */}
|
||||
{searchResults.length > 0 && (
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
{/* Search Results List */}
|
||||
<div className="bg-white dark:bg-slate-800 rounded-lg shadow p-4">
|
||||
<h3 className="text-md font-semibold text-gray-900 dark:text-white mb-4">
|
||||
Gefundene Chunks ({searchResults.length})
|
||||
</h3>
|
||||
<div className="space-y-3 max-h-[600px] overflow-y-auto">
|
||||
{searchResults.map((result, idx) => (
|
||||
<div
|
||||
key={idx}
|
||||
onClick={() => loadTraceability(result)}
|
||||
className={`p-4 border rounded-lg cursor-pointer transition-all ${
|
||||
selectedChunk?.text === result.text
|
||||
? 'border-blue-500 bg-blue-50 dark:bg-blue-900/20'
|
||||
: 'border-gray-200 dark:border-slate-700 hover:border-gray-300 dark:hover:border-slate-600'
|
||||
}`}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs font-medium px-2 py-0.5 bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400 rounded">
|
||||
{result.regulation_code}
|
||||
</span>
|
||||
{result.article && (
|
||||
<span className="text-xs text-gray-500 dark:text-gray-400">
|
||||
Art. {result.article}
|
||||
{result.paragraph && ` Abs. ${result.paragraph}`}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<span className="text-xs text-gray-400">
|
||||
Score: {(result.score || 0).toFixed(3)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Text Preview */}
|
||||
<p
|
||||
className="text-sm text-gray-700 dark:text-gray-300 line-clamp-4"
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: highlightText(result.text.substring(0, 400) + (result.text.length > 400 ? '...' : ''), searchQuery)
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Metadata */}
|
||||
<div className="mt-2 flex items-center gap-4 text-xs text-gray-400">
|
||||
<span>Chunk #{result.chunk_index || idx}</span>
|
||||
<span>{result.text.length} Zeichen</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Traceability Panel */}
|
||||
<div className="bg-white dark:bg-slate-800 rounded-lg shadow p-4">
|
||||
<h3 className="text-md font-semibold text-gray-900 dark:text-white mb-4">
|
||||
Traceability
|
||||
</h3>
|
||||
|
||||
{!selectedChunk ? (
|
||||
<div className="text-center py-12 text-gray-500 dark:text-gray-400">
|
||||
<svg className="w-12 h-12 mx-auto mb-4 opacity-50" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
<p>Waehlen Sie einen Chunk aus der Liste, um die Traceability zu sehen.</p>
|
||||
</div>
|
||||
) : loadingTrace ? (
|
||||
<div className="text-center py-12">
|
||||
<div className="animate-spin w-8 h-8 border-2 border-blue-500 border-t-transparent rounded-full mx-auto mb-4"></div>
|
||||
<p className="text-gray-500 dark:text-gray-400">Lade Traceability...</p>
|
||||
</div>
|
||||
) : traceability ? (
|
||||
<div className="space-y-6">
|
||||
{/* Selected Chunk Detail */}
|
||||
<div className="border-l-4 border-blue-500 pl-4">
|
||||
<h4 className="text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
📄 Ausgewaehlter Chunk
|
||||
</h4>
|
||||
<div className="bg-gray-50 dark:bg-slate-700 rounded p-3">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<span className="text-xs font-medium px-2 py-0.5 bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400 rounded">
|
||||
{traceability.chunk.regulation_code}
|
||||
</span>
|
||||
{traceability.chunk.article && (
|
||||
<span className="text-xs text-gray-500 dark:text-gray-400">
|
||||
Art. {traceability.chunk.article}
|
||||
{traceability.chunk.paragraph && ` Abs. ${traceability.chunk.paragraph}`}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-300 whitespace-pre-wrap">
|
||||
{traceability.chunk.text}
|
||||
</p>
|
||||
{traceability.chunk.source_url && (
|
||||
<a
|
||||
href={traceability.chunk.source_url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="mt-2 inline-flex items-center gap-1 text-xs text-blue-600 hover:underline"
|
||||
>
|
||||
🔗 Quelle oeffnen
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Arrow Down */}
|
||||
<div className="flex justify-center">
|
||||
<svg className="w-6 h-6 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 14l-7 7m0 0l-7-7m7 7V3" />
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
{/* Requirements */}
|
||||
<div className="border-l-4 border-orange-500 pl-4">
|
||||
<h4 className="text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
📋 Extrahierte Anforderungen ({traceability.requirements.length})
|
||||
</h4>
|
||||
{traceability.requirements.length > 0 ? (
|
||||
<div className="space-y-2">
|
||||
{traceability.requirements.map((req, idx) => (
|
||||
<div key={idx} className="bg-orange-50 dark:bg-orange-900/20 rounded p-3">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<span className="text-xs font-medium text-orange-700 dark:text-orange-400">
|
||||
{req.category || 'Anforderung'}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-300">{req.text}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400 italic">
|
||||
Keine Anforderungen aus diesem Chunk extrahiert.
|
||||
<br />
|
||||
<span className="text-xs">(Requirements-Extraktion ist noch nicht implementiert)</span>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Arrow Down */}
|
||||
<div className="flex justify-center">
|
||||
<svg className="w-6 h-6 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 14l-7 7m0 0l-7-7m7 7V3" />
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
{/* Controls */}
|
||||
<div className="border-l-4 border-green-500 pl-4">
|
||||
<h4 className="text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
✅ Abgeleitete Controls ({traceability.controls.length})
|
||||
</h4>
|
||||
{traceability.controls.length > 0 ? (
|
||||
<div className="space-y-2">
|
||||
{traceability.controls.map((ctrl, idx) => (
|
||||
<div key={idx} className="bg-green-50 dark:bg-green-900/20 rounded p-3">
|
||||
<div className="font-medium text-sm text-green-700 dark:text-green-400 mb-1">
|
||||
{ctrl.name}
|
||||
</div>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-300">{ctrl.description}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400 italic">
|
||||
Keine Controls aus diesem Chunk abgeleitet.
|
||||
<br />
|
||||
<span className="text-xs">(Control-Ableitung ist noch nicht implementiert)</span>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Empty State */}
|
||||
{!searching && searchResults.length === 0 && searchQuery && (
|
||||
<div className="bg-white dark:bg-slate-800 rounded-lg shadow p-12 text-center">
|
||||
<svg className="w-16 h-16 mx-auto mb-4 text-gray-300 dark:text-gray-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9.172 16.172a4 4 0 015.656 0M9 10h.01M15 10h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
<h3 className="text-lg font-medium text-gray-900 dark:text-white mb-2">
|
||||
Keine Ergebnisse gefunden
|
||||
</h3>
|
||||
<p className="text-gray-500 dark:text-gray-400">
|
||||
Versuchen Sie einen anderen Suchbegriff oder waehlen Sie eine andere Regulierung.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Initial State */}
|
||||
{!searching && searchResults.length === 0 && !searchQuery && (
|
||||
<div className="bg-white dark:bg-slate-800 rounded-lg shadow p-12 text-center">
|
||||
<svg className="w-16 h-16 mx-auto mb-4 text-gray-300 dark:text-gray-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
|
||||
</svg>
|
||||
<h3 className="text-lg font-medium text-gray-900 dark:text-white mb-2">
|
||||
Bereit fuer Stichproben
|
||||
</h3>
|
||||
<p className="text-gray-500 dark:text-gray-400 max-w-md mx-auto">
|
||||
Geben Sie einen Suchbegriff ein, um Chunks zu finden. Sie koennen nach Artikeln,
|
||||
Paragraphen oder spezifischen Textpassagen suchen.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Audit Info */}
|
||||
<div className="bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg p-4">
|
||||
<h3 className="text-sm font-medium text-blue-800 dark:text-blue-400 mb-2">
|
||||
ℹ️ Hinweise fuer Auditoren
|
||||
</h3>
|
||||
<ul className="text-sm text-blue-700 dark:text-blue-300 space-y-1 list-disc list-inside">
|
||||
<li>Die Suche ist semantisch - aehnliche Begriffe werden gefunden, auch wenn die exakte Formulierung abweicht</li>
|
||||
<li>Jeder Chunk entspricht einem logischen Textabschnitt aus dem Originaldokument</li>
|
||||
<li>Die Traceability zeigt, wie aus dem Originaltext Anforderungen und Controls abgeleitet wurden</li>
|
||||
<li>Klicken Sie auf "Quelle oeffnen", um das Originaldokument zu pruefen</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,674 @@
|
||||
'use client'
|
||||
|
||||
/**
|
||||
* DSFA Document Manager
|
||||
*
|
||||
* Manages DSFA-related sources and documents for the RAG pipeline.
|
||||
* Features:
|
||||
* - View all registered DSFA sources with license info
|
||||
* - Upload new documents
|
||||
* - Trigger re-indexing
|
||||
* - View corpus statistics
|
||||
*/
|
||||
|
||||
import { useState, useEffect } from 'react'
|
||||
import Link from 'next/link'
|
||||
import {
|
||||
ArrowLeft,
|
||||
RefreshCw,
|
||||
Upload,
|
||||
FileText,
|
||||
Database,
|
||||
Scale,
|
||||
ExternalLink,
|
||||
ChevronDown,
|
||||
ChevronUp,
|
||||
Search,
|
||||
Filter,
|
||||
CheckCircle,
|
||||
Clock,
|
||||
AlertCircle,
|
||||
BookOpen
|
||||
} from 'lucide-react'
|
||||
import {
|
||||
DSFASource,
|
||||
DSFACorpusStats,
|
||||
DSFASourceStats,
|
||||
DSFALicenseCode,
|
||||
DSFA_LICENSE_LABELS,
|
||||
DSFA_DOCUMENT_TYPE_LABELS
|
||||
} from '@/lib/sdk/types'
|
||||
|
||||
// ============================================================================
|
||||
// TYPES
|
||||
// ============================================================================
|
||||
|
||||
interface APIError {
|
||||
message: string
|
||||
status?: number
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// API FUNCTIONS
|
||||
// ============================================================================
|
||||
|
||||
const API_BASE = process.env.NEXT_PUBLIC_KLAUSUR_SERVICE_URL || 'http://localhost:8086'
|
||||
|
||||
async function fetchSources(): Promise<DSFASource[]> {
|
||||
try {
|
||||
const response = await fetch(`${API_BASE}/api/v1/dsfa-rag/sources`)
|
||||
if (!response.ok) throw new Error('Failed to fetch sources')
|
||||
return await response.json()
|
||||
} catch {
|
||||
// Return mock data for demo
|
||||
return MOCK_SOURCES
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchStats(): Promise<DSFACorpusStats> {
|
||||
try {
|
||||
const response = await fetch(`${API_BASE}/api/v1/dsfa-rag/stats`)
|
||||
if (!response.ok) throw new Error('Failed to fetch stats')
|
||||
return await response.json()
|
||||
} catch {
|
||||
return MOCK_STATS
|
||||
}
|
||||
}
|
||||
|
||||
async function initializeCorpus(): Promise<{ sources_registered: number }> {
|
||||
const response = await fetch(`${API_BASE}/api/v1/dsfa-rag/init`, {
|
||||
method: 'POST',
|
||||
})
|
||||
if (!response.ok) throw new Error('Failed to initialize corpus')
|
||||
return await response.json()
|
||||
}
|
||||
|
||||
async function triggerIngestion(sourceCode: string): Promise<void> {
|
||||
const response = await fetch(`${API_BASE}/api/v1/dsfa-rag/sources/${sourceCode}/ingest`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({}),
|
||||
})
|
||||
if (!response.ok) throw new Error('Failed to trigger ingestion')
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// MOCK DATA
|
||||
// ============================================================================
|
||||
|
||||
const MOCK_SOURCES: DSFASource[] = [
|
||||
{
|
||||
id: '1',
|
||||
sourceCode: 'WP248',
|
||||
name: 'WP248 rev.01 - Leitlinien zur DSFA',
|
||||
fullName: 'Leitlinien zur Datenschutz-Folgenabschaetzung',
|
||||
organization: 'Artikel-29-Datenschutzgruppe / EDPB',
|
||||
sourceUrl: 'https://ec.europa.eu/newsroom/article29/items/611236/en',
|
||||
licenseCode: 'EDPB-LICENSE',
|
||||
licenseName: 'EDPB Document License',
|
||||
attributionRequired: true,
|
||||
attributionText: 'Quelle: WP248 rev.01, Artikel-29-Datenschutzgruppe (2017)',
|
||||
documentType: 'guideline',
|
||||
language: 'de',
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
sourceCode: 'DSK_KP5',
|
||||
name: 'Kurzpapier Nr. 5 - DSFA nach Art. 35 DS-GVO',
|
||||
organization: 'Datenschutzkonferenz (DSK)',
|
||||
sourceUrl: 'https://www.datenschutzkonferenz-online.de/media/kp/dsk_kpnr_5.pdf',
|
||||
licenseCode: 'DL-DE-BY-2.0',
|
||||
licenseName: 'Datenlizenz DE – Namensnennung 2.0',
|
||||
licenseUrl: 'https://www.govdata.de/dl-de/by-2-0',
|
||||
attributionRequired: true,
|
||||
attributionText: 'Quelle: DSK Kurzpapier Nr. 5 (Stand: 2018)',
|
||||
documentType: 'guideline',
|
||||
language: 'de',
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
sourceCode: 'BFDI_MUSS_PUBLIC',
|
||||
name: 'BfDI DSFA-Liste (oeffentlicher Bereich)',
|
||||
organization: 'BfDI',
|
||||
sourceUrl: 'https://www.bfdi.bund.de',
|
||||
licenseCode: 'DL-DE-ZERO-2.0',
|
||||
licenseName: 'Datenlizenz DE – Zero 2.0',
|
||||
attributionRequired: false,
|
||||
attributionText: 'Quelle: BfDI, Liste gem. Art. 35 Abs. 4 DSGVO',
|
||||
documentType: 'checklist',
|
||||
language: 'de',
|
||||
},
|
||||
{
|
||||
id: '4',
|
||||
sourceCode: 'NI_MUSS_PRIVATE',
|
||||
name: 'LfD NI DSFA-Liste (nicht-oeffentlich)',
|
||||
organization: 'LfD Niedersachsen',
|
||||
sourceUrl: 'https://www.lfd.niedersachsen.de/download/131098',
|
||||
licenseCode: 'DL-DE-BY-2.0',
|
||||
licenseName: 'Datenlizenz DE – Namensnennung 2.0',
|
||||
attributionRequired: true,
|
||||
attributionText: 'Quelle: LfD Niedersachsen, DSFA-Muss-Liste',
|
||||
documentType: 'checklist',
|
||||
language: 'de',
|
||||
},
|
||||
]
|
||||
|
||||
const MOCK_STATS: DSFACorpusStats = {
|
||||
sources: [
|
||||
{
|
||||
sourceId: '1',
|
||||
sourceCode: 'WP248',
|
||||
name: 'WP248 rev.01',
|
||||
organization: 'EDPB',
|
||||
licenseCode: 'EDPB-LICENSE',
|
||||
documentType: 'guideline',
|
||||
documentCount: 1,
|
||||
chunkCount: 50,
|
||||
lastIndexedAt: '2026-02-09T10:00:00Z',
|
||||
},
|
||||
{
|
||||
sourceId: '2',
|
||||
sourceCode: 'DSK_KP5',
|
||||
name: 'DSK Kurzpapier Nr. 5',
|
||||
organization: 'DSK',
|
||||
licenseCode: 'DL-DE-BY-2.0',
|
||||
documentType: 'guideline',
|
||||
documentCount: 1,
|
||||
chunkCount: 35,
|
||||
lastIndexedAt: '2026-02-09T10:00:00Z',
|
||||
},
|
||||
],
|
||||
totalSources: 45,
|
||||
totalDocuments: 45,
|
||||
totalChunks: 850,
|
||||
qdrantCollection: 'bp_dsfa_corpus',
|
||||
qdrantPointsCount: 850,
|
||||
qdrantStatus: 'green',
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// COMPONENTS
|
||||
// ============================================================================
|
||||
|
||||
function LicenseBadge({ licenseCode }: { licenseCode: DSFALicenseCode }) {
|
||||
const colorMap: Record<DSFALicenseCode, string> = {
|
||||
'DL-DE-BY-2.0': 'bg-blue-100 text-blue-700 border-blue-200',
|
||||
'DL-DE-ZERO-2.0': 'bg-gray-100 text-gray-700 border-gray-200',
|
||||
'CC-BY-4.0': 'bg-green-100 text-green-700 border-green-200',
|
||||
'EDPB-LICENSE': 'bg-purple-100 text-purple-700 border-purple-200',
|
||||
'PUBLIC_DOMAIN': 'bg-gray-100 text-gray-600 border-gray-200',
|
||||
'PROPRIETARY': 'bg-amber-100 text-amber-700 border-amber-200',
|
||||
}
|
||||
|
||||
return (
|
||||
<span className={`inline-flex items-center gap-1 px-2 py-0.5 rounded text-xs border ${colorMap[licenseCode] || 'bg-gray-100 text-gray-700 border-gray-200'}`}>
|
||||
<Scale className="w-3 h-3" />
|
||||
{DSFA_LICENSE_LABELS[licenseCode] || licenseCode}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
function DocumentTypeBadge({ type }: { type?: string }) {
|
||||
if (!type) return null
|
||||
|
||||
const colorMap: Record<string, string> = {
|
||||
guideline: 'bg-indigo-100 text-indigo-700',
|
||||
checklist: 'bg-emerald-100 text-emerald-700',
|
||||
regulation: 'bg-red-100 text-red-700',
|
||||
template: 'bg-orange-100 text-orange-700',
|
||||
}
|
||||
|
||||
return (
|
||||
<span className={`inline-flex items-center px-2 py-0.5 rounded text-xs ${colorMap[type] || 'bg-gray-100 text-gray-700'}`}>
|
||||
{DSFA_DOCUMENT_TYPE_LABELS[type as keyof typeof DSFA_DOCUMENT_TYPE_LABELS] || type}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
function StatusIndicator({ status }: { status: string }) {
|
||||
const statusConfig: Record<string, { color: string; icon: React.ReactNode; label: string }> = {
|
||||
green: { color: 'text-green-500', icon: <CheckCircle className="w-4 h-4" />, label: 'Aktiv' },
|
||||
yellow: { color: 'text-yellow-500', icon: <Clock className="w-4 h-4" />, label: 'Ausstehend' },
|
||||
red: { color: 'text-red-500', icon: <AlertCircle className="w-4 h-4" />, label: 'Fehler' },
|
||||
}
|
||||
|
||||
const config = statusConfig[status] || statusConfig.yellow
|
||||
|
||||
return (
|
||||
<span className={`inline-flex items-center gap-1 ${config.color}`}>
|
||||
{config.icon}
|
||||
<span className="text-sm">{config.label}</span>
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
function SourceCard({
|
||||
source,
|
||||
stats,
|
||||
onIngest,
|
||||
isIngesting
|
||||
}: {
|
||||
source: DSFASource
|
||||
stats?: DSFASourceStats
|
||||
onIngest: () => void
|
||||
isIngesting: boolean
|
||||
}) {
|
||||
const [isExpanded, setIsExpanded] = useState(false)
|
||||
|
||||
return (
|
||||
<div className="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 overflow-hidden">
|
||||
<div className="p-4">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<span className="font-mono text-xs bg-gray-100 dark:bg-gray-700 px-2 py-0.5 rounded">
|
||||
{source.sourceCode}
|
||||
</span>
|
||||
<DocumentTypeBadge type={source.documentType} />
|
||||
</div>
|
||||
<h3 className="font-semibold text-gray-900 dark:text-white truncate">
|
||||
{source.name}
|
||||
</h3>
|
||||
{source.organization && (
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">
|
||||
{source.organization}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setIsExpanded(!isExpanded)}
|
||||
className="p-1 hover:bg-gray-100 dark:hover:bg-gray-700 rounded"
|
||||
>
|
||||
{isExpanded ? (
|
||||
<ChevronUp className="w-5 h-5 text-gray-400" />
|
||||
) : (
|
||||
<ChevronDown className="w-5 h-5 text-gray-400" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-4 mt-3">
|
||||
<LicenseBadge licenseCode={source.licenseCode} />
|
||||
{stats && (
|
||||
<>
|
||||
<span className="text-sm text-gray-500">
|
||||
{stats.documentCount} Dok.
|
||||
</span>
|
||||
<span className="text-sm text-gray-500">
|
||||
{stats.chunkCount} Chunks
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{source.attributionRequired && (
|
||||
<div className="mt-3 p-2 bg-amber-50 dark:bg-amber-900/20 rounded text-xs text-amber-700 dark:text-amber-300">
|
||||
<strong>Attribution:</strong> {source.attributionText}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isExpanded && (
|
||||
<div className="border-t border-gray-200 dark:border-gray-700 p-4 bg-gray-50 dark:bg-gray-900">
|
||||
<dl className="grid grid-cols-2 gap-3 text-sm">
|
||||
{source.sourceUrl && (
|
||||
<>
|
||||
<dt className="text-gray-500">Quelle:</dt>
|
||||
<dd>
|
||||
<a
|
||||
href={source.sourceUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-blue-600 hover:underline flex items-center gap-1"
|
||||
>
|
||||
Link <ExternalLink className="w-3 h-3" />
|
||||
</a>
|
||||
</dd>
|
||||
</>
|
||||
)}
|
||||
{source.licenseUrl && (
|
||||
<>
|
||||
<dt className="text-gray-500">Lizenz-URL:</dt>
|
||||
<dd>
|
||||
<a
|
||||
href={source.licenseUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-blue-600 hover:underline flex items-center gap-1"
|
||||
>
|
||||
{source.licenseName} <ExternalLink className="w-3 h-3" />
|
||||
</a>
|
||||
</dd>
|
||||
</>
|
||||
)}
|
||||
<dt className="text-gray-500">Sprache:</dt>
|
||||
<dd className="uppercase">{source.language}</dd>
|
||||
{stats?.lastIndexedAt && (
|
||||
<>
|
||||
<dt className="text-gray-500">Zuletzt indexiert:</dt>
|
||||
<dd>{new Date(stats.lastIndexedAt).toLocaleString('de-DE')}</dd>
|
||||
</>
|
||||
)}
|
||||
</dl>
|
||||
|
||||
<div className="mt-4 flex gap-2">
|
||||
<button
|
||||
onClick={onIngest}
|
||||
disabled={isIngesting}
|
||||
className="px-3 py-1.5 text-sm bg-blue-600 text-white rounded-lg hover:bg-blue-700 disabled:opacity-50 flex items-center gap-1"
|
||||
>
|
||||
{isIngesting ? (
|
||||
<RefreshCw className="w-4 h-4 animate-spin" />
|
||||
) : (
|
||||
<RefreshCw className="w-4 h-4" />
|
||||
)}
|
||||
Neu indexieren
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function StatsOverview({ stats }: { stats: DSFACorpusStats }) {
|
||||
return (
|
||||
<div className="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 p-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h2 className="text-lg font-semibold text-gray-900 dark:text-white flex items-center gap-2">
|
||||
<Database className="w-5 h-5" />
|
||||
Corpus-Statistik
|
||||
</h2>
|
||||
<StatusIndicator status={stats.qdrantStatus} />
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
<div className="text-center p-4 bg-blue-50 dark:bg-blue-900/20 rounded-lg">
|
||||
<p className="text-2xl font-bold text-blue-600 dark:text-blue-400">
|
||||
{stats.totalSources}
|
||||
</p>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">Quellen</p>
|
||||
</div>
|
||||
<div className="text-center p-4 bg-emerald-50 dark:bg-emerald-900/20 rounded-lg">
|
||||
<p className="text-2xl font-bold text-emerald-600 dark:text-emerald-400">
|
||||
{stats.totalDocuments}
|
||||
</p>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">Dokumente</p>
|
||||
</div>
|
||||
<div className="text-center p-4 bg-purple-50 dark:bg-purple-900/20 rounded-lg">
|
||||
<p className="text-2xl font-bold text-purple-600 dark:text-purple-400">
|
||||
{stats.totalChunks.toLocaleString()}
|
||||
</p>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">Chunks</p>
|
||||
</div>
|
||||
<div className="text-center p-4 bg-orange-50 dark:bg-orange-900/20 rounded-lg">
|
||||
<p className="text-2xl font-bold text-orange-600 dark:text-orange-400">
|
||||
{stats.qdrantPointsCount.toLocaleString()}
|
||||
</p>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">Vektoren</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 p-3 bg-gray-50 dark:bg-gray-900 rounded-lg">
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">
|
||||
<strong>Collection:</strong>{' '}
|
||||
<code className="font-mono bg-gray-200 dark:bg-gray-700 px-1 rounded">
|
||||
{stats.qdrantCollection}
|
||||
</code>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// MAIN PAGE
|
||||
// ============================================================================
|
||||
|
||||
export default function DSFADocumentManagerPage() {
|
||||
const [sources, setSources] = useState<DSFASource[]>([])
|
||||
const [stats, setStats] = useState<DSFACorpusStats | null>(null)
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [searchQuery, setSearchQuery] = useState('')
|
||||
const [filterType, setFilterType] = useState<string>('all')
|
||||
const [ingestingSource, setIngestingSource] = useState<string | null>(null)
|
||||
const [isInitializing, setIsInitializing] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
async function loadData() {
|
||||
setIsLoading(true)
|
||||
try {
|
||||
const [sourcesData, statsData] = await Promise.all([
|
||||
fetchSources(),
|
||||
fetchStats(),
|
||||
])
|
||||
setSources(sourcesData)
|
||||
setStats(statsData)
|
||||
setError(null)
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to load data')
|
||||
setSources(MOCK_SOURCES)
|
||||
setStats(MOCK_STATS)
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
loadData()
|
||||
}, [])
|
||||
|
||||
const handleInitialize = async () => {
|
||||
setIsInitializing(true)
|
||||
try {
|
||||
await initializeCorpus()
|
||||
// Reload data
|
||||
const [sourcesData, statsData] = await Promise.all([
|
||||
fetchSources(),
|
||||
fetchStats(),
|
||||
])
|
||||
setSources(sourcesData)
|
||||
setStats(statsData)
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to initialize')
|
||||
} finally {
|
||||
setIsInitializing(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleIngest = async (sourceCode: string) => {
|
||||
setIngestingSource(sourceCode)
|
||||
try {
|
||||
await triggerIngestion(sourceCode)
|
||||
// Reload stats
|
||||
const statsData = await fetchStats()
|
||||
setStats(statsData)
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to ingest')
|
||||
} finally {
|
||||
setIngestingSource(null)
|
||||
}
|
||||
}
|
||||
|
||||
// Filter sources
|
||||
const filteredSources = sources.filter(source => {
|
||||
const matchesSearch = searchQuery === '' ||
|
||||
source.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
source.sourceCode.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
source.organization?.toLowerCase().includes(searchQuery.toLowerCase())
|
||||
|
||||
const matchesType = filterType === 'all' || source.documentType === filterType
|
||||
|
||||
return matchesSearch && matchesType
|
||||
})
|
||||
|
||||
// Get stats by source code
|
||||
const getStatsForSource = (sourceCode: string): DSFASourceStats | undefined => {
|
||||
return stats?.sources.find(s => s.sourceCode === sourceCode)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-gray-50 to-gray-100 dark:from-gray-900 dark:to-gray-800 p-8">
|
||||
<div className="max-w-6xl mx-auto">
|
||||
{/* Header */}
|
||||
<div className="mb-8">
|
||||
<Link
|
||||
href="/ai/rag-pipeline"
|
||||
className="inline-flex items-center gap-2 text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white mb-4"
|
||||
>
|
||||
<ArrowLeft className="w-4 h-4" />
|
||||
Zurueck zur RAG-Pipeline
|
||||
</Link>
|
||||
|
||||
<div className="flex justify-between items-start">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-gray-900 dark:text-white flex items-center gap-3">
|
||||
<BookOpen className="w-8 h-8 text-blue-600" />
|
||||
DSFA-Quellen Manager
|
||||
</h1>
|
||||
<p className="text-gray-500 dark:text-gray-400 mt-1">
|
||||
Verwalten Sie DSFA-Guidance Dokumente mit vollstaendiger Lizenzattribution
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={handleInitialize}
|
||||
disabled={isInitializing}
|
||||
className="px-4 py-2 bg-emerald-600 text-white rounded-lg hover:bg-emerald-700 disabled:opacity-50 flex items-center gap-2"
|
||||
>
|
||||
{isInitializing ? (
|
||||
<RefreshCw className="w-4 h-4 animate-spin" />
|
||||
) : (
|
||||
<Database className="w-4 h-4" />
|
||||
)}
|
||||
Initialisieren
|
||||
</button>
|
||||
<button className="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 flex items-center gap-2">
|
||||
<Upload className="w-4 h-4" />
|
||||
Dokument hochladen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Error Banner */}
|
||||
{error && (
|
||||
<div className="mb-6 p-4 bg-red-50 dark:bg-red-900/30 border border-red-200 dark:border-red-700 rounded-xl">
|
||||
<div className="flex items-center gap-3">
|
||||
<AlertCircle className="w-5 h-5 text-red-500" />
|
||||
<span className="text-red-800 dark:text-red-200">{error}</span>
|
||||
<button
|
||||
onClick={() => setError(null)}
|
||||
className="ml-auto text-red-600 hover:text-red-800"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Stats Overview */}
|
||||
{stats && <StatsOverview stats={stats} />}
|
||||
|
||||
{/* Search & Filter */}
|
||||
<div className="mt-6 flex gap-4">
|
||||
<div className="flex-1 relative">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-gray-400" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Quellen durchsuchen..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="w-full pl-10 pr-4 py-2 border border-gray-200 dark:border-gray-700 rounded-lg bg-white dark:bg-gray-800"
|
||||
/>
|
||||
</div>
|
||||
<div className="relative">
|
||||
<Filter className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400" />
|
||||
<select
|
||||
value={filterType}
|
||||
onChange={(e) => setFilterType(e.target.value)}
|
||||
className="pl-9 pr-8 py-2 border border-gray-200 dark:border-gray-700 rounded-lg bg-white dark:bg-gray-800 appearance-none"
|
||||
>
|
||||
<option value="all">Alle Typen</option>
|
||||
<option value="guideline">Leitlinien</option>
|
||||
<option value="checklist">Prueflisten</option>
|
||||
<option value="regulation">Verordnungen</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Sources List */}
|
||||
<div className="mt-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h2 className="text-lg font-semibold text-gray-900 dark:text-white">
|
||||
Registrierte Quellen ({filteredSources.length})
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center py-20">
|
||||
<div className="text-center">
|
||||
<div className="w-12 h-12 border-4 border-blue-200 border-t-blue-600 rounded-full animate-spin mx-auto mb-4" />
|
||||
<p className="text-gray-500 dark:text-gray-400">Lade Quellen...</p>
|
||||
</div>
|
||||
</div>
|
||||
) : filteredSources.length === 0 ? (
|
||||
<div className="text-center py-12 bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700">
|
||||
<FileText className="w-12 h-12 text-gray-400 mx-auto mb-4" />
|
||||
<p className="text-gray-500 dark:text-gray-400">
|
||||
{searchQuery || filterType !== 'all'
|
||||
? 'Keine Quellen gefunden'
|
||||
: 'Noch keine Quellen registriert'}
|
||||
</p>
|
||||
{!searchQuery && filterType === 'all' && (
|
||||
<button
|
||||
onClick={handleInitialize}
|
||||
className="mt-4 px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700"
|
||||
>
|
||||
Quellen initialisieren
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid gap-4">
|
||||
{filteredSources.map(source => (
|
||||
<SourceCard
|
||||
key={source.id}
|
||||
source={source}
|
||||
stats={getStatsForSource(source.sourceCode)}
|
||||
onIngest={() => handleIngest(source.sourceCode)}
|
||||
isIngesting={ingestingSource === source.sourceCode}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Info Box */}
|
||||
<div className="mt-8 p-6 bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-xl">
|
||||
<h3 className="font-semibold text-blue-900 dark:text-blue-100 mb-2">
|
||||
Ueber die Lizenzattribution
|
||||
</h3>
|
||||
<p className="text-sm text-blue-800 dark:text-blue-200 mb-4">
|
||||
Alle DSFA-Quellen werden mit vollstaendiger Lizenzinformation gespeichert.
|
||||
Bei der Nutzung der RAG-Suche werden automatisch die korrekten Attributionen angezeigt.
|
||||
</p>
|
||||
<div className="grid grid-cols-2 md:grid-cols-3 gap-3 text-sm">
|
||||
<div className="flex items-center gap-2">
|
||||
<LicenseBadge licenseCode="DL-DE-BY-2.0" />
|
||||
<span className="text-blue-700 dark:text-blue-300">Namensnennung</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<LicenseBadge licenseCode="DL-DE-ZERO-2.0" />
|
||||
<span className="text-blue-700 dark:text-blue-300">Keine Attribution</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<LicenseBadge licenseCode="CC-BY-4.0" />
|
||||
<span className="text-blue-700 dark:text-blue-300">CC Attribution</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,52 @@
|
||||
/**
|
||||
* TypeScript Types for BQAS (Breakpilot Quality Assurance System)
|
||||
*/
|
||||
|
||||
export interface TestResult {
|
||||
test_id: string
|
||||
test_name: string
|
||||
passed: boolean
|
||||
composite_score: number
|
||||
intent_accuracy: number
|
||||
faithfulness: number
|
||||
relevance: number
|
||||
coherence: number
|
||||
safety: string
|
||||
reasoning: string
|
||||
expected_intent: string
|
||||
detected_intent: string
|
||||
}
|
||||
|
||||
export interface TestRun {
|
||||
id: number
|
||||
timestamp: string
|
||||
git_commit: string
|
||||
golden_score: number
|
||||
synthetic_score: number
|
||||
total_tests: number
|
||||
passed_tests: number
|
||||
failed_tests: number
|
||||
duration_seconds: number
|
||||
}
|
||||
|
||||
export interface BQASMetrics {
|
||||
total_tests: number
|
||||
passed_tests: number
|
||||
failed_tests: number
|
||||
avg_intent_accuracy: number
|
||||
avg_faithfulness: number
|
||||
avg_relevance: number
|
||||
avg_coherence: number
|
||||
safety_pass_rate: number
|
||||
avg_composite_score: number
|
||||
scores_by_intent: Record<string, number>
|
||||
failed_test_ids: string[]
|
||||
}
|
||||
|
||||
export interface TrendData {
|
||||
dates: string[]
|
||||
scores: number[]
|
||||
trend: 'improving' | 'stable' | 'declining' | 'insufficient_data'
|
||||
}
|
||||
|
||||
export type TabType = 'overview' | 'golden' | 'rag' | 'synthetic' | 'history' | 'guide'
|
||||
@@ -0,0 +1,152 @@
|
||||
'use client'
|
||||
|
||||
/**
|
||||
* Architecture Overview Page
|
||||
*
|
||||
* Central view of all backend modules and their connections.
|
||||
* Helps track migration progress and ensure no modules are lost.
|
||||
*/
|
||||
|
||||
import { useState } from 'react'
|
||||
import { PagePurpose } from '@/components/common/PagePurpose'
|
||||
import { ArchitectureView } from '@/components/common/ArchitectureView'
|
||||
import { DataFlowDiagram } from '@/components/common/DataFlowDiagram'
|
||||
import { getModuleStats } from '@/lib/module-registry'
|
||||
|
||||
type ViewMode = 'list' | 'diagram'
|
||||
|
||||
export default function ArchitecturePage() {
|
||||
const [viewMode, setViewMode] = useState<ViewMode>('list')
|
||||
const stats = getModuleStats()
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<PagePurpose
|
||||
title="Architektur-Uebersicht"
|
||||
purpose="Zentrale Uebersicht aller Backend-Module und deren Verbindung zum Frontend. Dient zur Sicherstellung, dass bei der Migration keine Module verloren gehen."
|
||||
audience={['Entwickler', 'DevOps', 'Architekten', 'Auditoren']}
|
||||
architecture={{
|
||||
services: ['consent-service', 'python-backend', 'klausur-service', 'voice-service'],
|
||||
databases: ['PostgreSQL', 'Qdrant']
|
||||
}}
|
||||
relatedPages={[
|
||||
{ name: 'Compliance Hub', href: '/sdk/compliance-hub', description: 'Compliance-Module' },
|
||||
{ name: 'AI Hub', href: '/ai', description: 'KI-Module' },
|
||||
]}
|
||||
/>
|
||||
|
||||
{/* Summary Cards */}
|
||||
<div className="grid grid-cols-4 gap-4">
|
||||
<div className="bg-white rounded-xl shadow-sm border p-4">
|
||||
<div className="text-sm text-slate-500 mb-1">Migrations-Fortschritt</div>
|
||||
<div className="text-3xl font-bold text-purple-600">{stats.percentComplete}%</div>
|
||||
<div className="text-sm text-slate-400">{stats.connected} von {stats.total} Modulen</div>
|
||||
</div>
|
||||
<div className="bg-white rounded-xl shadow-sm border p-4">
|
||||
<div className="text-sm text-slate-500 mb-1">Verbunden</div>
|
||||
<div className="text-3xl font-bold text-green-600">{stats.connected}</div>
|
||||
<div className="text-sm text-green-500">Vollstaendig migriert</div>
|
||||
</div>
|
||||
<div className="bg-white rounded-xl shadow-sm border p-4">
|
||||
<div className="text-sm text-slate-500 mb-1">Teilweise verbunden</div>
|
||||
<div className="text-3xl font-bold text-yellow-600">{stats.partial}</div>
|
||||
<div className="text-sm text-yellow-500">In Bearbeitung</div>
|
||||
</div>
|
||||
<div className="bg-white rounded-xl shadow-sm border p-4">
|
||||
<div className="text-sm text-slate-500 mb-1">Nicht verbunden</div>
|
||||
<div className="text-3xl font-bold text-red-600">{stats.notConnected}</div>
|
||||
<div className="text-sm text-red-500">Noch zu migrieren</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* View Toggle */}
|
||||
<div className="bg-white rounded-xl shadow-sm border p-4">
|
||||
<div className="flex items-center gap-4">
|
||||
<span className="text-sm font-medium text-slate-700">Ansicht:</span>
|
||||
<div className="flex rounded-lg border border-slate-200 overflow-hidden">
|
||||
<button
|
||||
onClick={() => setViewMode('list')}
|
||||
className={`px-4 py-2 text-sm font-medium transition-colors ${
|
||||
viewMode === 'list'
|
||||
? 'bg-purple-600 text-white'
|
||||
: 'bg-white text-slate-600 hover:bg-slate-50'
|
||||
}`}
|
||||
>
|
||||
Modul-Liste
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setViewMode('diagram')}
|
||||
className={`px-4 py-2 text-sm font-medium transition-colors ${
|
||||
viewMode === 'diagram'
|
||||
? 'bg-purple-600 text-white'
|
||||
: 'bg-white text-slate-600 hover:bg-slate-50'
|
||||
}`}
|
||||
>
|
||||
Datenfluss-Diagramm
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Content based on view mode */}
|
||||
{viewMode === 'list' ? (
|
||||
<ArchitectureView showAllCategories />
|
||||
) : (
|
||||
<DataFlowDiagram />
|
||||
)}
|
||||
|
||||
{/* Migration Checklist */}
|
||||
<div className="bg-white rounded-xl shadow-sm border p-6">
|
||||
<h3 className="text-lg font-semibold text-slate-900 mb-4">Migrations-Checkliste</h3>
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center gap-3 p-3 border border-green-200 bg-green-50 rounded-lg">
|
||||
<input type="checkbox" checked readOnly className="w-4 h-4 text-green-600" />
|
||||
<span className="text-slate-700">Grundgeruest Admin v2 erstellt (Layout, Navigation)</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 p-3 border border-green-200 bg-green-50 rounded-lg">
|
||||
<input type="checkbox" checked readOnly className="w-4 h-4 text-green-600" />
|
||||
<span className="text-slate-700">Compliance Hub migriert (DSR, DSMS, VVT, TOM, DSFA, Controls, Evidence, Risks)</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 p-3 border border-green-200 bg-green-50 rounded-lg">
|
||||
<input type="checkbox" checked readOnly className="w-4 h-4 text-green-600" />
|
||||
<span className="text-slate-700">Consent Verwaltung migriert (inkl. Einwilligungen)</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 p-3 border border-green-200 bg-green-50 rounded-lg">
|
||||
<input type="checkbox" checked readOnly className="w-4 h-4 text-green-600" />
|
||||
<span className="text-slate-700">Workflow (Versionierung) migriert mit Sync-Scroll</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 p-3 border border-green-200 bg-green-50 rounded-lg">
|
||||
<input type="checkbox" checked readOnly className="w-4 h-4 text-green-600" />
|
||||
<span className="text-slate-700">KI-Module migriert (LLM Compare, RAG, AI Quality, Agents)</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 p-3 border border-green-200 bg-green-50 rounded-lg">
|
||||
<input type="checkbox" checked readOnly className="w-4 h-4 text-green-600" />
|
||||
<span className="text-slate-700">Infrastruktur-Module migriert (GPU, Security, SBOM, CI/CD, Middleware)</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 p-3 border border-green-200 bg-green-50 rounded-lg">
|
||||
<input type="checkbox" checked readOnly className="w-4 h-4 text-green-600" />
|
||||
<span className="text-slate-700">Communication-Module migriert (Mail, Alerts, Matrix, Video-Chat)</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 p-3 border border-green-200 bg-green-50 rounded-lg">
|
||||
<input type="checkbox" checked readOnly className="w-4 h-4 text-green-600" />
|
||||
<span className="text-slate-700">Development-Module migriert (Brandbook, Content, Docs, Game, Unity)</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 p-3 border border-yellow-200 bg-yellow-50 rounded-lg">
|
||||
<input type="checkbox" className="w-4 h-4" />
|
||||
<span className="text-slate-700">Klausur-Korrektur migrieren</span>
|
||||
<span className="text-xs text-yellow-600 ml-auto">Bleibt vorerst im alten Admin</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 p-3 border border-yellow-200 bg-yellow-50 rounded-lg">
|
||||
<input type="checkbox" className="w-4 h-4" />
|
||||
<span className="text-slate-700">OCR-Labeling migrieren</span>
|
||||
<span className="text-xs text-yellow-600 ml-auto">Prioritaet: Mittel</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 p-3 border border-slate-200 rounded-lg">
|
||||
<input type="checkbox" className="w-4 h-4" />
|
||||
<span className="text-slate-700">Verwaiste Module identifiziert (voice, training, multiplayer, pca-platform)</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,991 @@
|
||||
'use client'
|
||||
|
||||
/**
|
||||
* Production Readiness Backlog
|
||||
*
|
||||
* Comprehensive checklist of items needed before going live with BreakPilot
|
||||
* Includes CI/CD, Security, RBAC, Data Protection, and Release Workflows
|
||||
*
|
||||
* Migrated from website/app/admin/backlog/page.tsx
|
||||
* Updated: 2026-02-03
|
||||
*/
|
||||
|
||||
import { useState, useEffect } from 'react'
|
||||
import { PagePurpose } from '@/components/common/PagePurpose'
|
||||
import { metaModules } from '@/lib/navigation'
|
||||
import {
|
||||
ChevronRight,
|
||||
Search,
|
||||
Package,
|
||||
RefreshCw,
|
||||
Shield,
|
||||
ClipboardCheck,
|
||||
Users,
|
||||
GitBranch,
|
||||
Tag,
|
||||
Database,
|
||||
FileText,
|
||||
CheckSquare,
|
||||
AlertTriangle,
|
||||
} from 'lucide-react'
|
||||
|
||||
interface BacklogItem {
|
||||
id: string
|
||||
title: string
|
||||
description: string
|
||||
category: string
|
||||
priority: 'critical' | 'high' | 'medium' | 'low'
|
||||
status: 'not_started' | 'in_progress' | 'review' | 'completed' | 'blocked'
|
||||
assignee?: string
|
||||
dueDate?: string
|
||||
notes?: string
|
||||
subtasks?: { id: string; title: string; completed: boolean }[]
|
||||
}
|
||||
|
||||
interface BacklogCategory {
|
||||
id: string
|
||||
name: string
|
||||
icon: React.ReactNode
|
||||
color: string
|
||||
bgColor: string
|
||||
description: string
|
||||
}
|
||||
|
||||
const categories: BacklogCategory[] = [
|
||||
{
|
||||
id: 'modules',
|
||||
name: 'Module Progress',
|
||||
icon: <Package className="w-5 h-5" />,
|
||||
color: 'text-violet-700',
|
||||
bgColor: 'bg-violet-100 border-violet-300',
|
||||
description: 'Fertigstellungsgrad aller Services & Module',
|
||||
},
|
||||
{
|
||||
id: 'cicd',
|
||||
name: 'CI/CD Pipelines',
|
||||
icon: <RefreshCw className="w-5 h-5" />,
|
||||
color: 'text-blue-700',
|
||||
bgColor: 'bg-blue-100 border-blue-300',
|
||||
description: 'Build, Test & Deployment Automation',
|
||||
},
|
||||
{
|
||||
id: 'security',
|
||||
name: 'Security & Vulnerability',
|
||||
icon: <Shield className="w-5 h-5" />,
|
||||
color: 'text-red-700',
|
||||
bgColor: 'bg-red-100 border-red-300',
|
||||
description: 'Security Scans, Dependency Checks & Penetration Testing',
|
||||
},
|
||||
{
|
||||
id: 'testing',
|
||||
name: 'Testing & Quality',
|
||||
icon: <ClipboardCheck className="w-5 h-5" />,
|
||||
color: 'text-emerald-700',
|
||||
bgColor: 'bg-emerald-100 border-emerald-300',
|
||||
description: 'Unit Tests, Integration Tests & E2E Testing',
|
||||
},
|
||||
{
|
||||
id: 'rbac',
|
||||
name: 'RBAC & Access Control',
|
||||
icon: <Users className="w-5 h-5" />,
|
||||
color: 'text-purple-700',
|
||||
bgColor: 'bg-purple-100 border-purple-300',
|
||||
description: 'Developer Roles, Permissions & Team Management',
|
||||
},
|
||||
{
|
||||
id: 'git',
|
||||
name: 'Git & Branch Protection',
|
||||
icon: <GitBranch className="w-5 h-5" />,
|
||||
color: 'text-orange-700',
|
||||
bgColor: 'bg-orange-100 border-orange-300',
|
||||
description: 'Protected Branches, Merge Requests & Code Reviews',
|
||||
},
|
||||
{
|
||||
id: 'release',
|
||||
name: 'Release Management',
|
||||
icon: <Tag className="w-5 h-5" />,
|
||||
color: 'text-green-700',
|
||||
bgColor: 'bg-green-100 border-green-300',
|
||||
description: 'Versioning, Changelog & Release Notes',
|
||||
},
|
||||
{
|
||||
id: 'data',
|
||||
name: 'Data Protection',
|
||||
icon: <Database className="w-5 h-5" />,
|
||||
color: 'text-cyan-700',
|
||||
bgColor: 'bg-cyan-100 border-cyan-300',
|
||||
description: 'Backup, Migration & Customer Data Safety',
|
||||
},
|
||||
{
|
||||
id: 'compliance',
|
||||
name: 'Compliance & SBOM',
|
||||
icon: <FileText className="w-5 h-5" />,
|
||||
color: 'text-teal-700',
|
||||
bgColor: 'bg-teal-100 border-teal-300',
|
||||
description: 'SBOM, Lizenzen & Open Source Compliance',
|
||||
},
|
||||
{
|
||||
id: 'approval',
|
||||
name: 'Approval Workflow',
|
||||
icon: <CheckSquare className="w-5 h-5" />,
|
||||
color: 'text-indigo-700',
|
||||
bgColor: 'bg-indigo-100 border-indigo-300',
|
||||
description: 'Developer Approval, QA Sign-off & Release Gates',
|
||||
},
|
||||
]
|
||||
|
||||
// UPDATED: 2026-02-03 - Reflects actual project state
|
||||
const initialBacklogItems: BacklogItem[] = [
|
||||
// ==================== MODULE PROGRESS ====================
|
||||
{
|
||||
id: 'mod-1',
|
||||
title: 'Consent Service (Go) - 90% fertig',
|
||||
description: 'DSGVO Consent Management Microservice - Production Ready',
|
||||
category: 'modules',
|
||||
priority: 'high',
|
||||
status: 'in_progress',
|
||||
notes: 'Port 8081. Umfangreiche Tests. JWT Auth, OAuth 2.0, TOTP 2FA, DSR Workflow, Matrix/Jitsi Integration, Session Management, PII Redactor.',
|
||||
subtasks: [
|
||||
{ id: 'mod-1-1', title: 'Core Consent API (CRUD, Versioning)', completed: true },
|
||||
{ id: 'mod-1-2', title: 'Authentication (JWT, OAuth 2.0, TOTP)', completed: true },
|
||||
{ id: 'mod-1-3', title: 'DSR Workflow (Art. 15-21)', completed: true },
|
||||
{ id: 'mod-1-4', title: 'Email Templates & Notifications', completed: true },
|
||||
{ id: 'mod-1-5', title: 'Matrix/Jitsi Integration', completed: true },
|
||||
{ id: 'mod-1-6', title: 'Session Management & Middleware', completed: true },
|
||||
{ id: 'mod-1-7', title: 'PII Redactor & Security Headers', completed: true },
|
||||
{ id: 'mod-1-8', title: 'Performance Tests (High-Load)', completed: false },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'mod-2',
|
||||
title: 'Admin-v2 Frontend (Next.js 15) - 95% fertig',
|
||||
description: 'Neues Admin Dashboard - Feature Complete',
|
||||
category: 'modules',
|
||||
priority: 'critical',
|
||||
status: 'completed',
|
||||
notes: 'Port 3002. 73 Seiten, 154 Dateien, 50k+ Zeilen Code. Alle Module migriert.',
|
||||
subtasks: [
|
||||
{ id: 'mod-2-1', title: 'Layout mit Sidebar Navigation', completed: true },
|
||||
{ id: 'mod-2-2', title: 'AI Module (Agents, RAG, Quality, LLM Compare)', completed: true },
|
||||
{ id: 'mod-2-3', title: 'Compliance Module (AI Act, DSFA, Controls, Evidence)', completed: true },
|
||||
{ id: 'mod-2-4', title: 'Communication Module (Mail, Matrix, Video-Chat, Alerts)', completed: true },
|
||||
{ id: 'mod-2-5', title: 'DSGVO Module (Advisory Board, Consent, DSR, TOM, VVT)', completed: true },
|
||||
{ id: 'mod-2-6', title: 'Infrastructure Module (CI/CD, GPU, SBOM, Security, Tests)', completed: true },
|
||||
{ id: 'mod-2-7', title: 'Education Module (Edu-Search, Foerderantrag)', completed: true },
|
||||
{ id: 'mod-2-8', title: 'Wizard Framework (Stepper, TestRunner, etc.)', completed: true },
|
||||
{ id: 'mod-2-9', title: 'API Proxy Routes', completed: true },
|
||||
{ id: 'mod-2-10', title: 'E2E Tests mit Playwright', completed: false },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'mod-3',
|
||||
title: 'Studio-v2 Frontend (Next.js 15) - 90% fertig',
|
||||
description: 'Lehrer/Schueler Studio mit Apple Weather UI',
|
||||
category: 'modules',
|
||||
priority: 'high',
|
||||
status: 'completed',
|
||||
notes: 'Port 3001. 21 Seiten, 111 Dateien, 38k+ Zeilen. Experimental Dashboard, Korrektur, Geo-Lernwelt.',
|
||||
subtasks: [
|
||||
{ id: 'mod-3-1', title: 'Experimental Dashboard (Glassmorphism)', completed: true },
|
||||
{ id: 'mod-3-2', title: 'Korrektur-System mit Fairness-Analyse', completed: true },
|
||||
{ id: 'mod-3-3', title: 'Geo-Lernwelt (Maps, AOI)', completed: true },
|
||||
{ id: 'mod-3-4', title: 'Voice Components', completed: true },
|
||||
{ id: 'mod-3-5', title: 'Worksheet Editor', completed: true },
|
||||
{ id: 'mod-3-6', title: 'Alerts & B2B Migration Wizard', completed: true },
|
||||
{ id: 'mod-3-7', title: 'Document Upload & QR Code', completed: true },
|
||||
{ id: 'mod-3-8', title: 'Messages & Meet Integration', completed: true },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'mod-4',
|
||||
title: 'Backend (Python FastAPI) - 85% fertig',
|
||||
description: 'Hauptbackend mit umfangreichen Erweiterungen',
|
||||
category: 'modules',
|
||||
priority: 'critical',
|
||||
status: 'in_progress',
|
||||
notes: 'Port 8000. 238 Dateien, 94k+ Zeilen. Alerts Agent, Compliance, Classroom Engine, Game, Klausur.',
|
||||
subtasks: [
|
||||
{ id: 'mod-4-1', title: 'Alerts Agent (Rules, Digests, Actions)', completed: true },
|
||||
{ id: 'mod-4-2', title: 'Compliance Module (AI Act, ISMS, Audit)', completed: true },
|
||||
{ id: 'mod-4-3', title: 'Classroom Engine (FSM, Analytics, Timer)', completed: true },
|
||||
{ id: 'mod-4-4', title: 'Game API (Learning Rules, Quiz)', completed: true },
|
||||
{ id: 'mod-4-5', title: 'Klausur Backend (OCR, Correction)', completed: true },
|
||||
{ id: 'mod-4-6', title: 'Unit API & Analytics', completed: true },
|
||||
{ id: 'mod-4-7', title: 'Middleware (Rate Limiter, Security)', completed: true },
|
||||
{ id: 'mod-4-8', title: 'Session Management (RBAC)', completed: true },
|
||||
{ id: 'mod-4-9', title: 'Transcription Worker', completed: true },
|
||||
{ id: 'mod-4-10', title: 'Alembic Migrations', completed: true },
|
||||
{ id: 'mod-4-11', title: 'Integration Tests erweitern', completed: false },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'mod-5',
|
||||
title: 'Klausur Service (Python) - 85% fertig',
|
||||
description: 'BYOEH Abitur-Klausurkorrektur System',
|
||||
category: 'modules',
|
||||
priority: 'critical',
|
||||
status: 'in_progress',
|
||||
notes: 'Port 8086. 45 Dateien, 20k+ Zeilen. BYOEH, Qdrant RAG, Embedding Service, Legal Corpus.',
|
||||
subtasks: [
|
||||
{ id: 'mod-5-1', title: 'BYOEH Upload & Encryption', completed: true },
|
||||
{ id: 'mod-5-2', title: 'Key-Sharing zwischen Pruefern', completed: true },
|
||||
{ id: 'mod-5-3', title: 'Qdrant RAG Integration', completed: true },
|
||||
{ id: 'mod-5-4', title: 'Hybrid Search (Keyword + Semantic)', completed: true },
|
||||
{ id: 'mod-5-5', title: 'Embedding Service', completed: true },
|
||||
{ id: 'mod-5-6', title: 'Legal Corpus Ingestion', completed: true },
|
||||
{ id: 'mod-5-7', title: 'PDF Export', completed: true },
|
||||
{ id: 'mod-5-8', title: 'OCR Pipeline (TrOCR, Vision)', completed: true },
|
||||
{ id: 'mod-5-9', title: 'Vocab Worksheet API', completed: true },
|
||||
{ id: 'mod-5-10', title: 'KI-gestuetzte Korrektur verbessern', completed: false },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'mod-6',
|
||||
title: 'Agent-Core - 80% fertig',
|
||||
description: 'Multi-Agent Architecture Framework',
|
||||
category: 'modules',
|
||||
priority: 'high',
|
||||
status: 'in_progress',
|
||||
notes: 'Neuer Service. Sessions, Shared Brain, Orchestrator, SOUL Files.',
|
||||
subtasks: [
|
||||
{ id: 'mod-6-1', title: 'Session Management & Heartbeat', completed: true },
|
||||
{ id: 'mod-6-2', title: 'Checkpoint System', completed: true },
|
||||
{ id: 'mod-6-3', title: 'Memory Store (mit TTL)', completed: true },
|
||||
{ id: 'mod-6-4', title: 'Context Manager', completed: true },
|
||||
{ id: 'mod-6-5', title: 'Knowledge Graph', completed: true },
|
||||
{ id: 'mod-6-6', title: 'Message Bus (Pub/Sub)', completed: true },
|
||||
{ id: 'mod-6-7', title: 'Supervisor & Task Router', completed: true },
|
||||
{ id: 'mod-6-8', title: 'SOUL Files (Agent Personalities)', completed: true },
|
||||
{ id: 'mod-6-9', title: 'Integration mit Voice Service', completed: false },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'mod-7',
|
||||
title: 'AI Compliance SDK (Go) - 75% fertig',
|
||||
description: 'UCCA Obligations Framework',
|
||||
category: 'modules',
|
||||
priority: 'high',
|
||||
status: 'in_progress',
|
||||
notes: 'Neuer Go Service. AI Act, DSGVO, NIS2 Module. Policy Engine.',
|
||||
subtasks: [
|
||||
{ id: 'mod-7-1', title: 'UCCA Obligations Framework', completed: true },
|
||||
{ id: 'mod-7-2', title: 'AI Act Module', completed: true },
|
||||
{ id: 'mod-7-3', title: 'DSGVO Module', completed: true },
|
||||
{ id: 'mod-7-4', title: 'NIS2 Module', completed: true },
|
||||
{ id: 'mod-7-5', title: 'Policy Engine', completed: true },
|
||||
{ id: 'mod-7-6', title: 'Legal RAG Integration', completed: true },
|
||||
{ id: 'mod-7-7', title: 'Audit Trail & Export', completed: true },
|
||||
{ id: 'mod-7-8', title: 'Escalation System', completed: false },
|
||||
{ id: 'mod-7-9', title: 'Funding/Foerderantrag Wizard', completed: false },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'mod-8',
|
||||
title: 'Geo Service (Python) - 70% fertig',
|
||||
description: 'Geographic Data Service fuer Geo-Lernwelt',
|
||||
category: 'modules',
|
||||
priority: 'medium',
|
||||
status: 'in_progress',
|
||||
notes: 'Neuer Service. AOI Packager, DEM Service, Tile Server.',
|
||||
subtasks: [
|
||||
{ id: 'mod-8-1', title: 'AOI Packager', completed: true },
|
||||
{ id: 'mod-8-2', title: 'DEM Service', completed: true },
|
||||
{ id: 'mod-8-3', title: 'OSM Extractor', completed: true },
|
||||
{ id: 'mod-8-4', title: 'Tile Server', completed: true },
|
||||
{ id: 'mod-8-5', title: 'Learning Generator', completed: true },
|
||||
{ id: 'mod-8-6', title: 'License Checker', completed: true },
|
||||
{ id: 'mod-8-7', title: 'Unity Integration', completed: false },
|
||||
{ id: 'mod-8-8', title: 'Performance Optimization', completed: false },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'mod-9',
|
||||
title: 'Edu-Search Service (Go) - 65% fertig',
|
||||
description: 'Educational Search mit Policy Engine',
|
||||
category: 'modules',
|
||||
priority: 'medium',
|
||||
status: 'in_progress',
|
||||
notes: 'Policy Handlers, Bundeslaender Policies, PII Detector.',
|
||||
subtasks: [
|
||||
{ id: 'mod-9-1', title: 'Policy Enforcer', completed: true },
|
||||
{ id: 'mod-9-2', title: 'PII Detector', completed: true },
|
||||
{ id: 'mod-9-3', title: 'Bundeslaender Policies', completed: true },
|
||||
{ id: 'mod-9-4', title: 'German Universities Data', completed: true },
|
||||
{ id: 'mod-9-5', title: 'Search API erweitern', completed: false },
|
||||
{ id: 'mod-9-6', title: 'Caching Layer', completed: false },
|
||||
],
|
||||
},
|
||||
|
||||
// ==================== CI/CD PIPELINES ====================
|
||||
{
|
||||
id: 'cicd-1',
|
||||
title: 'Woodpecker CI Setup',
|
||||
description: 'Self-hosted CI/CD auf Mac Mini',
|
||||
category: 'cicd',
|
||||
priority: 'critical',
|
||||
status: 'completed',
|
||||
notes: 'Implementiert. Woodpecker CI laeuft auf macmini:8082. Pipelines fuer alle Services.',
|
||||
subtasks: [
|
||||
{ id: 'cicd-1-1', title: 'Woodpecker Server & Agent installiert', completed: true },
|
||||
{ id: 'cicd-1-2', title: 'Gitea Integration', completed: true },
|
||||
{ id: 'cicd-1-3', title: 'Docker Build Pipelines', completed: true },
|
||||
{ id: 'cicd-1-4', title: 'Test Pipelines (Go, Python, Node)', completed: true },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'cicd-2',
|
||||
title: 'SBOM Generation Pipeline',
|
||||
description: 'Automatische SBOM-Generierung in CI',
|
||||
category: 'cicd',
|
||||
priority: 'high',
|
||||
status: 'completed',
|
||||
notes: 'Implementiert in .gitea/workflows/sbom.yaml',
|
||||
subtasks: [
|
||||
{ id: 'cicd-2-1', title: 'CycloneDX SBOM Generation', completed: true },
|
||||
{ id: 'cicd-2-2', title: 'Artifact Upload', completed: true },
|
||||
{ id: 'cicd-2-3', title: 'SBOM Viewer in Admin', completed: true },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'cicd-3',
|
||||
title: 'Production Deployment Pipeline',
|
||||
description: 'Kontrolliertes Deployment mit Rollback',
|
||||
category: 'cicd',
|
||||
priority: 'critical',
|
||||
status: 'not_started',
|
||||
subtasks: [
|
||||
{ id: 'cicd-3-1', title: 'Blue-Green oder Canary Strategy', completed: false },
|
||||
{ id: 'cicd-3-2', title: 'Automatischer Rollback', completed: false },
|
||||
{ id: 'cicd-3-3', title: 'Health Checks nach Deploy', completed: false },
|
||||
{ id: 'cicd-3-4', title: 'Deployment Notifications', completed: false },
|
||||
],
|
||||
},
|
||||
|
||||
// ==================== SECURITY ====================
|
||||
{
|
||||
id: 'sec-1',
|
||||
title: 'Dependency Vulnerability Scanning',
|
||||
description: 'Automatische Pruefung auf Schwachstellen',
|
||||
category: 'security',
|
||||
priority: 'critical',
|
||||
status: 'completed',
|
||||
notes: 'Dependabot konfiguriert fuer Go, Python, npm, Docker.',
|
||||
subtasks: [
|
||||
{ id: 'sec-1-1', title: 'Dependabot fuer Go', completed: true },
|
||||
{ id: 'sec-1-2', title: 'Dependabot fuer Python', completed: true },
|
||||
{ id: 'sec-1-3', title: 'Dependabot fuer npm', completed: true },
|
||||
{ id: 'sec-1-4', title: 'Block Merge bei kritischen CVEs', completed: true },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'sec-2',
|
||||
title: 'Container Image Scanning',
|
||||
description: 'Trivy Scans fuer alle Docker Images',
|
||||
category: 'security',
|
||||
priority: 'high',
|
||||
status: 'completed',
|
||||
notes: 'Trivy in CI integriert.',
|
||||
subtasks: [
|
||||
{ id: 'sec-2-1', title: 'Trivy Integration', completed: true },
|
||||
{ id: 'sec-2-2', title: 'Base Image Policy', completed: true },
|
||||
{ id: 'sec-2-3', title: 'Scan Report bei Build', completed: true },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'sec-3',
|
||||
title: 'SAST (Static Application Security Testing)',
|
||||
description: 'Code-Analyse auf Sicherheitsluecken',
|
||||
category: 'security',
|
||||
priority: 'high',
|
||||
status: 'completed',
|
||||
notes: 'Gosec, Bandit, npm audit in CI.',
|
||||
subtasks: [
|
||||
{ id: 'sec-3-1', title: 'Gosec fuer Go', completed: true },
|
||||
{ id: 'sec-3-2', title: 'Bandit fuer Python', completed: true },
|
||||
{ id: 'sec-3-3', title: 'npm audit', completed: true },
|
||||
{ id: 'sec-3-4', title: 'Semgrep Regeln', completed: false },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'sec-4',
|
||||
title: 'Secret Scanning',
|
||||
description: 'Verhindern dass Secrets in Git landen',
|
||||
category: 'security',
|
||||
priority: 'critical',
|
||||
status: 'completed',
|
||||
notes: 'Gitleaks in CI. SSH Keys in .gitignore.',
|
||||
subtasks: [
|
||||
{ id: 'sec-4-1', title: 'Gitleaks Pre-commit', completed: true },
|
||||
{ id: 'sec-4-2', title: 'SSH Keys in .gitignore', completed: true },
|
||||
{ id: 'sec-4-3', title: 'Historische Commits gescannt', completed: true },
|
||||
],
|
||||
},
|
||||
|
||||
// ==================== TESTING ====================
|
||||
{
|
||||
id: 'test-1',
|
||||
title: 'Backend Test Coverage erweitern',
|
||||
description: 'Integration & E2E Tests fuer Backend APIs',
|
||||
category: 'testing',
|
||||
priority: 'high',
|
||||
status: 'in_progress',
|
||||
notes: '238 Backend-Dateien, davon 20+ Test-Dateien.',
|
||||
subtasks: [
|
||||
{ id: 'test-1-1', title: 'Alerts Agent Tests', completed: true },
|
||||
{ id: 'test-1-2', title: 'Compliance API Tests', completed: true },
|
||||
{ id: 'test-1-3', title: 'Classroom API Tests', completed: true },
|
||||
{ id: 'test-1-4', title: 'Session Middleware Tests', completed: true },
|
||||
{ id: 'test-1-5', title: 'Load Testing mit k6', completed: false },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'test-2',
|
||||
title: 'Frontend E2E Tests',
|
||||
description: 'Playwright Tests fuer Admin-v2 und Studio-v2',
|
||||
category: 'testing',
|
||||
priority: 'critical',
|
||||
status: 'not_started',
|
||||
notes: 'Kritischer Mangel - keine E2E Tests!',
|
||||
subtasks: [
|
||||
{ id: 'test-2-1', title: 'Playwright Setup', completed: false },
|
||||
{ id: 'test-2-2', title: 'Admin-v2 Critical Paths', completed: false },
|
||||
{ id: 'test-2-3', title: 'Studio-v2 User Flows', completed: false },
|
||||
{ id: 'test-2-4', title: 'Visual Regression', completed: false },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'test-3',
|
||||
title: 'Agent-Core Tests',
|
||||
description: 'Unit Tests fuer Multi-Agent Framework',
|
||||
category: 'testing',
|
||||
priority: 'high',
|
||||
status: 'completed',
|
||||
notes: 'Umfangreiche Test-Suite vorhanden.',
|
||||
subtasks: [
|
||||
{ id: 'test-3-1', title: 'Session Manager Tests', completed: true },
|
||||
{ id: 'test-3-2', title: 'Memory Store Tests', completed: true },
|
||||
{ id: 'test-3-3', title: 'Message Bus Tests', completed: true },
|
||||
{ id: 'test-3-4', title: 'Task Router Tests', completed: true },
|
||||
{ id: 'test-3-5', title: 'Heartbeat Tests', completed: true },
|
||||
],
|
||||
},
|
||||
|
||||
// ==================== RBAC ====================
|
||||
{
|
||||
id: 'rbac-1',
|
||||
title: 'Gitea Team Permissions',
|
||||
description: 'Team-basierte Zugriffsrechte',
|
||||
category: 'rbac',
|
||||
priority: 'high',
|
||||
status: 'not_started',
|
||||
subtasks: [
|
||||
{ id: 'rbac-1-1', title: 'Maintainers Team (Full Access)', completed: false },
|
||||
{ id: 'rbac-1-2', title: 'Developers Team (Write)', completed: false },
|
||||
{ id: 'rbac-1-3', title: 'Reviewers Team (Read + Review)', completed: false },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'rbac-2',
|
||||
title: 'Admin Panel Access Control',
|
||||
description: 'Rollenbasierte Zugriffsrechte im Admin',
|
||||
category: 'rbac',
|
||||
priority: 'medium',
|
||||
status: 'in_progress',
|
||||
notes: 'RBAC Middleware im Backend implementiert.',
|
||||
subtasks: [
|
||||
{ id: 'rbac-2-1', title: 'RBAC Middleware', completed: true },
|
||||
{ id: 'rbac-2-2', title: 'Session Store', completed: true },
|
||||
{ id: 'rbac-2-3', title: 'Protected Routes', completed: true },
|
||||
{ id: 'rbac-2-4', title: 'Admin Authentication UI', completed: false },
|
||||
],
|
||||
},
|
||||
|
||||
// ==================== GIT ====================
|
||||
{
|
||||
id: 'git-1',
|
||||
title: 'Protected Branches Setup',
|
||||
description: 'Schutz fuer main Branch',
|
||||
category: 'git',
|
||||
priority: 'critical',
|
||||
status: 'not_started',
|
||||
subtasks: [
|
||||
{ id: 'git-1-1', title: 'No direct push to main', completed: false },
|
||||
{ id: 'git-1-2', title: 'Require PR with Approval', completed: false },
|
||||
{ id: 'git-1-3', title: 'Require Status Checks', completed: false },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'git-2',
|
||||
title: 'Alle Dateien committet',
|
||||
description: 'Keine ungetrackten Produktionsdateien',
|
||||
category: 'git',
|
||||
priority: 'critical',
|
||||
status: 'completed',
|
||||
notes: 'Am 2026-02-03 bereinigt: ~870 Dateien, 329k Zeilen committet.',
|
||||
subtasks: [
|
||||
{ id: 'git-2-1', title: 'admin-v2 (154 Dateien)', completed: true },
|
||||
{ id: 'git-2-2', title: 'studio-v2 (111 Dateien)', completed: true },
|
||||
{ id: 'git-2-3', title: 'backend (238 Dateien)', completed: true },
|
||||
{ id: 'git-2-4', title: 'website (120 Dateien)', completed: true },
|
||||
{ id: 'git-2-5', title: 'klausur-service (45 Dateien)', completed: true },
|
||||
{ id: 'git-2-6', title: 'consent-service (15 Dateien)', completed: true },
|
||||
{ id: 'git-2-7', title: 'Neue Services (161 Dateien)', completed: true },
|
||||
{ id: 'git-2-8', title: '.gitignore aktualisiert', completed: true },
|
||||
],
|
||||
},
|
||||
|
||||
// ==================== RELEASE ====================
|
||||
{
|
||||
id: 'rel-1',
|
||||
title: 'Semantic Versioning',
|
||||
description: 'Automatische Versionierung nach SemVer',
|
||||
category: 'release',
|
||||
priority: 'high',
|
||||
status: 'not_started',
|
||||
subtasks: [
|
||||
{ id: 'rel-1-1', title: 'Conventional Commits', completed: false },
|
||||
{ id: 'rel-1-2', title: 'Automatische Git Tags', completed: false },
|
||||
{ id: 'rel-1-3', title: 'CHANGELOG Generation', completed: false },
|
||||
],
|
||||
},
|
||||
|
||||
// ==================== DATA ====================
|
||||
{
|
||||
id: 'data-1',
|
||||
title: 'Database Backup Strategy',
|
||||
description: 'Automatische Backups mit Retention',
|
||||
category: 'data',
|
||||
priority: 'critical',
|
||||
status: 'not_started',
|
||||
subtasks: [
|
||||
{ id: 'data-1-1', title: 'Taegliche Backups', completed: false },
|
||||
{ id: 'data-1-2', title: 'Point-in-Time Recovery', completed: false },
|
||||
{ id: 'data-1-3', title: 'Backup Encryption', completed: false },
|
||||
{ id: 'data-1-4', title: 'Restore Test dokumentieren', completed: false },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'data-2',
|
||||
title: 'Customer Data Protection',
|
||||
description: 'Schutz von Stammdaten & Dokumenten',
|
||||
category: 'data',
|
||||
priority: 'critical',
|
||||
status: 'in_progress',
|
||||
subtasks: [
|
||||
{ id: 'data-2-1', title: 'Encryption at Rest', completed: true },
|
||||
{ id: 'data-2-2', title: 'Audit Log fuer Consent', completed: true },
|
||||
{ id: 'data-2-3', title: 'PII Masking in Logs', completed: true },
|
||||
{ id: 'data-2-4', title: 'Secure Document Storage', completed: false },
|
||||
],
|
||||
},
|
||||
|
||||
// ==================== COMPLIANCE ====================
|
||||
{
|
||||
id: 'sbom-1',
|
||||
title: 'SBOM erstellt und dokumentiert',
|
||||
description: 'Software Bill of Materials',
|
||||
category: 'compliance',
|
||||
priority: 'high',
|
||||
status: 'completed',
|
||||
notes: 'Umfassende SBOM in /admin/sbom verfuegbar.',
|
||||
subtasks: [
|
||||
{ id: 'sbom-1-1', title: 'Go Dependencies', completed: true },
|
||||
{ id: 'sbom-1-2', title: 'Python Dependencies', completed: true },
|
||||
{ id: 'sbom-1-3', title: 'npm Dependencies', completed: true },
|
||||
{ id: 'sbom-1-4', title: 'Docker Base Images', completed: true },
|
||||
{ id: 'sbom-1-5', title: 'CycloneDX Export', completed: true },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'sbom-2',
|
||||
title: 'Lizenz-Compliance',
|
||||
description: 'Alle Lizenzen geprueft',
|
||||
category: 'compliance',
|
||||
priority: 'high',
|
||||
status: 'in_progress',
|
||||
subtasks: [
|
||||
{ id: 'sbom-2-1', title: 'Lizenzen identifiziert', completed: true },
|
||||
{ id: 'sbom-2-2', title: 'Kompatibilitaet geprueft', completed: false },
|
||||
{ id: 'sbom-2-3', title: 'LICENSES.md erstellt', completed: false },
|
||||
],
|
||||
},
|
||||
|
||||
// ==================== APPROVAL ====================
|
||||
{
|
||||
id: 'appr-1',
|
||||
title: 'Release Approval Gates',
|
||||
description: 'Mehrstufige Freigabe',
|
||||
category: 'approval',
|
||||
priority: 'critical',
|
||||
status: 'not_started',
|
||||
subtasks: [
|
||||
{ id: 'appr-1-1', title: 'QA Sign-off', completed: false },
|
||||
{ id: 'appr-1-2', title: 'Security Review', completed: false },
|
||||
{ id: 'appr-1-3', title: 'Product Owner Freigabe', completed: false },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'appr-2',
|
||||
title: 'Post-Deployment Verification',
|
||||
description: 'Checks nach Deployment',
|
||||
category: 'approval',
|
||||
priority: 'high',
|
||||
status: 'not_started',
|
||||
subtasks: [
|
||||
{ id: 'appr-2-1', title: 'Smoke Tests', completed: false },
|
||||
{ id: 'appr-2-2', title: 'Error Rate Monitoring', completed: false },
|
||||
{ id: 'appr-2-3', title: 'Rollback Kriterien', completed: false },
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
const statusLabels: Record<BacklogItem['status'], { label: string; color: string }> = {
|
||||
not_started: { label: 'Nicht begonnen', color: 'bg-slate-100 text-slate-600' },
|
||||
in_progress: { label: 'In Arbeit', color: 'bg-blue-100 text-blue-700' },
|
||||
review: { label: 'In Review', color: 'bg-yellow-100 text-yellow-700' },
|
||||
completed: { label: 'Abgeschlossen', color: 'bg-green-100 text-green-700' },
|
||||
blocked: { label: 'Blockiert', color: 'bg-red-100 text-red-700' },
|
||||
}
|
||||
|
||||
const priorityLabels: Record<BacklogItem['priority'], { label: string; color: string }> = {
|
||||
critical: { label: 'Kritisch', color: 'bg-red-500 text-white' },
|
||||
high: { label: 'Hoch', color: 'bg-orange-500 text-white' },
|
||||
medium: { label: 'Mittel', color: 'bg-yellow-500 text-white' },
|
||||
low: { label: 'Niedrig', color: 'bg-slate-500 text-white' },
|
||||
}
|
||||
|
||||
export default function BacklogPage() {
|
||||
const module = metaModules.find((m) => m.id === 'backlog')
|
||||
const [items, setItems] = useState<BacklogItem[]>(initialBacklogItems)
|
||||
const [selectedCategory, setSelectedCategory] = useState<string | null>(null)
|
||||
const [selectedPriority, setSelectedPriority] = useState<string | null>(null)
|
||||
const [expandedItems, setExpandedItems] = useState<Set<string>>(new Set())
|
||||
const [searchQuery, setSearchQuery] = useState('')
|
||||
|
||||
// Load saved state from localStorage
|
||||
useEffect(() => {
|
||||
const saved = localStorage.getItem('backlogItems-v2')
|
||||
if (saved) {
|
||||
try {
|
||||
setItems(JSON.parse(saved))
|
||||
} catch (e) {
|
||||
console.error('Failed to load backlog items:', e)
|
||||
}
|
||||
}
|
||||
}, [])
|
||||
|
||||
// Save state to localStorage
|
||||
useEffect(() => {
|
||||
localStorage.setItem('backlogItems-v2', JSON.stringify(items))
|
||||
}, [items])
|
||||
|
||||
const filteredItems = items.filter((item) => {
|
||||
if (selectedCategory && item.category !== selectedCategory) return false
|
||||
if (selectedPriority && item.priority !== selectedPriority) return false
|
||||
if (searchQuery) {
|
||||
const query = searchQuery.toLowerCase()
|
||||
return (
|
||||
item.title.toLowerCase().includes(query) ||
|
||||
item.description.toLowerCase().includes(query) ||
|
||||
item.subtasks?.some((st) => st.title.toLowerCase().includes(query))
|
||||
)
|
||||
}
|
||||
return true
|
||||
})
|
||||
|
||||
const toggleExpand = (id: string) => {
|
||||
const newExpanded = new Set(expandedItems)
|
||||
if (newExpanded.has(id)) {
|
||||
newExpanded.delete(id)
|
||||
} else {
|
||||
newExpanded.add(id)
|
||||
}
|
||||
setExpandedItems(newExpanded)
|
||||
}
|
||||
|
||||
const updateItemStatus = (id: string, status: BacklogItem['status']) => {
|
||||
setItems(items.map((item) => (item.id === id ? { ...item, status } : item)))
|
||||
}
|
||||
|
||||
const toggleSubtask = (itemId: string, subtaskId: string) => {
|
||||
setItems(
|
||||
items.map((item) => {
|
||||
if (item.id !== itemId) return item
|
||||
return {
|
||||
...item,
|
||||
subtasks: item.subtasks?.map((st) =>
|
||||
st.id === subtaskId ? { ...st, completed: !st.completed } : st
|
||||
),
|
||||
}
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
const getProgress = () => {
|
||||
const total = items.length
|
||||
const completed = items.filter((i) => i.status === 'completed').length
|
||||
return { total, completed, percentage: Math.round((completed / total) * 100) }
|
||||
}
|
||||
|
||||
const getCategoryProgress = (categoryId: string) => {
|
||||
const categoryItems = items.filter((i) => i.category === categoryId)
|
||||
const completed = categoryItems.filter((i) => i.status === 'completed').length
|
||||
return { total: categoryItems.length, completed }
|
||||
}
|
||||
|
||||
const resetToDefaults = () => {
|
||||
if (confirm('Backlog auf Standardwerte zuruecksetzen? Alle lokalen Aenderungen gehen verloren.')) {
|
||||
setItems(initialBacklogItems)
|
||||
localStorage.removeItem('backlogItems-v2')
|
||||
}
|
||||
}
|
||||
|
||||
const progress = getProgress()
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{module && (
|
||||
<PagePurpose
|
||||
title={module.name}
|
||||
purpose={module.purpose}
|
||||
audience={module.audience}
|
||||
collapsible={true}
|
||||
defaultCollapsed={true}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Overall Progress */}
|
||||
<div className="bg-white rounded-xl border border-slate-200 p-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-slate-900">Gesamtfortschritt</h2>
|
||||
<p className="text-sm text-slate-500">
|
||||
{progress.completed} von {progress.total} Aufgaben abgeschlossen
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-4">
|
||||
<button
|
||||
onClick={resetToDefaults}
|
||||
className="text-sm text-slate-500 hover:text-slate-700"
|
||||
>
|
||||
Zuruecksetzen
|
||||
</button>
|
||||
<div className="text-3xl font-bold text-blue-600">{progress.percentage}%</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="w-full bg-slate-200 rounded-full h-3">
|
||||
<div
|
||||
className="bg-blue-600 h-3 rounded-full transition-all duration-500"
|
||||
style={{ width: `${progress.percentage}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Category Cards */}
|
||||
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-5 gap-3">
|
||||
{categories.map((cat) => {
|
||||
const catProgress = getCategoryProgress(cat.id)
|
||||
return (
|
||||
<button
|
||||
key={cat.id}
|
||||
onClick={() => setSelectedCategory(selectedCategory === cat.id ? null : cat.id)}
|
||||
className={`p-3 rounded-xl border-2 text-left transition-all ${
|
||||
selectedCategory === cat.id
|
||||
? `${cat.bgColor} ring-2 ring-offset-2`
|
||||
: 'bg-white border-slate-200 hover:border-slate-300'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<span className={selectedCategory === cat.id ? cat.color : 'text-slate-500'}>
|
||||
{cat.icon}
|
||||
</span>
|
||||
<span className="font-medium text-xs truncate">{cat.name}</span>
|
||||
</div>
|
||||
<div className="text-xs text-slate-500">
|
||||
{catProgress.completed}/{catProgress.total}
|
||||
</div>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Filters & Search */}
|
||||
<div className="flex flex-wrap gap-4">
|
||||
<div className="flex-1 min-w-[200px] relative">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-400" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Suchen..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="w-full pl-10 pr-4 py-2 border border-slate-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
/>
|
||||
</div>
|
||||
<select
|
||||
value={selectedPriority || ''}
|
||||
onChange={(e) => setSelectedPriority(e.target.value || null)}
|
||||
className="px-4 py-2 border border-slate-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
>
|
||||
<option value="">Alle Prioritaeten</option>
|
||||
<option value="critical">Kritisch</option>
|
||||
<option value="high">Hoch</option>
|
||||
<option value="medium">Mittel</option>
|
||||
<option value="low">Niedrig</option>
|
||||
</select>
|
||||
{(selectedCategory || selectedPriority || searchQuery) && (
|
||||
<button
|
||||
onClick={() => {
|
||||
setSelectedCategory(null)
|
||||
setSelectedPriority(null)
|
||||
setSearchQuery('')
|
||||
}}
|
||||
className="px-4 py-2 text-sm text-slate-600 hover:text-slate-900"
|
||||
>
|
||||
Filter zuruecksetzen
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Backlog Items */}
|
||||
<div className="space-y-3">
|
||||
{filteredItems.map((item) => {
|
||||
const category = categories.find((c) => c.id === item.category)
|
||||
const isExpanded = expandedItems.has(item.id)
|
||||
const completedSubtasks = item.subtasks?.filter((st) => st.completed).length || 0
|
||||
const totalSubtasks = item.subtasks?.length || 0
|
||||
|
||||
return (
|
||||
<div
|
||||
key={item.id}
|
||||
className="bg-white rounded-xl border border-slate-200 overflow-hidden"
|
||||
>
|
||||
<div
|
||||
className="p-4 cursor-pointer hover:bg-slate-50 transition-colors"
|
||||
onClick={() => toggleExpand(item.id)}
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
{/* Expand Icon */}
|
||||
<button className="mt-1 text-slate-400">
|
||||
<ChevronRight
|
||||
className={`w-5 h-5 transition-transform ${isExpanded ? 'rotate-90' : ''}`}
|
||||
/>
|
||||
</button>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 mb-1 flex-wrap">
|
||||
<h3 className="font-semibold text-slate-900">{item.title}</h3>
|
||||
<span
|
||||
className={`px-2 py-0.5 rounded text-xs font-medium ${
|
||||
priorityLabels[item.priority].color
|
||||
}`}
|
||||
>
|
||||
{priorityLabels[item.priority].label}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-sm text-slate-500 mb-2">{item.description}</p>
|
||||
{item.notes && (
|
||||
<p className="text-xs text-slate-400 mb-2 italic">{item.notes}</p>
|
||||
)}
|
||||
<div className="flex items-center gap-3 text-xs">
|
||||
<span className={`px-2 py-1 rounded border ${category?.bgColor}`}>
|
||||
{category?.name}
|
||||
</span>
|
||||
{totalSubtasks > 0 && (
|
||||
<span className="text-slate-500">
|
||||
{completedSubtasks}/{totalSubtasks} Teilaufgaben
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Status */}
|
||||
<select
|
||||
value={item.status}
|
||||
onChange={(e) => {
|
||||
e.stopPropagation()
|
||||
updateItemStatus(item.id, e.target.value as BacklogItem['status'])
|
||||
}}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className={`px-3 py-1.5 rounded-lg text-sm font-medium border-0 cursor-pointer ${
|
||||
statusLabels[item.status].color
|
||||
}`}
|
||||
>
|
||||
{Object.entries(statusLabels).map(([value, { label }]) => (
|
||||
<option key={value} value={value}>
|
||||
{label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Progress Bar */}
|
||||
{totalSubtasks > 0 && (
|
||||
<div className="mt-3 ml-8">
|
||||
<div className="w-full bg-slate-200 rounded-full h-1.5">
|
||||
<div
|
||||
className="bg-green-500 h-1.5 rounded-full transition-all"
|
||||
style={{ width: `${(completedSubtasks / totalSubtasks) * 100}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Expanded Subtasks */}
|
||||
{isExpanded && item.subtasks && item.subtasks.length > 0 && (
|
||||
<div className="border-t border-slate-200 bg-slate-50 p-4 pl-12">
|
||||
<h4 className="text-sm font-medium text-slate-700 mb-3">Teilaufgaben</h4>
|
||||
<ul className="space-y-2">
|
||||
{item.subtasks.map((subtask) => (
|
||||
<li key={subtask.id} className="flex items-center gap-3">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={subtask.completed}
|
||||
onChange={() => toggleSubtask(item.id, subtask.id)}
|
||||
className="w-4 h-4 rounded border-slate-300 text-blue-600 focus:ring-blue-500"
|
||||
/>
|
||||
<span
|
||||
className={`text-sm ${
|
||||
subtask.completed ? 'text-slate-400 line-through' : 'text-slate-700'
|
||||
}`}
|
||||
>
|
||||
{subtask.title}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
{filteredItems.length === 0 && (
|
||||
<div className="text-center py-12 text-slate-500">
|
||||
Keine Aufgaben gefunden. Versuche einen anderen Filter.
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Info Box */}
|
||||
<div className="bg-amber-50 border border-amber-200 rounded-xl p-6">
|
||||
<div className="flex items-start gap-4">
|
||||
<AlertTriangle className="w-6 h-6 text-amber-600 flex-shrink-0 mt-0.5" />
|
||||
<div>
|
||||
<h3 className="font-semibold text-amber-900">Wichtiger Hinweis</h3>
|
||||
<p className="text-sm text-amber-800 mt-1">
|
||||
Diese Backlog-Liste muss vollstaendig abgearbeitet sein, bevor BreakPilot in den
|
||||
Produktivbetrieb gehen kann. Alle kritischen Items muessen abgeschlossen sein. Der
|
||||
Fortschritt wird lokal im Browser gespeichert und kann mit "Zuruecksetzen"
|
||||
auf die Standardwerte zurueckgesetzt werden.
|
||||
</p>
|
||||
<p className="text-xs text-amber-700 mt-2">
|
||||
Letzte Aktualisierung: 2026-02-03
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,912 @@
|
||||
'use client'
|
||||
|
||||
/**
|
||||
* Alerts Monitoring Admin Page (migrated from website/admin/alerts)
|
||||
*
|
||||
* Google Alerts & Feed-Ueberwachung Dashboard
|
||||
* Provides inbox management, topic configuration, rule builder, and relevance profiles
|
||||
*/
|
||||
|
||||
import { useEffect, useState, useCallback } from 'react'
|
||||
import { PagePurpose } from '@/components/common/PagePurpose'
|
||||
|
||||
// Types
|
||||
interface AlertItem {
|
||||
id: string
|
||||
title: string
|
||||
url: string
|
||||
snippet: string
|
||||
topic_name: string
|
||||
relevance_score: number | null
|
||||
relevance_decision: string | null
|
||||
status: string
|
||||
fetched_at: string
|
||||
published_at: string | null
|
||||
matched_rule: string | null
|
||||
tags: string[]
|
||||
}
|
||||
|
||||
interface Topic {
|
||||
id: string
|
||||
name: string
|
||||
feed_url: string
|
||||
feed_type: string
|
||||
is_active: boolean
|
||||
fetch_interval_minutes: number
|
||||
last_fetched_at: string | null
|
||||
alert_count: number
|
||||
}
|
||||
|
||||
interface Rule {
|
||||
id: string
|
||||
name: string
|
||||
topic_id: string | null
|
||||
conditions: Array<{
|
||||
field: string
|
||||
operator: string
|
||||
value: string | number
|
||||
}>
|
||||
action_type: string
|
||||
action_config: Record<string, unknown>
|
||||
priority: number
|
||||
is_active: boolean
|
||||
}
|
||||
|
||||
interface Profile {
|
||||
priorities: string[]
|
||||
exclusions: string[]
|
||||
positive_examples: Array<{ title: string; url: string }>
|
||||
negative_examples: Array<{ title: string; url: string }>
|
||||
policies: {
|
||||
keep_threshold: number
|
||||
drop_threshold: number
|
||||
}
|
||||
}
|
||||
|
||||
interface Stats {
|
||||
total_alerts: number
|
||||
new_alerts: number
|
||||
kept_alerts: number
|
||||
review_alerts: number
|
||||
dropped_alerts: number
|
||||
total_topics: number
|
||||
active_topics: number
|
||||
total_rules: number
|
||||
}
|
||||
|
||||
// Tab type
|
||||
type TabId = 'dashboard' | 'inbox' | 'topics' | 'rules' | 'profile' | 'audit' | 'documentation'
|
||||
|
||||
export default function AlertsPage() {
|
||||
const [activeTab, setActiveTab] = useState<TabId>('dashboard')
|
||||
const [stats, setStats] = useState<Stats | null>(null)
|
||||
const [alerts, setAlerts] = useState<AlertItem[]>([])
|
||||
const [topics, setTopics] = useState<Topic[]>([])
|
||||
const [rules, setRules] = useState<Rule[]>([])
|
||||
const [profile, setProfile] = useState<Profile | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [inboxFilter, setInboxFilter] = useState<string>('all')
|
||||
|
||||
const API_BASE = '/api/alerts'
|
||||
|
||||
const fetchData = useCallback(async () => {
|
||||
try {
|
||||
const [statsRes, alertsRes, topicsRes, rulesRes, profileRes] = await Promise.all([
|
||||
fetch(`${API_BASE}/stats`),
|
||||
fetch(`${API_BASE}/inbox?limit=50`),
|
||||
fetch(`${API_BASE}/topics`),
|
||||
fetch(`${API_BASE}/rules`),
|
||||
fetch(`${API_BASE}/profile`),
|
||||
])
|
||||
|
||||
if (statsRes.ok) setStats(await statsRes.json())
|
||||
if (alertsRes.ok) {
|
||||
const data = await alertsRes.json()
|
||||
setAlerts(data.items || [])
|
||||
}
|
||||
if (topicsRes.ok) {
|
||||
const data = await topicsRes.json()
|
||||
setTopics(data.topics || data.items || [])
|
||||
}
|
||||
if (rulesRes.ok) {
|
||||
const data = await rulesRes.json()
|
||||
setRules(data.rules || data.items || [])
|
||||
}
|
||||
if (profileRes.ok) setProfile(await profileRes.json())
|
||||
|
||||
setError(null)
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Verbindungsfehler')
|
||||
// Set demo data
|
||||
setStats({
|
||||
total_alerts: 147,
|
||||
new_alerts: 23,
|
||||
kept_alerts: 89,
|
||||
review_alerts: 12,
|
||||
dropped_alerts: 23,
|
||||
total_topics: 5,
|
||||
active_topics: 4,
|
||||
total_rules: 8,
|
||||
})
|
||||
setAlerts([
|
||||
{
|
||||
id: 'demo_1',
|
||||
title: 'Neue Studie zur digitalen Bildung an Schulen',
|
||||
url: 'https://example.com/artikel1',
|
||||
snippet: 'Eine aktuelle Studie zeigt, dass digitale Lernmittel den Lernerfolg steigern koennen...',
|
||||
topic_name: 'Digitale Bildung',
|
||||
relevance_score: 0.85,
|
||||
relevance_decision: 'KEEP',
|
||||
status: 'new',
|
||||
fetched_at: new Date().toISOString(),
|
||||
published_at: null,
|
||||
matched_rule: null,
|
||||
tags: ['bildung', 'digital'],
|
||||
},
|
||||
{
|
||||
id: 'demo_2',
|
||||
title: 'Inklusion: Fortbildungen fuer Lehrkraefte',
|
||||
url: 'https://example.com/artikel2',
|
||||
snippet: 'Das Kultusministerium bietet neue Fortbildungsangebote zum Thema Inklusion an...',
|
||||
topic_name: 'Inklusion',
|
||||
relevance_score: 0.72,
|
||||
relevance_decision: 'KEEP',
|
||||
status: 'new',
|
||||
fetched_at: new Date(Date.now() - 3600000).toISOString(),
|
||||
published_at: null,
|
||||
matched_rule: null,
|
||||
tags: ['inklusion'],
|
||||
},
|
||||
])
|
||||
setTopics([
|
||||
{
|
||||
id: 'topic_1',
|
||||
name: 'Digitale Bildung',
|
||||
feed_url: 'https://google.com/alerts/feeds/123',
|
||||
feed_type: 'rss',
|
||||
is_active: true,
|
||||
fetch_interval_minutes: 60,
|
||||
last_fetched_at: new Date().toISOString(),
|
||||
alert_count: 47,
|
||||
},
|
||||
{
|
||||
id: 'topic_2',
|
||||
name: 'Inklusion',
|
||||
feed_url: 'https://google.com/alerts/feeds/456',
|
||||
feed_type: 'rss',
|
||||
is_active: true,
|
||||
fetch_interval_minutes: 60,
|
||||
last_fetched_at: new Date(Date.now() - 1800000).toISOString(),
|
||||
alert_count: 32,
|
||||
},
|
||||
])
|
||||
setRules([
|
||||
{
|
||||
id: 'rule_1',
|
||||
name: 'Stellenanzeigen ausschliessen',
|
||||
topic_id: null,
|
||||
conditions: [{ field: 'title', operator: 'contains', value: 'Stellenangebot' }],
|
||||
action_type: 'drop',
|
||||
action_config: {},
|
||||
priority: 10,
|
||||
is_active: true,
|
||||
},
|
||||
])
|
||||
setProfile({
|
||||
priorities: ['Inklusion', 'digitale Bildung'],
|
||||
exclusions: ['Stellenanzeigen', 'Werbung'],
|
||||
positive_examples: [],
|
||||
negative_examples: [],
|
||||
policies: { keep_threshold: 0.7, drop_threshold: 0.3 },
|
||||
})
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
fetchData()
|
||||
}, [fetchData])
|
||||
|
||||
const formatTimeAgo = (dateStr: string | null) => {
|
||||
if (!dateStr) return '-'
|
||||
const date = new Date(dateStr)
|
||||
const now = new Date()
|
||||
const diffMs = now.getTime() - date.getTime()
|
||||
const diffMins = Math.floor(diffMs / 60000)
|
||||
|
||||
if (diffMins < 1) return 'gerade eben'
|
||||
if (diffMins < 60) return `vor ${diffMins} Min.`
|
||||
if (diffMins < 1440) return `vor ${Math.floor(diffMins / 60)} Std.`
|
||||
return `vor ${Math.floor(diffMins / 1440)} Tagen`
|
||||
}
|
||||
|
||||
const getScoreBadge = (score: number | null) => {
|
||||
if (score === null) return null
|
||||
const pct = Math.round(score * 100)
|
||||
let cls = 'bg-slate-100 text-slate-600'
|
||||
if (pct >= 70) cls = 'bg-green-100 text-green-800'
|
||||
else if (pct >= 40) cls = 'bg-amber-100 text-amber-800'
|
||||
else cls = 'bg-red-100 text-red-800'
|
||||
return <span className={`px-2 py-0.5 rounded text-xs font-semibold ${cls}`}>{pct}%</span>
|
||||
}
|
||||
|
||||
const getDecisionBadge = (decision: string | null) => {
|
||||
if (!decision) return null
|
||||
const styles: Record<string, string> = {
|
||||
KEEP: 'bg-green-100 text-green-800',
|
||||
REVIEW: 'bg-amber-100 text-amber-800',
|
||||
DROP: 'bg-red-100 text-red-800',
|
||||
}
|
||||
return (
|
||||
<span className={`px-2 py-0.5 rounded text-xs font-semibold uppercase ${styles[decision] || 'bg-slate-100'}`}>
|
||||
{decision}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
const filteredAlerts = alerts.filter((alert) => {
|
||||
if (inboxFilter === 'all') return true
|
||||
if (inboxFilter === 'new') return alert.status === 'new'
|
||||
if (inboxFilter === 'keep') return alert.relevance_decision === 'KEEP'
|
||||
if (inboxFilter === 'review') return alert.relevance_decision === 'REVIEW'
|
||||
return true
|
||||
})
|
||||
|
||||
const tabs: { id: TabId; label: string; badge?: number }[] = [
|
||||
{ id: 'dashboard', label: 'Dashboard' },
|
||||
{ id: 'inbox', label: 'Inbox', badge: stats?.new_alerts || 0 },
|
||||
{ id: 'topics', label: 'Topics' },
|
||||
{ id: 'rules', label: 'Regeln' },
|
||||
{ id: 'profile', label: 'Profil' },
|
||||
{ id: 'audit', label: 'Audit' },
|
||||
{ id: 'documentation', label: 'Dokumentation' },
|
||||
]
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-green-600" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* Page Purpose */}
|
||||
<PagePurpose
|
||||
title="Alerts Monitoring"
|
||||
purpose="Google Alerts & Feed-Ueberwachung mit KI-gestuetzter Relevanzpruefung. Verwalten Sie Topics, konfigurieren Sie Filterregeln und nutzen Sie LLM-basiertes Scoring fuer automatische Kategorisierung."
|
||||
audience={['Marketing', 'Admins', 'DSB']}
|
||||
architecture={{
|
||||
services: ['backend (FastAPI)', 'APScheduler', 'LLM Gateway'],
|
||||
databases: ['PostgreSQL', 'Valkey Cache'],
|
||||
}}
|
||||
relatedPages={[
|
||||
{ name: 'Unified Inbox', href: '/communication/mail', description: 'E-Mail-Konten verwalten' },
|
||||
{ name: 'Voice Service', href: '/communication/matrix', description: 'Voice-First Interface' },
|
||||
]}
|
||||
collapsible={true}
|
||||
defaultCollapsed={false}
|
||||
/>
|
||||
|
||||
{/* Stats Overview */}
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 mb-6">
|
||||
<div className="bg-white rounded-xl border border-slate-200 p-4 shadow-sm">
|
||||
<div className="text-3xl font-bold text-slate-900">{stats?.total_alerts || 0}</div>
|
||||
<div className="text-sm text-slate-500">Alerts gesamt</div>
|
||||
</div>
|
||||
<div className="bg-white rounded-xl border border-slate-200 p-4 shadow-sm">
|
||||
<div className="text-3xl font-bold text-blue-600">{stats?.new_alerts || 0}</div>
|
||||
<div className="text-sm text-slate-500">Neue Alerts</div>
|
||||
</div>
|
||||
<div className="bg-white rounded-xl border border-slate-200 p-4 shadow-sm">
|
||||
<div className="text-3xl font-bold text-green-600">{stats?.kept_alerts || 0}</div>
|
||||
<div className="text-sm text-slate-500">Relevant</div>
|
||||
</div>
|
||||
<div className="bg-white rounded-xl border border-slate-200 p-4 shadow-sm">
|
||||
<div className="text-3xl font-bold text-amber-600">{stats?.review_alerts || 0}</div>
|
||||
<div className="text-sm text-slate-500">Zur Pruefung</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tab Navigation */}
|
||||
<div className="bg-white rounded-lg shadow mb-6">
|
||||
<div className="border-b border-slate-200 px-4">
|
||||
<nav className="flex gap-4 overflow-x-auto">
|
||||
{tabs.map((tab) => (
|
||||
<button
|
||||
key={tab.id}
|
||||
onClick={() => setActiveTab(tab.id)}
|
||||
className={`pb-3 pt-4 px-1 text-sm font-medium border-b-2 transition-colors flex items-center gap-2 whitespace-nowrap ${
|
||||
activeTab === tab.id
|
||||
? 'border-green-600 text-green-600'
|
||||
: 'border-transparent text-slate-500 hover:text-slate-700'
|
||||
}`}
|
||||
>
|
||||
{tab.label}
|
||||
{tab.badge !== undefined && tab.badge > 0 && (
|
||||
<span className="px-2 py-0.5 rounded-full text-xs font-semibold bg-red-500 text-white">
|
||||
{tab.badge}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
<div className="p-6">
|
||||
{/* Dashboard Tab */}
|
||||
{activeTab === 'dashboard' && (
|
||||
<div className="space-y-6">
|
||||
{/* Quick Actions */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div className="bg-slate-50 rounded-xl p-6">
|
||||
<h3 className="font-semibold text-slate-900 mb-4">Aktive Topics</h3>
|
||||
<div className="space-y-3">
|
||||
{topics.slice(0, 5).map((topic) => (
|
||||
<div key={topic.id} className="flex items-center justify-between p-3 bg-white rounded-lg border border-slate-200">
|
||||
<div>
|
||||
<div className="font-medium text-slate-900">{topic.name}</div>
|
||||
<div className="text-xs text-slate-500">{topic.alert_count} Alerts</div>
|
||||
</div>
|
||||
<span className={`px-2 py-1 rounded text-xs font-semibold ${topic.is_active ? 'bg-green-100 text-green-800' : 'bg-slate-100 text-slate-600'}`}>
|
||||
{topic.is_active ? 'Aktiv' : 'Pausiert'}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
{topics.length === 0 && (
|
||||
<div className="text-sm text-slate-500 text-center py-4">Keine Topics konfiguriert</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-slate-50 rounded-xl p-6">
|
||||
<h3 className="font-semibold text-slate-900 mb-4">Letzte Alerts</h3>
|
||||
<div className="space-y-3">
|
||||
{alerts.slice(0, 5).map((alert) => (
|
||||
<div key={alert.id} className="p-3 bg-white rounded-lg border border-slate-200">
|
||||
<div className="font-medium text-slate-900 text-sm truncate">{alert.title}</div>
|
||||
<div className="flex items-center gap-2 mt-1">
|
||||
<span className="text-xs text-slate-500">{alert.topic_name}</span>
|
||||
{getScoreBadge(alert.relevance_score)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{alerts.length === 0 && (
|
||||
<div className="text-sm text-slate-500 text-center py-4">Keine Alerts vorhanden</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="bg-amber-50 border border-amber-200 rounded-lg p-4">
|
||||
<p className="text-sm text-amber-800">
|
||||
<strong>Hinweis:</strong> API nicht erreichbar. Demo-Daten werden angezeigt.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Inbox Tab */}
|
||||
{activeTab === 'inbox' && (
|
||||
<div className="space-y-4">
|
||||
{/* Filters */}
|
||||
<div className="flex gap-2 flex-wrap">
|
||||
{['all', 'new', 'keep', 'review'].map((filter) => (
|
||||
<button
|
||||
key={filter}
|
||||
onClick={() => setInboxFilter(filter)}
|
||||
className={`px-4 py-2 rounded-full text-sm font-medium transition-colors ${
|
||||
inboxFilter === filter
|
||||
? 'bg-green-600 text-white'
|
||||
: 'bg-slate-100 text-slate-600 hover:bg-slate-200'
|
||||
}`}
|
||||
>
|
||||
{filter === 'all' && 'Alle'}
|
||||
{filter === 'new' && 'Neu'}
|
||||
{filter === 'keep' && 'Relevant'}
|
||||
{filter === 'review' && 'Pruefung'}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Alerts Table */}
|
||||
<div className="bg-white rounded-xl border border-slate-200 overflow-hidden">
|
||||
<table className="w-full">
|
||||
<thead className="bg-slate-50 border-b border-slate-200">
|
||||
<tr>
|
||||
<th className="text-left p-4 text-xs font-semibold text-slate-500 uppercase">Alert</th>
|
||||
<th className="text-left p-4 text-xs font-semibold text-slate-500 uppercase">Topic</th>
|
||||
<th className="text-left p-4 text-xs font-semibold text-slate-500 uppercase">Score</th>
|
||||
<th className="text-left p-4 text-xs font-semibold text-slate-500 uppercase">Decision</th>
|
||||
<th className="text-left p-4 text-xs font-semibold text-slate-500 uppercase">Zeit</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-slate-100">
|
||||
{filteredAlerts.map((alert) => (
|
||||
<tr key={alert.id} className="hover:bg-slate-50">
|
||||
<td className="p-4">
|
||||
<a href={alert.url} target="_blank" rel="noopener noreferrer" className="font-medium text-slate-900 hover:text-green-600">
|
||||
{alert.title}
|
||||
</a>
|
||||
<p className="text-sm text-slate-500 truncate max-w-md">{alert.snippet}</p>
|
||||
</td>
|
||||
<td className="p-4 text-sm text-slate-600">{alert.topic_name}</td>
|
||||
<td className="p-4">{getScoreBadge(alert.relevance_score)}</td>
|
||||
<td className="p-4">{getDecisionBadge(alert.relevance_decision)}</td>
|
||||
<td className="p-4 text-sm text-slate-500">{formatTimeAgo(alert.fetched_at)}</td>
|
||||
</tr>
|
||||
))}
|
||||
{filteredAlerts.length === 0 && (
|
||||
<tr>
|
||||
<td colSpan={5} className="p-8 text-center text-slate-500">
|
||||
Keine Alerts gefunden
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Topics Tab */}
|
||||
{activeTab === 'topics' && (
|
||||
<div className="space-y-4">
|
||||
<div className="flex justify-between items-center">
|
||||
<h3 className="font-semibold text-slate-900">Feed Topics</h3>
|
||||
<button className="px-4 py-2 bg-green-600 text-white rounded-lg text-sm font-medium hover:bg-green-700">
|
||||
+ Topic hinzufuegen
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{topics.map((topic) => (
|
||||
<div key={topic.id} className="bg-white rounded-xl border border-slate-200 p-4">
|
||||
<div className="flex justify-between items-start mb-3">
|
||||
<div className="w-10 h-10 bg-green-100 rounded-lg flex items-center justify-center">
|
||||
<svg className="w-5 h-5 text-green-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 5c7.18 0 13 5.82 13 13M6 11a7 7 0 017 7m-6 0a1 1 0 11-2 0 1 1 0 012 0z" />
|
||||
</svg>
|
||||
</div>
|
||||
<span className={`px-2 py-1 rounded text-xs font-semibold ${topic.is_active ? 'bg-green-100 text-green-800' : 'bg-slate-100 text-slate-600'}`}>
|
||||
{topic.is_active ? 'Aktiv' : 'Pausiert'}
|
||||
</span>
|
||||
</div>
|
||||
<h4 className="font-semibold text-slate-900">{topic.name}</h4>
|
||||
<p className="text-sm text-slate-500 truncate">{topic.feed_url}</p>
|
||||
<div className="flex justify-between items-center mt-4 pt-4 border-t border-slate-100">
|
||||
<div className="text-sm">
|
||||
<span className="font-semibold text-slate-900">{topic.alert_count}</span>
|
||||
<span className="text-slate-500"> Alerts</span>
|
||||
</div>
|
||||
<div className="text-xs text-slate-500">
|
||||
{formatTimeAgo(topic.last_fetched_at)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{topics.length === 0 && (
|
||||
<div className="col-span-full text-center py-8 text-slate-500">
|
||||
Keine Topics konfiguriert
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Rules Tab */}
|
||||
{activeTab === 'rules' && (
|
||||
<div className="space-y-4">
|
||||
<div className="flex justify-between items-center">
|
||||
<h3 className="font-semibold text-slate-900">Filterregeln</h3>
|
||||
<button className="px-4 py-2 bg-green-600 text-white rounded-lg text-sm font-medium hover:bg-green-700">
|
||||
+ Regel erstellen
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-xl border border-slate-200 divide-y divide-slate-100">
|
||||
{rules.map((rule) => (
|
||||
<div key={rule.id} className="p-4 flex items-center gap-4">
|
||||
<div className="text-slate-400 cursor-grab">
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 6h16M4 12h16M4 18h16" />
|
||||
</svg>
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<div className="font-medium text-slate-900">{rule.name}</div>
|
||||
<div className="text-sm text-slate-500">
|
||||
Wenn: {rule.conditions[0]?.field} {rule.conditions[0]?.operator} "{rule.conditions[0]?.value}"
|
||||
</div>
|
||||
</div>
|
||||
<span className={`px-3 py-1 rounded text-xs font-semibold uppercase ${
|
||||
rule.action_type === 'keep' ? 'bg-green-100 text-green-800' :
|
||||
rule.action_type === 'drop' ? 'bg-red-100 text-red-800' :
|
||||
rule.action_type === 'email' ? 'bg-blue-100 text-blue-800' :
|
||||
'bg-purple-100 text-purple-800'
|
||||
}`}>
|
||||
{rule.action_type}
|
||||
</span>
|
||||
<div
|
||||
className={`w-12 h-6 rounded-full relative cursor-pointer transition-colors ${
|
||||
rule.is_active ? 'bg-green-500' : 'bg-slate-300'
|
||||
}`}
|
||||
>
|
||||
<div
|
||||
className={`absolute w-5 h-5 bg-white rounded-full top-0.5 transition-all shadow ${
|
||||
rule.is_active ? 'left-6' : 'left-0.5'
|
||||
}`}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{rules.length === 0 && (
|
||||
<div className="p-8 text-center text-slate-500">
|
||||
Keine Regeln konfiguriert
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Profile Tab */}
|
||||
{activeTab === 'profile' && (
|
||||
<div className="max-w-2xl space-y-6">
|
||||
<div className="bg-white rounded-xl border border-slate-200 p-6">
|
||||
<h3 className="font-semibold text-slate-900 mb-4">Relevanzprofil</h3>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-2">
|
||||
Prioritaeten (wichtige Themen)
|
||||
</label>
|
||||
<textarea
|
||||
className="w-full p-3 border border-slate-200 rounded-lg text-sm focus:ring-2 focus:ring-green-500 focus:border-green-500"
|
||||
rows={4}
|
||||
defaultValue={profile?.priorities?.join('\n') || ''}
|
||||
placeholder="Ein Thema pro Zeile..."
|
||||
/>
|
||||
<p className="text-xs text-slate-500 mt-1">Alerts zu diesen Themen werden hoeher bewertet.</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-2">
|
||||
Ausschluesse (unerwuenschte Themen)
|
||||
</label>
|
||||
<textarea
|
||||
className="w-full p-3 border border-slate-200 rounded-lg text-sm focus:ring-2 focus:ring-green-500 focus:border-green-500"
|
||||
rows={4}
|
||||
defaultValue={profile?.exclusions?.join('\n') || ''}
|
||||
placeholder="Ein Thema pro Zeile..."
|
||||
/>
|
||||
<p className="text-xs text-slate-500 mt-1">Alerts zu diesen Themen werden niedriger bewertet.</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-2">
|
||||
Schwellenwert KEEP
|
||||
</label>
|
||||
<select
|
||||
className="w-full p-3 border border-slate-200 rounded-lg text-sm focus:ring-2 focus:ring-green-500 focus:border-green-500"
|
||||
defaultValue={profile?.policies?.keep_threshold || 0.7}
|
||||
>
|
||||
<option value={0.8}>80% (sehr streng)</option>
|
||||
<option value={0.7}>70% (empfohlen)</option>
|
||||
<option value={0.6}>60% (weniger streng)</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-2">
|
||||
Schwellenwert DROP
|
||||
</label>
|
||||
<select
|
||||
className="w-full p-3 border border-slate-200 rounded-lg text-sm focus:ring-2 focus:ring-green-500 focus:border-green-500"
|
||||
defaultValue={profile?.policies?.drop_threshold || 0.3}
|
||||
>
|
||||
<option value={0.4}>40% (strenger)</option>
|
||||
<option value={0.3}>30% (empfohlen)</option>
|
||||
<option value={0.2}>20% (lockerer)</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button className="px-4 py-2 bg-green-600 text-white rounded-lg text-sm font-medium hover:bg-green-700">
|
||||
Profil speichern
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Audit Tab */}
|
||||
{activeTab === 'audit' && (
|
||||
<div className="space-y-6">
|
||||
<h3 className="text-sm font-semibold text-slate-700 uppercase tracking-wide">Audit-relevante Informationen</h3>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{/* Database Info */}
|
||||
<div className="bg-white rounded-xl border border-slate-200 p-4">
|
||||
<h4 className="font-semibold text-slate-900 mb-3 flex items-center gap-2">
|
||||
<svg className="w-5 h-5 text-green-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 7v10c0 2.21 3.582 4 8 4s8-1.79 8-4V7M4 7c0 2.21 3.582 4 8 4s8-1.79 8-4M4 7c0-2.21 3.582-4 8-4s8 1.79 8 4" />
|
||||
</svg>
|
||||
Datenbank
|
||||
</h4>
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between py-2 border-b border-slate-100">
|
||||
<span className="text-sm text-slate-600">Tabellen</span>
|
||||
<span className="text-sm font-medium bg-green-100 text-green-800 px-2 py-0.5 rounded">4 (topics, items, rules, profiles)</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between py-2 border-b border-slate-100">
|
||||
<span className="text-sm text-slate-600">Indizes</span>
|
||||
<span className="text-sm font-medium bg-green-100 text-green-800 px-2 py-0.5 rounded">URL-Hash, Topic-ID, Status</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between py-2">
|
||||
<span className="text-sm text-slate-600">Backups</span>
|
||||
<span className="text-sm font-medium bg-green-100 text-green-800 px-2 py-0.5 rounded">PostgreSQL pg_dump</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* API Security */}
|
||||
<div className="bg-white rounded-xl border border-slate-200 p-4">
|
||||
<h4 className="font-semibold text-slate-900 mb-3 flex items-center gap-2">
|
||||
<svg className="w-5 h-5 text-green-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" />
|
||||
</svg>
|
||||
API Sicherheit
|
||||
</h4>
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between py-2 border-b border-slate-100">
|
||||
<span className="text-sm text-slate-600">Authentifizierung</span>
|
||||
<span className="text-sm font-medium bg-amber-100 text-amber-800 px-2 py-0.5 rounded">Bearer Token (geplant)</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between py-2 border-b border-slate-100">
|
||||
<span className="text-sm text-slate-600">Rate Limiting</span>
|
||||
<span className="text-sm font-medium bg-amber-100 text-amber-800 px-2 py-0.5 rounded">Nicht implementiert</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between py-2">
|
||||
<span className="text-sm text-slate-600">Input Validation</span>
|
||||
<span className="text-sm font-medium bg-green-100 text-green-800 px-2 py-0.5 rounded">Pydantic Models</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Logging */}
|
||||
<div className="bg-white rounded-xl border border-slate-200 p-4">
|
||||
<h4 className="font-semibold text-slate-900 mb-3 flex items-center gap-2">
|
||||
<svg className="w-5 h-5 text-green-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 17v-2m3 2v-4m3 4v-6m2 10H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
|
||||
</svg>
|
||||
Logging & Monitoring
|
||||
</h4>
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between py-2 border-b border-slate-100">
|
||||
<span className="text-sm text-slate-600">Structured Logging</span>
|
||||
<span className="text-sm font-medium bg-green-100 text-green-800 px-2 py-0.5 rounded">Python logging</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between py-2 border-b border-slate-100">
|
||||
<span className="text-sm text-slate-600">Metriken</span>
|
||||
<span className="text-sm font-medium bg-green-100 text-green-800 px-2 py-0.5 rounded">Stats Endpoint</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between py-2">
|
||||
<span className="text-sm text-slate-600">Health Checks</span>
|
||||
<span className="text-sm font-medium bg-green-100 text-green-800 px-2 py-0.5 rounded">/api/alerts/health</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Privacy Notes */}
|
||||
<div className="bg-blue-50 border border-blue-200 rounded-lg p-4">
|
||||
<h4 className="text-sm font-semibold text-blue-800 mb-2">Datenschutz-Hinweise</h4>
|
||||
<ul className="space-y-1">
|
||||
<li className="text-sm text-blue-700 flex items-start gap-2">
|
||||
<svg className="w-4 h-4 mt-0.5 text-blue-500 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
Alle Daten werden in Deutschland gespeichert (PostgreSQL)
|
||||
</li>
|
||||
<li className="text-sm text-blue-700 flex items-start gap-2">
|
||||
<svg className="w-4 h-4 mt-0.5 text-blue-500 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
Keine personenbezogenen Daten in Alerts (nur URLs und Snippets)
|
||||
</li>
|
||||
<li className="text-sm text-blue-700 flex items-start gap-2">
|
||||
<svg className="w-4 h-4 mt-0.5 text-blue-500 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
LLM-Verarbeitung kann on-premise mit Ollama/vLLM erfolgen
|
||||
</li>
|
||||
<li className="text-sm text-blue-700 flex items-start gap-2">
|
||||
<svg className="w-4 h-4 mt-0.5 text-blue-500 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
DSGVO-konforme Datenverarbeitung
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Documentation Tab */}
|
||||
{activeTab === 'documentation' && (
|
||||
<div className="bg-white rounded-xl border border-slate-200 p-6 overflow-auto max-h-[calc(100vh-350px)]">
|
||||
<div className="prose prose-slate max-w-none prose-headings:font-semibold prose-h1:text-2xl prose-h2:text-xl prose-h3:text-lg">
|
||||
{/* Header */}
|
||||
<div className="not-prose mb-8 pb-6 border-b border-slate-200">
|
||||
<h1 className="text-2xl font-bold text-slate-900">BreakPilot Alerts Agent</h1>
|
||||
<p className="text-sm text-slate-500 mt-1">Version: 1.0.0 | Stand: Januar 2026 | Autor: BreakPilot Development Team</p>
|
||||
</div>
|
||||
|
||||
{/* Audit Box */}
|
||||
<div className="not-prose bg-blue-50 border border-blue-200 rounded-lg p-4 mb-6">
|
||||
<h3 className="font-semibold text-blue-900 mb-2">Audit-Relevante Informationen</h3>
|
||||
<p className="text-sm text-blue-800">
|
||||
Dieses Dokument dient als technische Dokumentation fuer das Alert-Monitoring-System der BreakPilot Plattform.
|
||||
Es ist fuer Audits durch Bildungstraeger und Datenschutzbeauftragte konzipiert.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Ziel des Systems */}
|
||||
<h2>Ziel des Alert-Systems</h2>
|
||||
<p>Das System ermoeglicht automatisierte Ueberwachung von Bildungsthemen mit:</p>
|
||||
<ul>
|
||||
<li><strong>Google Alerts Integration</strong>: RSS-Feeds von Google Alerts automatisch abrufen</li>
|
||||
<li><strong>RSS/Atom Feeds</strong>: Beliebige Nachrichtenquellen einbinden</li>
|
||||
<li><strong>KI-Relevanzpruefung</strong>: Automatische Bewertung der Relevanz durch LLM</li>
|
||||
<li><strong>Regelbasierte Filterung</strong>: Flexible Regeln fuer automatische Sortierung</li>
|
||||
<li><strong>Multi-Channel Actions</strong>: E-Mail, Webhook, Slack Benachrichtigungen</li>
|
||||
<li><strong>Few-Shot Learning</strong>: Profil verbessert sich durch Nutzerfeedback</li>
|
||||
</ul>
|
||||
|
||||
{/* Architecture Diagram */}
|
||||
<h2>Systemarchitektur</h2>
|
||||
<div className="not-prose bg-slate-900 rounded-lg p-4 overflow-x-auto">
|
||||
<pre className="text-green-400 text-xs">{`
|
||||
┌─────────────────────────────────────────────────────────────────────┐
|
||||
│ BreakPilot Alerts Frontend │
|
||||
│ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ ┌──────────┐│
|
||||
│ │ Dashboard │ │ Inbox │ │ Topics │ │ Profile ││
|
||||
│ └──────────────┘ └──────────────┘ └──────────────┘ └──────────┘│
|
||||
└───────────────────────────────┬─────────────────────────────────────┘
|
||||
│
|
||||
v
|
||||
┌─────────────────────────────────────────────────────────────────────┐
|
||||
│ Ingestion Layer │
|
||||
│ ┌────────────────┐ ┌────────────────┐ ┌────────────────┐ │
|
||||
│ │ RSS Fetcher │ │ Email Parser │ │ APScheduler │ │
|
||||
│ └───────┬────────┘ └───────┬────────┘ └───────┬────────┘ │
|
||||
│ └───────────────────┼───────────────────┘ │
|
||||
│ ┌──────────────────────────────────────────────────────┐ │
|
||||
│ │ Deduplication (URL-Hash + SimHash) │ │
|
||||
│ └──────────────────────────────────────────────────────┘ │
|
||||
└─────────────────────────────────────────────────────────────────────┘
|
||||
│
|
||||
v
|
||||
┌─────────────────────────────────────────────────────────────────────┐
|
||||
│ Processing Layer │
|
||||
│ ┌──────────────────────────────────────────────────────┐ │
|
||||
│ │ Rule Engine │ │
|
||||
│ └──────────────────────────────────────────────────────┘ │
|
||||
│ ┌──────────────────────────────────────────────────────┐ │
|
||||
│ │ LLM Relevance Scorer │ │
|
||||
│ │ Output: { score, decision: KEEP/DROP/REVIEW } │ │
|
||||
│ └──────────────────────────────────────────────────────┘ │
|
||||
└─────────────────────────────────────────────────────────────────────┘
|
||||
│
|
||||
v
|
||||
┌─────────────────────────────────────────────────────────────────────┐
|
||||
│ Action Layer │
|
||||
│ ┌────────────────┐ ┌────────────────┐ ┌────────────────┐ │
|
||||
│ │ Email Action │ │ Webhook Action │ │ Slack Action │ │
|
||||
│ └────────────────┘ └────────────────┘ └────────────────┘ │
|
||||
└─────────────────────────────────────────────────────────────────────┘
|
||||
│
|
||||
v
|
||||
┌─────────────────────────────────────────────────────────────────────┐
|
||||
│ Storage Layer │
|
||||
│ ┌────────────────┐ ┌────────────────┐ ┌────────────────┐ │
|
||||
│ │ PostgreSQL │ │ Valkey │ │ LLM Gateway │ │
|
||||
│ └────────────────┘ └────────────────┘ └────────────────┘ │
|
||||
└─────────────────────────────────────────────────────────────────────┘`}</pre>
|
||||
</div>
|
||||
|
||||
{/* API Endpoints */}
|
||||
<h2>API Endpoints</h2>
|
||||
<div className="not-prose overflow-x-auto">
|
||||
<table className="min-w-full text-sm border border-slate-200 rounded-lg">
|
||||
<thead className="bg-slate-50">
|
||||
<tr>
|
||||
<th className="px-4 py-2 text-left font-semibold text-slate-700 border-b">Endpoint</th>
|
||||
<th className="px-4 py-2 text-left font-semibold text-slate-700 border-b">Methode</th>
|
||||
<th className="px-4 py-2 text-left font-semibold text-slate-700 border-b">Beschreibung</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-slate-200">
|
||||
<tr><td className="px-4 py-2 font-mono text-xs">/api/alerts/inbox</td><td className="px-4 py-2">GET</td><td className="px-4 py-2 text-slate-600">Inbox Items abrufen</td></tr>
|
||||
<tr><td className="px-4 py-2 font-mono text-xs">/api/alerts/ingest</td><td className="px-4 py-2">POST</td><td className="px-4 py-2 text-slate-600">Manuell Alert importieren</td></tr>
|
||||
<tr><td className="px-4 py-2 font-mono text-xs">/api/alerts/topics</td><td className="px-4 py-2">GET/POST</td><td className="px-4 py-2 text-slate-600">Topics verwalten</td></tr>
|
||||
<tr><td className="px-4 py-2 font-mono text-xs">/api/alerts/rules</td><td className="px-4 py-2">GET/POST</td><td className="px-4 py-2 text-slate-600">Regeln verwalten</td></tr>
|
||||
<tr><td className="px-4 py-2 font-mono text-xs">/api/alerts/profile</td><td className="px-4 py-2">GET/PUT</td><td className="px-4 py-2 text-slate-600">Profil abrufen/aktualisieren</td></tr>
|
||||
<tr><td className="px-4 py-2 font-mono text-xs">/api/alerts/stats</td><td className="px-4 py-2">GET</td><td className="px-4 py-2 text-slate-600">Statistiken abrufen</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* Rule Engine */}
|
||||
<h2>Rule Engine - Operatoren</h2>
|
||||
<div className="not-prose overflow-x-auto">
|
||||
<table className="min-w-full text-sm border border-slate-200 rounded-lg">
|
||||
<thead className="bg-slate-50">
|
||||
<tr>
|
||||
<th className="px-4 py-2 text-left font-semibold text-slate-700 border-b">Operator</th>
|
||||
<th className="px-4 py-2 text-left font-semibold text-slate-700 border-b">Beschreibung</th>
|
||||
<th className="px-4 py-2 text-left font-semibold text-slate-700 border-b">Beispiel</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-slate-200">
|
||||
<tr><td className="px-4 py-2 font-mono text-xs">contains</td><td className="px-4 py-2">Text enthaelt</td><td className="px-4 py-2 text-slate-600">title contains "Inklusion"</td></tr>
|
||||
<tr><td className="px-4 py-2 font-mono text-xs">not_contains</td><td className="px-4 py-2">Text enthaelt nicht</td><td className="px-4 py-2 text-slate-600">title not_contains "Werbung"</td></tr>
|
||||
<tr><td className="px-4 py-2 font-mono text-xs">equals</td><td className="px-4 py-2">Exakte Uebereinstimmung</td><td className="px-4 py-2 text-slate-600">status equals "new"</td></tr>
|
||||
<tr><td className="px-4 py-2 font-mono text-xs">regex</td><td className="px-4 py-2">Regulaerer Ausdruck</td><td className="px-4 py-2 text-slate-600">title regex "\d{4}"</td></tr>
|
||||
<tr><td className="px-4 py-2 font-mono text-xs">gt / lt</td><td className="px-4 py-2">Groesser/Kleiner</td><td className="px-4 py-2 text-slate-600">relevance_score gt 0.8</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* Scoring */}
|
||||
<h2>LLM Relevanz-Scoring</h2>
|
||||
<div className="not-prose overflow-x-auto">
|
||||
<table className="min-w-full text-sm border border-slate-200 rounded-lg">
|
||||
<thead className="bg-slate-50">
|
||||
<tr>
|
||||
<th className="px-4 py-2 text-left font-semibold text-slate-700 border-b">Entscheidung</th>
|
||||
<th className="px-4 py-2 text-left font-semibold text-slate-700 border-b">Score-Bereich</th>
|
||||
<th className="px-4 py-2 text-left font-semibold text-slate-700 border-b">Bedeutung</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-slate-200">
|
||||
<tr className="bg-green-50"><td className="px-4 py-2 font-semibold text-green-800">KEEP</td><td className="px-4 py-2">0.7 - 1.0</td><td className="px-4 py-2">Klar relevant, in Inbox anzeigen</td></tr>
|
||||
<tr className="bg-amber-50"><td className="px-4 py-2 font-semibold text-amber-800">REVIEW</td><td className="px-4 py-2">0.4 - 0.7</td><td className="px-4 py-2">Unsicher, Nutzer entscheidet</td></tr>
|
||||
<tr className="bg-red-50"><td className="px-4 py-2 font-semibold text-red-800">DROP</td><td className="px-4 py-2">0.0 - 0.4</td><td className="px-4 py-2">Irrelevant, automatisch archivieren</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* Contact */}
|
||||
<h2>Kontakt & Support</h2>
|
||||
<div className="not-prose overflow-x-auto">
|
||||
<table className="min-w-full text-sm border border-slate-200 rounded-lg">
|
||||
<thead className="bg-slate-50">
|
||||
<tr>
|
||||
<th className="px-4 py-2 text-left font-semibold text-slate-700 border-b">Kontakt</th>
|
||||
<th className="px-4 py-2 text-left font-semibold text-slate-700 border-b">Adresse</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-slate-200">
|
||||
<tr><td className="px-4 py-2">Technischer Support</td><td className="px-4 py-2">support@breakpilot.de</td></tr>
|
||||
<tr><td className="px-4 py-2">Datenschutzbeauftragter</td><td className="px-4 py-2">dsb@breakpilot.de</td></tr>
|
||||
<tr><td className="px-4 py-2">Dokumentation</td><td className="px-4 py-2">docs.breakpilot.de</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="not-prose mt-8 pt-6 border-t border-slate-200 text-sm text-slate-500">
|
||||
<p>Dokumentation erstellt: Januar 2026 | Version: 1.0.0</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,946 @@
|
||||
'use client'
|
||||
|
||||
/**
|
||||
* Unified Inbox Mail Admin Page
|
||||
* Migrated from website/admin/mail to admin-v2/communication/mail
|
||||
*
|
||||
* Admin interface for managing email accounts, viewing system status,
|
||||
* and configuring AI analysis settings.
|
||||
*/
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import Link from 'next/link'
|
||||
import { PagePurpose } from '@/components/common/PagePurpose'
|
||||
|
||||
// API Base URL for backend operations (accounts, sync, etc.)
|
||||
const API_BASE = process.env.NEXT_PUBLIC_KLAUSUR_SERVICE_URL || 'http://macmini:8086'
|
||||
|
||||
// Types
|
||||
interface EmailAccount {
|
||||
id: string
|
||||
email: string
|
||||
displayName: string
|
||||
imapHost: string
|
||||
imapPort: number
|
||||
smtpHost: string
|
||||
smtpPort: number
|
||||
status: 'active' | 'inactive' | 'error' | 'syncing'
|
||||
lastSync: string | null
|
||||
emailCount: number
|
||||
unreadCount: number
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
interface MailStats {
|
||||
totalAccounts: number
|
||||
activeAccounts: number
|
||||
totalEmails: number
|
||||
unreadEmails: number
|
||||
totalTasks: number
|
||||
pendingTasks: number
|
||||
overdueTasks: number
|
||||
aiAnalyzedCount: number
|
||||
lastSyncTime: string | null
|
||||
}
|
||||
|
||||
interface SyncStatus {
|
||||
running: boolean
|
||||
accountsInProgress: string[]
|
||||
lastCompleted: string | null
|
||||
errors: string[]
|
||||
}
|
||||
|
||||
// Tab definitions
|
||||
type TabId = 'overview' | 'accounts' | 'ai-settings' | 'templates' | 'logs'
|
||||
|
||||
const tabs: { id: TabId; name: string }[] = [
|
||||
{ id: 'overview', name: 'Uebersicht' },
|
||||
{ id: 'accounts', name: 'Konten' },
|
||||
{ id: 'ai-settings', name: 'KI-Einstellungen' },
|
||||
{ id: 'templates', name: 'Vorlagen' },
|
||||
{ id: 'logs', name: 'Audit-Log' },
|
||||
]
|
||||
|
||||
// Main Component
|
||||
export default function MailAdminPage() {
|
||||
const [activeTab, setActiveTab] = useState<TabId>('overview')
|
||||
const [stats, setStats] = useState<MailStats | null>(null)
|
||||
const [accounts, setAccounts] = useState<EmailAccount[]>([])
|
||||
const [syncStatus, setSyncStatus] = useState<SyncStatus | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
const fetchData = useCallback(async () => {
|
||||
try {
|
||||
setLoading(true)
|
||||
|
||||
// Fetch stats via our proxy API (avoids CORS/mixed-content issues)
|
||||
const response = await fetch('/api/admin/mail')
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json()
|
||||
setStats(data.stats)
|
||||
setAccounts(data.accounts)
|
||||
setSyncStatus(data.syncStatus)
|
||||
setError(null)
|
||||
} else {
|
||||
const errorData = await response.json().catch(() => ({}))
|
||||
throw new Error(errorData.details || `API returned ${response.status}`)
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch mail data:', err)
|
||||
setError('Verbindung zum Mail-Service (Mailpit) fehlgeschlagen. Laeuft Mailpit auf Port 8025?')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
fetchData()
|
||||
|
||||
// Refresh every 10 seconds if syncing
|
||||
const interval = setInterval(() => {
|
||||
if (syncStatus?.running) {
|
||||
fetchData()
|
||||
}
|
||||
}, 10000)
|
||||
|
||||
return () => clearInterval(interval)
|
||||
}, [fetchData, syncStatus?.running])
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* Page Purpose */}
|
||||
<PagePurpose
|
||||
title="Unified Inbox"
|
||||
purpose="Verwalten Sie E-Mail-Konten, synchronisieren Sie Postfaecher und konfigurieren Sie die KI-gestuetzte E-Mail-Analyse fuer automatische Kategorisierung und Aufgabenerkennung."
|
||||
audience={['Admins', 'Schulleitung']}
|
||||
architecture={{
|
||||
services: ['Mailpit (Dev Mail Catcher)', 'IMAP/SMTP Server (Prod)'],
|
||||
databases: ['PostgreSQL', 'Vault (Credentials)'],
|
||||
}}
|
||||
relatedPages={[
|
||||
{ name: 'Mail Wizard', href: '/communication/mail/wizard', description: 'Interaktives Setup und Testing' },
|
||||
{ name: 'Voice Service', href: '/communication/matrix', description: 'Voice-First Interface' },
|
||||
]}
|
||||
collapsible={true}
|
||||
defaultCollapsed={false}
|
||||
/>
|
||||
|
||||
{/* Quick Link to Wizard */}
|
||||
<div className="mb-6">
|
||||
<Link
|
||||
href="/communication/mail/wizard"
|
||||
className="inline-flex items-center gap-2 px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors"
|
||||
>
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 10V3L4 14h7v7l9-11h-7z" />
|
||||
</svg>
|
||||
Mail Wizard starten
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* Error Banner */}
|
||||
{error && (
|
||||
<div className="mb-6 bg-red-50 border border-red-200 rounded-lg p-4 flex items-center gap-3">
|
||||
<svg className="w-5 h-5 text-red-500 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
<span className="text-red-700">{error}</span>
|
||||
<button onClick={fetchData} className="ml-auto text-red-600 hover:text-red-800 text-sm font-medium">
|
||||
Erneut versuchen
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Tab Navigation */}
|
||||
<div className="border-b border-slate-200 mb-6">
|
||||
<nav className="-mb-px flex space-x-8">
|
||||
{tabs.map((tab) => (
|
||||
<button
|
||||
key={tab.id}
|
||||
onClick={() => setActiveTab(tab.id)}
|
||||
className={`
|
||||
flex items-center gap-2 py-4 px-1 border-b-2 font-medium text-sm transition-colors
|
||||
${activeTab === tab.id
|
||||
? 'border-blue-500 text-blue-600'
|
||||
: 'border-transparent text-slate-500 hover:text-slate-700 hover:border-slate-300'
|
||||
}
|
||||
`}
|
||||
>
|
||||
{tab.name}
|
||||
</button>
|
||||
))}
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
{/* Tab Content */}
|
||||
{activeTab === 'overview' && (
|
||||
<OverviewTab
|
||||
stats={stats}
|
||||
syncStatus={syncStatus}
|
||||
loading={loading}
|
||||
onRefresh={fetchData}
|
||||
/>
|
||||
)}
|
||||
{activeTab === 'accounts' && (
|
||||
<AccountsTab
|
||||
accounts={accounts}
|
||||
loading={loading}
|
||||
onRefresh={fetchData}
|
||||
/>
|
||||
)}
|
||||
{activeTab === 'ai-settings' && (
|
||||
<AISettingsTab />
|
||||
)}
|
||||
{activeTab === 'templates' && (
|
||||
<TemplatesTab />
|
||||
)}
|
||||
{activeTab === 'logs' && (
|
||||
<AuditLogTab />
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Overview Tab
|
||||
// ============================================================================
|
||||
|
||||
function OverviewTab({
|
||||
stats,
|
||||
syncStatus,
|
||||
loading,
|
||||
onRefresh
|
||||
}: {
|
||||
stats: MailStats | null
|
||||
syncStatus: SyncStatus | null
|
||||
loading: boolean
|
||||
onRefresh: () => void
|
||||
}) {
|
||||
const triggerSync = async () => {
|
||||
try {
|
||||
await fetch(`${API_BASE}/api/v1/mail/sync/all`, {
|
||||
method: 'POST',
|
||||
})
|
||||
onRefresh()
|
||||
} catch (err) {
|
||||
console.error('Failed to trigger sync:', err)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-slate-900">System-Uebersicht</h2>
|
||||
<p className="text-sm text-slate-500">Status aller E-Mail-Konten und Aufgaben</p>
|
||||
</div>
|
||||
<div className="flex gap-3">
|
||||
<button
|
||||
onClick={onRefresh}
|
||||
className="px-4 py-2 text-sm font-medium text-slate-700 bg-white border border-slate-300 rounded-lg hover:bg-slate-50"
|
||||
>
|
||||
Aktualisieren
|
||||
</button>
|
||||
<button
|
||||
onClick={triggerSync}
|
||||
disabled={syncStatus?.running}
|
||||
className="px-4 py-2 text-sm font-medium text-white bg-blue-600 rounded-lg hover:bg-blue-700 disabled:opacity-50"
|
||||
>
|
||||
{syncStatus?.running ? 'Synchronisiert...' : 'Alle synchronisieren'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Loading State */}
|
||||
{loading && (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600"></div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Stats Grid */}
|
||||
{!loading && stats && (
|
||||
<>
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
<StatCard
|
||||
title="E-Mail-Konten"
|
||||
value={stats.totalAccounts}
|
||||
subtitle={`${stats.activeAccounts} aktiv`}
|
||||
color="blue"
|
||||
/>
|
||||
<StatCard
|
||||
title="E-Mails gesamt"
|
||||
value={stats.totalEmails}
|
||||
subtitle={`${stats.unreadEmails} ungelesen`}
|
||||
color="green"
|
||||
/>
|
||||
<StatCard
|
||||
title="Aufgaben"
|
||||
value={stats.totalTasks}
|
||||
subtitle={`${stats.pendingTasks} offen`}
|
||||
color="yellow"
|
||||
/>
|
||||
<StatCard
|
||||
title="Ueberfaellig"
|
||||
value={stats.overdueTasks}
|
||||
color={stats.overdueTasks > 0 ? 'red' : 'green'}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Sync Status */}
|
||||
<div className="bg-white rounded-lg border border-slate-200 p-6">
|
||||
<h3 className="text-sm font-medium text-slate-700 mb-4">Synchronisierung</h3>
|
||||
<div className="flex items-center gap-4">
|
||||
{syncStatus?.running ? (
|
||||
<>
|
||||
<div className="w-3 h-3 bg-yellow-500 rounded-full animate-pulse"></div>
|
||||
<span className="text-slate-600">
|
||||
Synchronisiere {syncStatus.accountsInProgress.length} Konto(en)...
|
||||
</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className="w-3 h-3 bg-green-500 rounded-full"></div>
|
||||
<span className="text-slate-600">Bereit</span>
|
||||
</>
|
||||
)}
|
||||
{stats.lastSyncTime && (
|
||||
<span className="text-sm text-slate-500 ml-auto">
|
||||
Letzte Sync: {new Date(stats.lastSyncTime).toLocaleString('de-DE')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{syncStatus?.errors && syncStatus.errors.length > 0 && (
|
||||
<div className="mt-4 p-4 bg-red-50 rounded-lg">
|
||||
<h4 className="text-sm font-medium text-red-800 mb-2">Fehler</h4>
|
||||
<ul className="text-sm text-red-700 space-y-1">
|
||||
{syncStatus.errors.slice(0, 3).map((error, i) => (
|
||||
<li key={i}>{error}</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* AI Stats */}
|
||||
<div className="bg-white rounded-lg border border-slate-200 p-6">
|
||||
<h3 className="text-sm font-medium text-slate-700 mb-4">KI-Analyse</h3>
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-6">
|
||||
<div>
|
||||
<p className="text-xs text-slate-500 uppercase tracking-wider">Analysiert</p>
|
||||
<p className="text-2xl font-bold text-slate-900">{stats.aiAnalyzedCount}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-slate-500 uppercase tracking-wider">Analyse-Rate</p>
|
||||
<p className="text-2xl font-bold text-slate-900">
|
||||
{stats.totalEmails > 0
|
||||
? `${Math.round((stats.aiAnalyzedCount / stats.totalEmails) * 100)}%`
|
||||
: '0%'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function StatCard({
|
||||
title,
|
||||
value,
|
||||
subtitle,
|
||||
color = 'blue'
|
||||
}: {
|
||||
title: string
|
||||
value: number
|
||||
subtitle?: string
|
||||
color?: 'blue' | 'green' | 'yellow' | 'red'
|
||||
}) {
|
||||
const colorClasses = {
|
||||
blue: 'text-blue-600',
|
||||
green: 'text-green-600',
|
||||
yellow: 'text-yellow-600',
|
||||
red: 'text-red-600',
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="bg-white rounded-lg border border-slate-200 p-6">
|
||||
<p className="text-xs text-slate-500 uppercase tracking-wider mb-1">{title}</p>
|
||||
<p className={`text-3xl font-bold ${colorClasses[color]}`}>{value.toLocaleString()}</p>
|
||||
{subtitle && <p className="text-sm text-slate-500 mt-1">{subtitle}</p>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Accounts Tab
|
||||
// ============================================================================
|
||||
|
||||
function AccountsTab({
|
||||
accounts,
|
||||
loading,
|
||||
onRefresh
|
||||
}: {
|
||||
accounts: EmailAccount[]
|
||||
loading: boolean
|
||||
onRefresh: () => void
|
||||
}) {
|
||||
const [showAddModal, setShowAddModal] = useState(false)
|
||||
|
||||
const testConnection = async (accountId: string) => {
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/api/v1/mail/accounts/${accountId}/test`, {
|
||||
method: 'POST',
|
||||
})
|
||||
if (res.ok) {
|
||||
alert('Verbindung erfolgreich!')
|
||||
} else {
|
||||
alert('Verbindungsfehler')
|
||||
}
|
||||
} catch (err) {
|
||||
alert('Verbindungsfehler')
|
||||
}
|
||||
}
|
||||
|
||||
const statusColors = {
|
||||
active: 'bg-green-100 text-green-800',
|
||||
inactive: 'bg-gray-100 text-gray-800',
|
||||
error: 'bg-red-100 text-red-800',
|
||||
syncing: 'bg-yellow-100 text-yellow-800',
|
||||
}
|
||||
|
||||
const statusLabels = {
|
||||
active: 'Aktiv',
|
||||
inactive: 'Inaktiv',
|
||||
error: 'Fehler',
|
||||
syncing: 'Synchronisiert...',
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-slate-900">E-Mail-Konten</h2>
|
||||
<p className="text-sm text-slate-500">Verwalten Sie die verbundenen E-Mail-Konten</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setShowAddModal(true)}
|
||||
className="px-4 py-2 text-sm font-medium text-white bg-blue-600 rounded-lg hover:bg-blue-700 flex items-center gap-2"
|
||||
>
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 6v6m0 0v6m0-6h6m-6 0H6" />
|
||||
</svg>
|
||||
Konto hinzufuegen
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Loading State */}
|
||||
{loading && (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600"></div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Accounts Grid */}
|
||||
{!loading && (
|
||||
<div className="grid gap-4">
|
||||
{accounts.length === 0 ? (
|
||||
<div className="bg-slate-50 rounded-lg p-8 text-center">
|
||||
<svg className="w-12 h-12 text-slate-400 mx-auto mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" />
|
||||
</svg>
|
||||
<h3 className="text-lg font-medium text-slate-900 mb-2">Keine E-Mail-Konten</h3>
|
||||
<p className="text-slate-500 mb-4">Fuegen Sie Ihr erstes E-Mail-Konto hinzu.</p>
|
||||
</div>
|
||||
) : (
|
||||
accounts.map((account) => (
|
||||
<div
|
||||
key={account.id}
|
||||
className="bg-white rounded-lg border border-slate-200 p-6 hover:shadow-md transition-shadow"
|
||||
>
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="w-12 h-12 bg-blue-100 rounded-lg flex items-center justify-center">
|
||||
<svg className="w-6 h-6 text-blue-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" />
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-slate-900">
|
||||
{account.displayName || account.email}
|
||||
</h3>
|
||||
<p className="text-sm text-slate-500">{account.email}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<span className={`px-3 py-1 rounded-full text-xs font-medium ${statusColors[account.status]}`}>
|
||||
{statusLabels[account.status]}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => testConnection(account.id)}
|
||||
className="p-2 text-slate-400 hover:text-slate-600"
|
||||
title="Verbindung testen"
|
||||
>
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 10V3L4 14h7v7l9-11h-7z" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
<div>
|
||||
<p className="text-xs text-slate-500 uppercase tracking-wider">E-Mails</p>
|
||||
<p className="text-lg font-semibold text-slate-900">{account.emailCount}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-slate-500 uppercase tracking-wider">Ungelesen</p>
|
||||
<p className="text-lg font-semibold text-slate-900">{account.unreadCount}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-slate-500 uppercase tracking-wider">IMAP</p>
|
||||
<p className="text-sm font-mono text-slate-700">{account.imapHost}:{account.imapPort}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-slate-500 uppercase tracking-wider">Letzte Sync</p>
|
||||
<p className="text-sm text-slate-700">
|
||||
{account.lastSync
|
||||
? new Date(account.lastSync).toLocaleString('de-DE')
|
||||
: 'Nie'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Add Account Modal */}
|
||||
{showAddModal && (
|
||||
<AddAccountModal onClose={() => setShowAddModal(false)} onSuccess={() => { setShowAddModal(false); onRefresh(); }} />
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function AddAccountModal({
|
||||
onClose,
|
||||
onSuccess
|
||||
}: {
|
||||
onClose: () => void
|
||||
onSuccess: () => void
|
||||
}) {
|
||||
const [formData, setFormData] = useState({
|
||||
email: '',
|
||||
displayName: '',
|
||||
imapHost: '',
|
||||
imapPort: 993,
|
||||
smtpHost: '',
|
||||
smtpPort: 587,
|
||||
username: '',
|
||||
password: '',
|
||||
})
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
setSubmitting(true)
|
||||
setError(null)
|
||||
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/api/v1/mail/accounts`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
email: formData.email,
|
||||
display_name: formData.displayName,
|
||||
imap_host: formData.imapHost,
|
||||
imap_port: formData.imapPort,
|
||||
smtp_host: formData.smtpHost,
|
||||
smtp_port: formData.smtpPort,
|
||||
username: formData.username,
|
||||
password: formData.password,
|
||||
}),
|
||||
})
|
||||
|
||||
if (res.ok) {
|
||||
onSuccess()
|
||||
} else {
|
||||
const data = await res.json()
|
||||
setError(data.detail || 'Fehler beim Hinzufuegen des Kontos')
|
||||
}
|
||||
} catch (err) {
|
||||
setError('Netzwerkfehler')
|
||||
} finally {
|
||||
setSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
|
||||
<div className="bg-white rounded-lg shadow-xl max-w-lg w-full mx-4 max-h-[90vh] overflow-y-auto">
|
||||
<div className="p-6 border-b border-slate-200">
|
||||
<h2 className="text-lg font-semibold text-slate-900">E-Mail-Konto hinzufuegen</h2>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="p-6 space-y-4">
|
||||
{error && (
|
||||
<div className="p-3 bg-red-50 text-red-700 rounded-lg text-sm">{error}</div>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="col-span-2">
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1">E-Mail-Adresse</label>
|
||||
<input
|
||||
type="email"
|
||||
required
|
||||
value={formData.email}
|
||||
onChange={(e) => setFormData({ ...formData, email: e.target.value })}
|
||||
className="w-full px-3 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-blue-500"
|
||||
placeholder="schulleitung@grundschule-xy.de"
|
||||
/>
|
||||
</div>
|
||||
<div className="col-span-2">
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1">Anzeigename</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.displayName}
|
||||
onChange={(e) => setFormData({ ...formData, displayName: e.target.value })}
|
||||
className="w-full px-3 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-blue-500"
|
||||
placeholder="Schulleitung"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1">IMAP Server</label>
|
||||
<input
|
||||
type="text"
|
||||
required
|
||||
value={formData.imapHost}
|
||||
onChange={(e) => setFormData({ ...formData, imapHost: e.target.value })}
|
||||
className="w-full px-3 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-blue-500"
|
||||
placeholder="imap.example.com"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1">IMAP Port</label>
|
||||
<input
|
||||
type="number"
|
||||
required
|
||||
value={formData.imapPort}
|
||||
onChange={(e) => setFormData({ ...formData, imapPort: parseInt(e.target.value) })}
|
||||
className="w-full px-3 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-blue-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1">SMTP Server</label>
|
||||
<input
|
||||
type="text"
|
||||
required
|
||||
value={formData.smtpHost}
|
||||
onChange={(e) => setFormData({ ...formData, smtpHost: e.target.value })}
|
||||
className="w-full px-3 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-blue-500"
|
||||
placeholder="smtp.example.com"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1">SMTP Port</label>
|
||||
<input
|
||||
type="number"
|
||||
required
|
||||
value={formData.smtpPort}
|
||||
onChange={(e) => setFormData({ ...formData, smtpPort: parseInt(e.target.value) })}
|
||||
className="w-full px-3 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-blue-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="col-span-2">
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1">Benutzername</label>
|
||||
<input
|
||||
type="text"
|
||||
required
|
||||
value={formData.username}
|
||||
onChange={(e) => setFormData({ ...formData, username: e.target.value })}
|
||||
className="w-full px-3 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-blue-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="col-span-2">
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1">Passwort</label>
|
||||
<input
|
||||
type="password"
|
||||
required
|
||||
value={formData.password}
|
||||
onChange={(e) => setFormData({ ...formData, password: e.target.value })}
|
||||
className="w-full px-3 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-blue-500"
|
||||
/>
|
||||
<p className="text-xs text-slate-500 mt-1">
|
||||
Das Passwort wird verschluesselt in Vault gespeichert.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-3 pt-4 border-t border-slate-200">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="px-4 py-2 text-sm font-medium text-slate-700 hover:bg-slate-100 rounded-lg"
|
||||
>
|
||||
Abbrechen
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={submitting}
|
||||
className="px-4 py-2 text-sm font-medium text-white bg-blue-600 rounded-lg hover:bg-blue-700 disabled:opacity-50"
|
||||
>
|
||||
{submitting ? 'Speichern...' : 'Konto hinzufuegen'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// AI Settings Tab
|
||||
// ============================================================================
|
||||
|
||||
function AISettingsTab() {
|
||||
const [settings, setSettings] = useState({
|
||||
autoAnalyze: true,
|
||||
autoCreateTasks: true,
|
||||
analysisModel: 'breakpilot-teacher-8b',
|
||||
confidenceThreshold: 0.7,
|
||||
})
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-slate-900">KI-Einstellungen</h2>
|
||||
<p className="text-sm text-slate-500">Konfigurieren Sie die automatische E-Mail-Analyse</p>
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-lg border border-slate-200 p-6 space-y-6">
|
||||
{/* Auto-Analyze */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h3 className="text-sm font-medium text-slate-900">Automatische Analyse</h3>
|
||||
<p className="text-sm text-slate-500">E-Mails automatisch beim Empfang analysieren</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setSettings({ ...settings, autoAnalyze: !settings.autoAnalyze })}
|
||||
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors ${
|
||||
settings.autoAnalyze ? 'bg-blue-600' : 'bg-slate-200'
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${
|
||||
settings.autoAnalyze ? 'translate-x-6' : 'translate-x-1'
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Auto-Create Tasks */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h3 className="text-sm font-medium text-slate-900">Aufgaben automatisch erstellen</h3>
|
||||
<p className="text-sm text-slate-500">Erkannte Fristen als Aufgaben anlegen</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setSettings({ ...settings, autoCreateTasks: !settings.autoCreateTasks })}
|
||||
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors ${
|
||||
settings.autoCreateTasks ? 'bg-blue-600' : 'bg-slate-200'
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${
|
||||
settings.autoCreateTasks ? 'translate-x-6' : 'translate-x-1'
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Model Selection */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-2">Analyse-Modell</label>
|
||||
<select
|
||||
value={settings.analysisModel}
|
||||
onChange={(e) => setSettings({ ...settings, analysisModel: e.target.value })}
|
||||
className="w-full md:w-64 px-3 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-blue-500"
|
||||
>
|
||||
<option value="breakpilot-teacher-8b">BreakPilot Teacher 8B (schnell)</option>
|
||||
<option value="breakpilot-teacher-70b">BreakPilot Teacher 70B (genau)</option>
|
||||
<option value="llama-3.1-8b-instruct">Llama 3.1 8B Instruct</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Confidence Threshold */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-2">
|
||||
Konfidenz-Schwelle: {Math.round(settings.confidenceThreshold * 100)}%
|
||||
</label>
|
||||
<input
|
||||
type="range"
|
||||
min="0.5"
|
||||
max="0.95"
|
||||
step="0.05"
|
||||
value={settings.confidenceThreshold}
|
||||
onChange={(e) => setSettings({ ...settings, confidenceThreshold: parseFloat(e.target.value) })}
|
||||
className="w-full md:w-64"
|
||||
/>
|
||||
<p className="text-xs text-slate-500 mt-1">
|
||||
Mindest-Konfidenz fuer automatische Aufgabenerstellung
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Sender Classification */}
|
||||
<div className="bg-white rounded-lg border border-slate-200 p-6">
|
||||
<h3 className="text-sm font-medium text-slate-700 mb-4">Bekannte Absender (Niedersachsen)</h3>
|
||||
<div className="grid grid-cols-2 md:grid-cols-3 gap-3">
|
||||
{[
|
||||
{ domain: '@mk.niedersachsen.de', type: 'Kultusministerium', priority: 'Hoch' },
|
||||
{ domain: '@rlsb.de', type: 'RLSB', priority: 'Hoch' },
|
||||
{ domain: '@landesschulbehoerde-nds.de', type: 'Landesschulbehoerde', priority: 'Hoch' },
|
||||
{ domain: '@nibis.de', type: 'NiBiS', priority: 'Mittel' },
|
||||
{ domain: '@schultraeger.de', type: 'Schultraeger', priority: 'Mittel' },
|
||||
].map((sender) => (
|
||||
<div key={sender.domain} className="p-3 bg-slate-50 rounded-lg">
|
||||
<p className="text-sm font-mono text-slate-700">{sender.domain}</p>
|
||||
<p className="text-xs text-slate-500">{sender.type}</p>
|
||||
<span className={`text-xs px-2 py-0.5 rounded ${
|
||||
sender.priority === 'Hoch' ? 'bg-red-100 text-red-700' : 'bg-yellow-100 text-yellow-700'
|
||||
}`}>
|
||||
{sender.priority}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Templates Tab
|
||||
// ============================================================================
|
||||
|
||||
function TemplatesTab() {
|
||||
const [templates] = useState([
|
||||
{ id: '1', name: 'Eingangsbestaetigung', category: 'Standard', usageCount: 45 },
|
||||
{ id: '2', name: 'Terminbestaetigung', category: 'Termine', usageCount: 23 },
|
||||
{ id: '3', name: 'Elternbrief-Vorlage', category: 'Eltern', usageCount: 67 },
|
||||
])
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-slate-900">E-Mail-Vorlagen</h2>
|
||||
<p className="text-sm text-slate-500">Verwalten Sie Antwort-Templates</p>
|
||||
</div>
|
||||
<button className="px-4 py-2 text-sm font-medium text-white bg-blue-600 rounded-lg hover:bg-blue-700 flex items-center gap-2">
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 6v6m0 0v6m0-6h6m-6 0H6" />
|
||||
</svg>
|
||||
Vorlage erstellen
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-lg border border-slate-200 overflow-hidden">
|
||||
<table className="min-w-full divide-y divide-slate-200">
|
||||
<thead className="bg-slate-50">
|
||||
<tr>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-slate-500 uppercase">Name</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-slate-500 uppercase">Kategorie</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-slate-500 uppercase">Verwendet</th>
|
||||
<th className="px-6 py-3 text-right text-xs font-medium text-slate-500 uppercase">Aktionen</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-slate-200">
|
||||
{templates.map((template) => (
|
||||
<tr key={template.id} className="hover:bg-slate-50">
|
||||
<td className="px-6 py-4 text-sm font-medium text-slate-900">{template.name}</td>
|
||||
<td className="px-6 py-4">
|
||||
<span className="px-2 py-1 bg-slate-100 text-slate-700 text-xs rounded">{template.category}</span>
|
||||
</td>
|
||||
<td className="px-6 py-4 text-sm text-slate-500">{template.usageCount}x</td>
|
||||
<td className="px-6 py-4 text-right">
|
||||
<button className="text-blue-600 hover:text-blue-800 text-sm font-medium">Bearbeiten</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Audit Log Tab
|
||||
// ============================================================================
|
||||
|
||||
function AuditLogTab() {
|
||||
const [logs] = useState([
|
||||
{ id: '1', action: 'account_created', user: 'admin@breakpilot.de', timestamp: new Date().toISOString(), details: 'Konto schulleitung@example.de hinzugefuegt' },
|
||||
{ id: '2', action: 'email_analyzed', user: 'system', timestamp: new Date(Date.now() - 3600000).toISOString(), details: '5 E-Mails analysiert' },
|
||||
{ id: '3', action: 'task_created', user: 'system', timestamp: new Date(Date.now() - 7200000).toISOString(), details: 'Aufgabe aus Fristenerkennung erstellt' },
|
||||
])
|
||||
|
||||
const actionLabels: Record<string, string> = {
|
||||
account_created: 'Konto erstellt',
|
||||
email_analyzed: 'E-Mail analysiert',
|
||||
task_created: 'Aufgabe erstellt',
|
||||
sync_completed: 'Sync abgeschlossen',
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-slate-900">Audit-Log</h2>
|
||||
<p className="text-sm text-slate-500">Alle Aktionen im Mail-System</p>
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-lg border border-slate-200 overflow-hidden">
|
||||
<table className="min-w-full divide-y divide-slate-200">
|
||||
<thead className="bg-slate-50">
|
||||
<tr>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-slate-500 uppercase">Zeit</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-slate-500 uppercase">Aktion</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-slate-500 uppercase">Benutzer</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-slate-500 uppercase">Details</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-slate-200">
|
||||
{logs.map((log) => (
|
||||
<tr key={log.id} className="hover:bg-slate-50">
|
||||
<td className="px-6 py-4 text-sm text-slate-500">
|
||||
{new Date(log.timestamp).toLocaleString('de-DE')}
|
||||
</td>
|
||||
<td className="px-6 py-4">
|
||||
<span className="px-2 py-1 bg-blue-100 text-blue-700 text-xs rounded font-medium">
|
||||
{actionLabels[log.action] || log.action}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-6 py-4 text-sm text-slate-700">{log.user}</td>
|
||||
<td className="px-6 py-4 text-sm text-slate-500">{log.details}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,421 @@
|
||||
'use client'
|
||||
|
||||
/**
|
||||
* Mail Wizard Page
|
||||
* Migrated from website/admin/mail/wizard to admin-v2/communication/mail/wizard
|
||||
*
|
||||
* Interaktives Lernen und Testen der E-Mail Integration
|
||||
*/
|
||||
|
||||
import { useState } from 'react'
|
||||
import Link from 'next/link'
|
||||
import { PagePurpose } from '@/components/common/PagePurpose'
|
||||
import {
|
||||
WizardStepper,
|
||||
WizardNavigation,
|
||||
EducationCard,
|
||||
ArchitectureContext,
|
||||
TestRunner,
|
||||
TestSummary,
|
||||
type WizardStep,
|
||||
type TestCategoryResult,
|
||||
type FullTestResults,
|
||||
type EducationContent,
|
||||
type ArchitectureContextType,
|
||||
} from '@/components/wizard'
|
||||
|
||||
// ==============================================
|
||||
// Constants
|
||||
// ==============================================
|
||||
|
||||
const BACKEND_URL = process.env.NEXT_PUBLIC_BACKEND_URL || 'http://macmini:8000'
|
||||
|
||||
const STEPS: WizardStep[] = [
|
||||
{ id: 'welcome', name: 'Willkommen', icon: '👋', status: 'pending' },
|
||||
{ id: 'smtp', name: 'SMTP', icon: '📤', status: 'pending', category: 'smtp' },
|
||||
{ id: 'imap', name: 'IMAP', icon: '📥', status: 'pending', category: 'imap' },
|
||||
{ id: 'templates', name: 'Templates', icon: '📝', status: 'pending', category: 'templates' },
|
||||
{ id: 'ai-analysis', name: 'KI-Analyse', icon: '🤖', status: 'pending', category: 'ai-analysis' },
|
||||
{ id: 'summary', name: 'Zusammenfassung', icon: '📊', status: 'pending' },
|
||||
]
|
||||
|
||||
const EDUCATION_CONTENT: Record<string, EducationContent> = {
|
||||
'welcome': {
|
||||
title: 'Willkommen zum Mail Wizard',
|
||||
content: [
|
||||
'E-Mail ist nach wie vor der wichtigste Kommunikationskanal mit Eltern.',
|
||||
'',
|
||||
'BreakPilot bietet:',
|
||||
'- SMTP: Versand von System-E-Mails (Benachrichtigungen, Newsletter)',
|
||||
'- IMAP: Empfang und Analyse eingehender E-Mails',
|
||||
'- Templates: Versionierte E-Mail-Vorlagen mit DSB-Freigabe',
|
||||
'- KI-Analyse: Automatische Kategorisierung und GFK-Pruefung',
|
||||
'',
|
||||
'In der Entwicklung nutzen wir Mailpit als Mail-Catcher.',
|
||||
'Alle E-Mails werden abgefangen und koennen inspiziert werden.',
|
||||
],
|
||||
},
|
||||
'smtp': {
|
||||
title: 'SMTP - Ausgehende E-Mails',
|
||||
content: [
|
||||
'SMTP (Simple Mail Transfer Protocol) sendet E-Mails.',
|
||||
'',
|
||||
'Typische Verwendung:',
|
||||
'- Passwort-Reset E-Mails',
|
||||
'- Einwilligungs-Erinnerungen',
|
||||
'- DSR-Kommunikation (Betroffenenanfragen)',
|
||||
'- Elternbriefe und Newsletter',
|
||||
'',
|
||||
'Entwicklungsumgebung:',
|
||||
'- Mailpit faengt alle E-Mails ab',
|
||||
'- Keine echten E-Mails werden versendet',
|
||||
'- Web-UI unter http://macmini:8025',
|
||||
'',
|
||||
'Produktion: Echter SMTP-Server (z.B. Postfix, SES)',
|
||||
],
|
||||
},
|
||||
'imap': {
|
||||
title: 'IMAP - Eingehende E-Mails',
|
||||
content: [
|
||||
'IMAP (Internet Message Access Protocol) empfaengt E-Mails.',
|
||||
'',
|
||||
'Anwendungsfaelle:',
|
||||
'- Eltern-Antworten auf Benachrichtigungen',
|
||||
'- Automatische Ticket-Erstellung aus E-Mails',
|
||||
'- Abwesenheitsmeldungen per E-Mail',
|
||||
'',
|
||||
'Verarbeitung:',
|
||||
'1. E-Mail wird empfangen',
|
||||
'2. KI analysiert Inhalt und Stimmung',
|
||||
'3. Automatische Kategorisierung',
|
||||
'4. Weiterleitung an zustaendige Stelle',
|
||||
'',
|
||||
'DSGVO: E-Mails werden nach Verarbeitung archiviert/geloescht',
|
||||
],
|
||||
},
|
||||
'templates': {
|
||||
title: 'E-Mail Templates - Versionierte Vorlagen',
|
||||
content: [
|
||||
'Alle System-E-Mails nutzen versionierte Templates.',
|
||||
'',
|
||||
'Workflow (wie bei rechtlichen Dokumenten):',
|
||||
'- draft: Entwurf wird erstellt',
|
||||
'- review: DSB/Admin prueft Inhalt',
|
||||
'- approved: Freigabe erteilt',
|
||||
'- published: Aktiv im System',
|
||||
'',
|
||||
'Template-Typen:',
|
||||
'- welcome: Willkommens-E-Mail',
|
||||
'- password_reset: Passwort zuruecksetzen',
|
||||
'- consent_reminder: Einwilligungs-Erinnerung',
|
||||
'- dsr_receipt: DSR-Eingangsbestaetigung',
|
||||
'',
|
||||
'Personalisierung: {{user.name}}, {{deadline}}, etc.',
|
||||
],
|
||||
},
|
||||
'ai-analysis': {
|
||||
title: 'KI-Analyse - LLM & GFK',
|
||||
content: [
|
||||
'KI-gestuetzte Analyse verbessert die Kommunikation.',
|
||||
'',
|
||||
'LLM-Funktionen:',
|
||||
'- Automatische Kategorisierung eingehender E-Mails',
|
||||
'- Sentiment-Analyse (positiv/neutral/negativ)',
|
||||
'- Zusammenfassung langer E-Mails',
|
||||
'- Antwort-Vorschlaege generieren',
|
||||
'',
|
||||
'GFK (Gewaltfreie Kommunikation):',
|
||||
'- Pruefung ausgehender Elternbriefe',
|
||||
'- Erkennung von "Du-Botschaften"',
|
||||
'- Vorschlaege fuer wertschaetzende Formulierung',
|
||||
'- Konfliktvermeidung durch bessere Sprache',
|
||||
'',
|
||||
'Optional: Nur aktiv wenn LLM_GATEWAY_ENABLED=true',
|
||||
],
|
||||
},
|
||||
'summary': {
|
||||
title: 'Test-Zusammenfassung',
|
||||
content: [
|
||||
'Hier sehen Sie eine Uebersicht aller durchgefuehrten Tests:',
|
||||
'- SMTP Server Verfuegbarkeit',
|
||||
'- IMAP Server Status',
|
||||
'- Template-Verwaltung',
|
||||
'- KI-Analyse Bereitschaft',
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
const ARCHITECTURE_CONTEXTS: Record<string, ArchitectureContextType> = {
|
||||
'smtp': {
|
||||
layer: 'service',
|
||||
services: ['backend', 'mailserver'],
|
||||
dependencies: ['Mailpit (Dev)', 'Postfix (Prod)', 'DNS/SPF/DKIM'],
|
||||
dataFlow: ['FastAPI', 'SMTP Client', 'Mailpit/Postfix', 'Recipient'],
|
||||
},
|
||||
'imap': {
|
||||
layer: 'service',
|
||||
services: ['backend', 'mailserver'],
|
||||
dependencies: ['IMAP Server', 'PostgreSQL', 'LLM Gateway'],
|
||||
dataFlow: ['Mailserver', 'IMAP Fetch', 'KI-Analyse', 'PostgreSQL'],
|
||||
},
|
||||
'templates': {
|
||||
layer: 'api',
|
||||
services: ['backend', 'consent-service'],
|
||||
dependencies: ['PostgreSQL', 'Template Engine', 'DSB Workflow'],
|
||||
dataFlow: ['Admin UI', 'FastAPI', 'email_templates', 'PostgreSQL'],
|
||||
},
|
||||
'ai-analysis': {
|
||||
layer: 'service',
|
||||
services: ['backend'],
|
||||
dependencies: ['LLM Gateway', 'OpenAI/Anthropic/Local', 'GFK Rules'],
|
||||
dataFlow: ['E-Mail Text', 'LLM Gateway', 'Analyse Result', 'PostgreSQL'],
|
||||
},
|
||||
}
|
||||
|
||||
// ==============================================
|
||||
// Main Component
|
||||
// ==============================================
|
||||
|
||||
export default function MailWizardPage() {
|
||||
const [currentStep, setCurrentStep] = useState(0)
|
||||
const [steps, setSteps] = useState<WizardStep[]>(STEPS)
|
||||
const [categoryResults, setCategoryResults] = useState<Record<string, TestCategoryResult>>({})
|
||||
const [fullResults, setFullResults] = useState<FullTestResults | null>(null)
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
const currentStepData = steps[currentStep]
|
||||
const isTestStep = currentStepData?.category !== undefined
|
||||
const isWelcome = currentStepData?.id === 'welcome'
|
||||
const isSummary = currentStepData?.id === 'summary'
|
||||
|
||||
const runCategoryTest = async (category: string) => {
|
||||
setIsLoading(true)
|
||||
setError(null)
|
||||
|
||||
try {
|
||||
const response = await fetch(`${BACKEND_URL}/api/admin/mail-tests/${category}`, {
|
||||
method: 'POST',
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}: ${response.statusText}`)
|
||||
}
|
||||
|
||||
const result: TestCategoryResult = await response.json()
|
||||
setCategoryResults((prev) => ({ ...prev, [category]: result }))
|
||||
|
||||
setSteps((prev) =>
|
||||
prev.map((step) =>
|
||||
step.category === category
|
||||
? { ...step, status: result.failed === 0 ? 'completed' : 'failed' }
|
||||
: step
|
||||
)
|
||||
)
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Unbekannter Fehler')
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const runAllTests = async () => {
|
||||
setIsLoading(true)
|
||||
setError(null)
|
||||
|
||||
try {
|
||||
const response = await fetch(`${BACKEND_URL}/api/admin/mail-tests/run-all`, {
|
||||
method: 'POST',
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}: ${response.statusText}`)
|
||||
}
|
||||
|
||||
const results: FullTestResults = await response.json()
|
||||
setFullResults(results)
|
||||
|
||||
setSteps((prev) =>
|
||||
prev.map((step) => {
|
||||
if (step.category) {
|
||||
const catResult = results.categories.find((c) => c.category === step.category)
|
||||
if (catResult) {
|
||||
return { ...step, status: catResult.failed === 0 ? 'completed' : 'failed' }
|
||||
}
|
||||
}
|
||||
return step
|
||||
})
|
||||
)
|
||||
|
||||
const newCategoryResults: Record<string, TestCategoryResult> = {}
|
||||
results.categories.forEach((cat) => {
|
||||
newCategoryResults[cat.category] = cat
|
||||
})
|
||||
setCategoryResults(newCategoryResults)
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Unbekannter Fehler')
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const goToNext = () => {
|
||||
if (currentStep < steps.length - 1) {
|
||||
setSteps((prev) =>
|
||||
prev.map((step, idx) =>
|
||||
idx === currentStep && step.status === 'pending'
|
||||
? { ...step, status: 'completed' }
|
||||
: step
|
||||
)
|
||||
)
|
||||
setCurrentStep((prev) => prev + 1)
|
||||
}
|
||||
}
|
||||
|
||||
const goToPrev = () => {
|
||||
if (currentStep > 0) {
|
||||
setCurrentStep((prev) => prev - 1)
|
||||
}
|
||||
}
|
||||
|
||||
const handleStepClick = (index: number) => {
|
||||
if (index <= currentStep || steps[index - 1]?.status !== 'pending') {
|
||||
setCurrentStep(index)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* Page Purpose */}
|
||||
<PagePurpose
|
||||
title="Mail Wizard"
|
||||
purpose="Interaktives Lernen und Testen der E-Mail Integration. Pruefen Sie SMTP, IMAP, Templates und KI-Analyse Schritt fuer Schritt."
|
||||
audience={['Admins', 'Entwickler']}
|
||||
architecture={{
|
||||
services: ['backend (Python)', 'mailpit (Dev)', 'LLM Gateway'],
|
||||
databases: ['PostgreSQL'],
|
||||
}}
|
||||
relatedPages={[
|
||||
{ name: 'Unified Inbox', href: '/communication/mail', description: 'E-Mail Verwaltung' },
|
||||
]}
|
||||
collapsible={true}
|
||||
defaultCollapsed={true}
|
||||
/>
|
||||
|
||||
{/* Back Link */}
|
||||
<div className="mb-6">
|
||||
<Link href="/communication/mail" className="text-blue-600 hover:text-blue-800 text-sm flex items-center gap-1">
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
|
||||
</svg>
|
||||
Zurueck zu E-Mail Management
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* Header */}
|
||||
<div className="bg-white rounded-lg shadow p-4 mb-6 flex items-center justify-between">
|
||||
<div className="flex items-center">
|
||||
<span className="text-3xl mr-3">📧</span>
|
||||
<div>
|
||||
<h2 className="text-lg font-bold text-gray-800">E-Mail Test Wizard</h2>
|
||||
<p className="text-sm text-gray-600">SMTP, IMAP, Templates & KI-Analyse</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Stepper */}
|
||||
<div className="bg-white rounded-lg shadow p-6 mb-6">
|
||||
<WizardStepper steps={steps} currentStep={currentStep} onStepClick={handleStepClick} />
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="bg-white rounded-lg shadow p-6">
|
||||
<div className="flex items-center mb-6">
|
||||
<span className="text-3xl mr-3">{currentStepData?.icon}</span>
|
||||
<div>
|
||||
<h2 className="text-xl font-bold text-gray-800">
|
||||
Schritt {currentStep + 1}: {currentStepData?.name}
|
||||
</h2>
|
||||
<p className="text-gray-500 text-sm">
|
||||
{currentStep + 1} von {steps.length}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<EducationCard content={EDUCATION_CONTENT[currentStepData?.id || '']} />
|
||||
|
||||
{isTestStep && currentStepData?.category && ARCHITECTURE_CONTEXTS[currentStepData.category] && (
|
||||
<ArchitectureContext
|
||||
context={ARCHITECTURE_CONTEXTS[currentStepData.category]}
|
||||
currentStep={currentStepData.name}
|
||||
/>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<div className="bg-red-50 border border-red-200 text-red-700 rounded-lg p-4 mb-6">
|
||||
<strong>Fehler:</strong> {error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isWelcome && (
|
||||
<div className="text-center py-8">
|
||||
<button
|
||||
onClick={goToNext}
|
||||
className="bg-blue-600 text-white px-8 py-3 rounded-lg font-medium hover:bg-blue-700 transition-colors"
|
||||
>
|
||||
Wizard starten
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isTestStep && currentStepData?.category && (
|
||||
<TestRunner
|
||||
category={currentStepData.category}
|
||||
categoryResult={categoryResults[currentStepData.category]}
|
||||
isLoading={isLoading}
|
||||
onRunTests={() => runCategoryTest(currentStepData.category!)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{isSummary && (
|
||||
<div>
|
||||
{!fullResults ? (
|
||||
<div className="text-center py-8">
|
||||
<p className="text-gray-600 mb-4">
|
||||
Fuehren Sie alle Tests aus um eine Zusammenfassung zu sehen.
|
||||
</p>
|
||||
<button
|
||||
onClick={runAllTests}
|
||||
disabled={isLoading}
|
||||
className={`px-6 py-3 rounded-lg font-medium transition-colors ${
|
||||
isLoading
|
||||
? 'bg-gray-400 cursor-not-allowed'
|
||||
: 'bg-blue-600 text-white hover:bg-blue-700'
|
||||
}`}
|
||||
>
|
||||
{isLoading ? 'Alle Tests laufen...' : 'Alle Tests ausfuehren'}
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<TestSummary results={fullResults} />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<WizardNavigation
|
||||
currentStep={currentStep}
|
||||
totalSteps={steps.length}
|
||||
onPrev={goToPrev}
|
||||
onNext={goToNext}
|
||||
showNext={!isSummary}
|
||||
isLoading={isLoading}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="text-center text-gray-500 text-sm mt-6">
|
||||
Diese Tests pruefen die E-Mail-Integration.
|
||||
Bei Fragen wenden Sie sich an das IT-Team.
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,594 @@
|
||||
'use client'
|
||||
|
||||
/**
|
||||
* Voice Service Admin Page (migrated from website/admin/voice)
|
||||
*
|
||||
* Displays:
|
||||
* - Voice-First Architecture Overview
|
||||
* - Developer Guide Content
|
||||
* - Live Voice Demo (embedded from studio-v2)
|
||||
* - Task State Machine Documentation
|
||||
* - DSGVO Compliance Information
|
||||
*/
|
||||
|
||||
import { useState } from 'react'
|
||||
import Link from 'next/link'
|
||||
import { PagePurpose } from '@/components/common/PagePurpose'
|
||||
|
||||
type TabType = 'overview' | 'demo' | 'tasks' | 'intents' | 'dsgvo' | 'api'
|
||||
|
||||
// Task State Machine data
|
||||
const TASK_STATES = [
|
||||
{ state: 'DRAFT', description: 'Task erstellt, noch nicht verarbeitet', color: 'bg-gray-100 text-gray-800', next: ['QUEUED', 'PAUSED'] },
|
||||
{ state: 'QUEUED', description: 'In Warteschlange fuer Verarbeitung', color: 'bg-blue-100 text-blue-800', next: ['RUNNING', 'PAUSED'] },
|
||||
{ state: 'RUNNING', description: 'Wird aktuell verarbeitet', color: 'bg-yellow-100 text-yellow-800', next: ['READY', 'PAUSED'] },
|
||||
{ state: 'READY', description: 'Fertig, wartet auf User-Bestaetigung', color: 'bg-green-100 text-green-800', next: ['APPROVED', 'REJECTED', 'PAUSED'] },
|
||||
{ state: 'APPROVED', description: 'Vom User bestaetigt', color: 'bg-emerald-100 text-emerald-800', next: ['COMPLETED'] },
|
||||
{ state: 'REJECTED', description: 'Vom User abgelehnt', color: 'bg-red-100 text-red-800', next: ['DRAFT'] },
|
||||
{ state: 'COMPLETED', description: 'Erfolgreich abgeschlossen', color: 'bg-teal-100 text-teal-800', next: [] },
|
||||
{ state: 'EXPIRED', description: 'TTL ueberschritten', color: 'bg-orange-100 text-orange-800', next: [] },
|
||||
{ state: 'PAUSED', description: 'Vom User pausiert', color: 'bg-purple-100 text-purple-800', next: ['DRAFT', 'QUEUED', 'RUNNING', 'READY'] },
|
||||
]
|
||||
|
||||
// Intent Types (22 types organized by group)
|
||||
const INTENT_GROUPS = [
|
||||
{
|
||||
group: 'Notizen',
|
||||
color: 'bg-blue-50 border-blue-200',
|
||||
intents: [
|
||||
{ type: 'student_observation', example: 'Notiz zu Max: heute wiederholt gestoert', description: 'Schuelerbeobachtungen' },
|
||||
{ type: 'reminder', example: 'Erinner mich morgen an Konferenz', description: 'Erinnerungen setzen' },
|
||||
{ type: 'homework_check', example: '7b Mathe Hausaufgabe kontrollieren', description: 'Hausaufgaben pruefen' },
|
||||
{ type: 'conference_topic', example: 'Thema Lehrerkonferenz: iPad-Regeln', description: 'Konferenzthemen' },
|
||||
{ type: 'correction_thought', example: 'Aufgabe 3: haeufiger Fehler erklaeren', description: 'Korrekturgedanken' },
|
||||
]
|
||||
},
|
||||
{
|
||||
group: 'Content-Generierung',
|
||||
color: 'bg-green-50 border-green-200',
|
||||
intents: [
|
||||
{ type: 'worksheet_generate', example: 'Erstelle 3 Lueckentexte zu Vokabeln', description: 'Arbeitsblaetter erstellen' },
|
||||
{ type: 'quiz_generate', example: '10-Minuten Vokabeltest mit Loesungen', description: 'Quiz/Tests erstellen' },
|
||||
{ type: 'quick_activity', example: '10 Minuten Einstieg, 5 Aufgaben', description: 'Schnelle Aktivitaeten' },
|
||||
{ type: 'differentiation', example: 'Zwei Schwierigkeitsstufen: Basis und Plus', description: 'Differenzierung' },
|
||||
]
|
||||
},
|
||||
{
|
||||
group: 'Kommunikation',
|
||||
color: 'bg-yellow-50 border-yellow-200',
|
||||
intents: [
|
||||
{ type: 'parent_letter', example: 'Neutraler Elternbrief wegen Stoerungen', description: 'Elternbriefe erstellen' },
|
||||
{ type: 'class_message', example: 'Nachricht an 8a: Hausaufgaben bis Mittwoch', description: 'Klassennachrichten' },
|
||||
]
|
||||
},
|
||||
{
|
||||
group: 'Canvas-Editor',
|
||||
color: 'bg-purple-50 border-purple-200',
|
||||
intents: [
|
||||
{ type: 'canvas_edit', example: 'Ueberschriften groesser, Zeilenabstand kleiner', description: 'Formatierung aendern' },
|
||||
{ type: 'canvas_layout', example: 'Alles auf eine Seite, Drucklayout A4', description: 'Layout anpassen' },
|
||||
{ type: 'canvas_element', example: 'Kasten fuer Merke hinzufuegen', description: 'Elemente hinzufuegen' },
|
||||
{ type: 'canvas_image', example: 'Bild 2 nach links, Pfeil auf Aufgabe 3', description: 'Bilder positionieren' },
|
||||
]
|
||||
},
|
||||
{
|
||||
group: 'RAG & Korrektur',
|
||||
color: 'bg-pink-50 border-pink-200',
|
||||
intents: [
|
||||
{ type: 'operator_checklist', example: 'Operatoren-Checkliste fuer diese Aufgabe', description: 'Operatoren abrufen' },
|
||||
{ type: 'eh_passage', example: 'Erwartungshorizont-Passage zu diesem Thema', description: 'EH-Passagen suchen' },
|
||||
{ type: 'feedback_suggestion', example: 'Kurze Feedbackformulierung vorschlagen', description: 'Feedback vorschlagen' },
|
||||
]
|
||||
},
|
||||
{
|
||||
group: 'Follow-up (TaskOrchestrator)',
|
||||
color: 'bg-teal-50 border-teal-200',
|
||||
intents: [
|
||||
{ type: 'task_summary', example: 'Fasse alle offenen Tasks zusammen', description: 'Task-Uebersicht' },
|
||||
{ type: 'convert_note', example: 'Mach aus der Notiz von gestern einen Elternbrief', description: 'Notizen konvertieren' },
|
||||
{ type: 'schedule_reminder', example: 'Erinner mich morgen an das Gespraech mit Max', description: 'Erinnerungen planen' },
|
||||
]
|
||||
},
|
||||
]
|
||||
|
||||
// DSGVO Data Categories
|
||||
const DSGVO_CATEGORIES = [
|
||||
{ category: 'Audio', processing: 'NUR transient im RAM, NIEMALS persistiert', storage: 'Keine', ttl: '-', icon: '🎤', risk: 'low' },
|
||||
{ category: 'PII (Schuelernamen)', processing: 'NUR auf Lehrergeraet', storage: 'Client-side', ttl: '-', icon: '👤', risk: 'high' },
|
||||
{ category: 'Pseudonyme', processing: 'Server erlaubt (student_ref, class_ref)', storage: 'Valkey Cache', ttl: '24h', icon: '🔢', risk: 'low' },
|
||||
{ category: 'Transkripte', processing: 'NUR verschluesselt (AES-256-GCM)', storage: 'PostgreSQL', ttl: '7 Tage', icon: '📝', risk: 'medium' },
|
||||
{ category: 'Task States', processing: 'TaskOrchestrator', storage: 'Valkey', ttl: '30 Tage', icon: '📋', risk: 'low' },
|
||||
{ category: 'Audit Logs', processing: 'Nur truncated IDs, keine PII', storage: 'PostgreSQL', ttl: '90 Tage', icon: '📊', risk: 'low' },
|
||||
]
|
||||
|
||||
// API Endpoints
|
||||
const API_ENDPOINTS = [
|
||||
{ method: 'POST', path: '/api/v1/sessions', description: 'Voice Session erstellen' },
|
||||
{ method: 'GET', path: '/api/v1/sessions/{id}', description: 'Session Status abrufen' },
|
||||
{ method: 'DELETE', path: '/api/v1/sessions/{id}', description: 'Session beenden' },
|
||||
{ method: 'GET', path: '/api/v1/sessions/{id}/tasks', description: 'Pending Tasks abrufen' },
|
||||
{ method: 'POST', path: '/api/v1/tasks', description: 'Task erstellen' },
|
||||
{ method: 'GET', path: '/api/v1/tasks/{id}', description: 'Task Status abrufen' },
|
||||
{ method: 'PUT', path: '/api/v1/tasks/{id}/transition', description: 'Task State aendern' },
|
||||
{ method: 'DELETE', path: '/api/v1/tasks/{id}', description: 'Task loeschen' },
|
||||
{ method: 'WS', path: '/ws/voice', description: 'Voice Streaming (WebSocket)' },
|
||||
{ method: 'GET', path: '/health', description: 'Health Check' },
|
||||
]
|
||||
|
||||
export default function VoiceMatrixPage() {
|
||||
const [activeTab, setActiveTab] = useState<TabType>('overview')
|
||||
const [demoLoaded, setDemoLoaded] = useState(false)
|
||||
|
||||
const tabs = [
|
||||
{ id: 'overview', name: 'Architektur', icon: '🏗️' },
|
||||
{ id: 'demo', name: 'Live Demo', icon: '🎤' },
|
||||
{ id: 'tasks', name: 'Task States', icon: '📋' },
|
||||
{ id: 'intents', name: 'Intents (22)', icon: '🎯' },
|
||||
{ id: 'dsgvo', name: 'DSGVO', icon: '🔒' },
|
||||
{ id: 'api', name: 'API', icon: '🔌' },
|
||||
]
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* Page Purpose */}
|
||||
<PagePurpose
|
||||
title="Voice Service"
|
||||
purpose="Voice-First Interface mit PersonaPlex-7B & TaskOrchestrator. Konfigurieren und testen Sie den Voice-Service fuer Lehrer-Interaktionen per Sprache."
|
||||
audience={['Entwickler', 'Admins']}
|
||||
architecture={{
|
||||
services: ['voice-service (Python, Port 8091)', 'studio-v2 (Next.js)', 'valkey (Cache)'],
|
||||
databases: ['PostgreSQL', 'Valkey Cache'],
|
||||
}}
|
||||
relatedPages={[
|
||||
{ name: 'Matrix & Jitsi', href: '/communication/matrix', description: 'Kommunikation Monitoring' },
|
||||
{ name: 'LLM Vergleich', href: '/ai/llm-compare', description: 'KI-Provider vergleichen' },
|
||||
{ name: 'GPU Infrastruktur', href: '/infrastructure/gpu', description: 'GPU fuer Voice-Service' },
|
||||
]}
|
||||
collapsible={true}
|
||||
defaultCollapsed={false}
|
||||
/>
|
||||
|
||||
{/* Quick Links */}
|
||||
<div className="mb-6 flex flex-wrap gap-3">
|
||||
<a
|
||||
href="https://macmini:3001/voice-test"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center gap-2 px-4 py-2 bg-teal-600 text-white rounded-lg hover:bg-teal-700 transition-colors"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 11a7 7 0 01-7 7m0 0a7 7 0 01-7-7m7 7v4m0 0H8m4 0h4m-4-8a3 3 0 01-3-3V5a3 3 0 116 0v6a3 3 0 01-3 3z" />
|
||||
</svg>
|
||||
Voice Test (Studio)
|
||||
</a>
|
||||
<a
|
||||
href="https://macmini:8091/health"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center gap-2 px-4 py-2 bg-green-100 text-green-700 rounded-lg hover:bg-green-200 transition-colors"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
Health Check
|
||||
</a>
|
||||
<Link
|
||||
href="/development/docs"
|
||||
className="flex items-center gap-2 px-4 py-2 bg-slate-100 text-slate-700 rounded-lg hover:bg-slate-200 transition-colors"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
|
||||
</svg>
|
||||
Developer Docs
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* Stats Overview */}
|
||||
<div className="grid grid-cols-2 md:grid-cols-6 gap-4 mb-6">
|
||||
<div className="bg-white rounded-lg shadow p-4">
|
||||
<div className="text-3xl font-bold text-teal-600">8091</div>
|
||||
<div className="text-sm text-slate-500">Port</div>
|
||||
</div>
|
||||
<div className="bg-white rounded-lg shadow p-4">
|
||||
<div className="text-3xl font-bold text-blue-600">22</div>
|
||||
<div className="text-sm text-slate-500">Task Types</div>
|
||||
</div>
|
||||
<div className="bg-white rounded-lg shadow p-4">
|
||||
<div className="text-3xl font-bold text-purple-600">9</div>
|
||||
<div className="text-sm text-slate-500">Task States</div>
|
||||
</div>
|
||||
<div className="bg-white rounded-lg shadow p-4">
|
||||
<div className="text-3xl font-bold text-green-600">24kHz</div>
|
||||
<div className="text-sm text-slate-500">Audio Rate</div>
|
||||
</div>
|
||||
<div className="bg-white rounded-lg shadow p-4">
|
||||
<div className="text-3xl font-bold text-orange-600">80ms</div>
|
||||
<div className="text-sm text-slate-500">Frame Size</div>
|
||||
</div>
|
||||
<div className="bg-white rounded-lg shadow p-4">
|
||||
<div className="text-3xl font-bold text-red-600">0</div>
|
||||
<div className="text-sm text-slate-500">Audio Persist</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tabs */}
|
||||
<div className="bg-white rounded-lg shadow mb-6">
|
||||
<div className="border-b border-slate-200 px-4">
|
||||
<div className="flex gap-1 overflow-x-auto">
|
||||
{tabs.map((tab) => (
|
||||
<button
|
||||
key={tab.id}
|
||||
onClick={() => setActiveTab(tab.id as TabType)}
|
||||
className={`px-4 py-3 text-sm font-medium whitespace-nowrap transition-colors border-b-2 ${
|
||||
activeTab === tab.id
|
||||
? 'border-teal-600 text-teal-600'
|
||||
: 'border-transparent text-slate-500 hover:text-slate-700'
|
||||
}`}
|
||||
>
|
||||
<span className="mr-2">{tab.icon}</span>
|
||||
{tab.name}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-6">
|
||||
{/* Overview Tab */}
|
||||
{activeTab === 'overview' && (
|
||||
<div className="space-y-6">
|
||||
<h3 className="text-lg font-semibold text-slate-900">Voice-First Architektur</h3>
|
||||
|
||||
{/* Architecture Diagram */}
|
||||
<div className="bg-slate-50 rounded-lg p-6 font-mono text-sm overflow-x-auto">
|
||||
<pre className="text-slate-700">{`
|
||||
┌──────────────────────────────────────────────────────────────────┐
|
||||
│ LEHRERGERAET (PWA / App) │
|
||||
│ ┌────────────────────────────────────────────────────────────┐ │
|
||||
│ │ VoiceCapture.tsx │ voice-encryption.ts │ voice-api.ts │ │
|
||||
│ │ Mikrofon │ AES-256-GCM │ WebSocket Client │ │
|
||||
│ └────────────────────────────────────────────────────────────┘ │
|
||||
└───────────────────────────┬──────────────────────────────────────┘
|
||||
│ WebSocket (wss://)
|
||||
▼
|
||||
┌──────────────────────────────────────────────────────────────────┐
|
||||
│ VOICE SERVICE (Port 8091) │
|
||||
│ ┌────────────────────────────────────────────────────────────┐ │
|
||||
│ │ main.py │ streaming.py │ sessions.py │ tasks.py │ │
|
||||
│ └────────────────────────────────────────────────────────────┘ │
|
||||
│ ┌────────────────────────────────────────────────────────────┐ │
|
||||
│ │ task_orchestrator.py │ intent_router.py │ encryption │ │
|
||||
│ └────────────────────────────────────────────────────────────┘ │
|
||||
└───────────────────────────┬──────────────────────────────────────┘
|
||||
│
|
||||
┌──────────────────┼──────────────────┐
|
||||
▼ ▼ ▼
|
||||
┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐
|
||||
│ PersonaPlex-7B │ │ Ollama Fallback │ │ Valkey Cache │
|
||||
│ (A100 GPU) │ │ (Mac Mini) │ │ (Sessions) │
|
||||
└─────────────────┘ └─────────────────┘ └─────────────────┘
|
||||
`}</pre>
|
||||
</div>
|
||||
|
||||
{/* Technology Stack */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<div className="bg-blue-50 border border-blue-200 rounded-lg p-4">
|
||||
<h4 className="font-semibold text-blue-800 mb-2">Voice Model (Produktion)</h4>
|
||||
<p className="text-sm text-blue-700">PersonaPlex-7B (NVIDIA)</p>
|
||||
<p className="text-xs text-blue-600 mt-1">Full-Duplex Speech-to-Speech</p>
|
||||
<p className="text-xs text-blue-500">Lizenz: MIT + NVIDIA Open Model</p>
|
||||
</div>
|
||||
<div className="bg-green-50 border border-green-200 rounded-lg p-4">
|
||||
<h4 className="font-semibold text-green-800 mb-2">Agent Orchestration</h4>
|
||||
<p className="text-sm text-green-700">TaskOrchestrator</p>
|
||||
<p className="text-xs text-green-600 mt-1">Task State Machine</p>
|
||||
<p className="text-xs text-green-500">Lizenz: Proprietary</p>
|
||||
</div>
|
||||
<div className="bg-purple-50 border border-purple-200 rounded-lg p-4">
|
||||
<h4 className="font-semibold text-purple-800 mb-2">Audio Codec</h4>
|
||||
<p className="text-sm text-purple-700">Mimi (24kHz, 80ms)</p>
|
||||
<p className="text-xs text-purple-600 mt-1">Low-Latency Streaming</p>
|
||||
<p className="text-xs text-purple-500">Lizenz: MIT</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Key Files */}
|
||||
<div>
|
||||
<h4 className="font-semibold text-slate-800 mb-3">Wichtige Dateien</h4>
|
||||
<div className="bg-white border border-slate-200 rounded-lg overflow-hidden">
|
||||
<table className="min-w-full divide-y divide-slate-200">
|
||||
<thead className="bg-slate-50">
|
||||
<tr>
|
||||
<th className="px-4 py-2 text-left text-xs font-medium text-slate-500 uppercase">Datei</th>
|
||||
<th className="px-4 py-2 text-left text-xs font-medium text-slate-500 uppercase">Beschreibung</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-slate-200">
|
||||
<tr><td className="px-4 py-2 font-mono text-sm">voice-service/main.py</td><td className="px-4 py-2 text-sm text-slate-600">FastAPI Entry, WebSocket Handler</td></tr>
|
||||
<tr><td className="px-4 py-2 font-mono text-sm">voice-service/services/task_orchestrator.py</td><td className="px-4 py-2 text-sm text-slate-600">Task State Machine</td></tr>
|
||||
<tr><td className="px-4 py-2 font-mono text-sm">voice-service/services/intent_router.py</td><td className="px-4 py-2 text-sm text-slate-600">Intent Detection (22 Types)</td></tr>
|
||||
<tr><td className="px-4 py-2 font-mono text-sm">voice-service/services/encryption_service.py</td><td className="px-4 py-2 text-sm text-slate-600">Namespace Key Management</td></tr>
|
||||
<tr><td className="px-4 py-2 font-mono text-sm">studio-v2/components/voice/VoiceCapture.tsx</td><td className="px-4 py-2 text-sm text-slate-600">Frontend Mikrofon + Crypto</td></tr>
|
||||
<tr><td className="px-4 py-2 font-mono text-sm">studio-v2/lib/voice/voice-encryption.ts</td><td className="px-4 py-2 text-sm text-slate-600">AES-256-GCM Client-side</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Demo Tab */}
|
||||
{activeTab === 'demo' && (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-lg font-semibold text-slate-900">Live Voice Demo</h3>
|
||||
<a
|
||||
href="https://macmini:3001/voice-test"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-sm text-teal-600 hover:text-teal-700 flex items-center gap-1"
|
||||
>
|
||||
In neuem Tab oeffnen
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
|
||||
</svg>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div className="bg-slate-100 rounded-lg p-4 text-sm text-slate-600 mb-4">
|
||||
<p><strong>Hinweis:</strong> Die Demo erfordert, dass der Voice Service (Port 8091) und das Studio-v2 Frontend (Port 3001) laufen.</p>
|
||||
<code className="block mt-2 bg-slate-200 p-2 rounded">docker compose up -d voice-service && cd studio-v2 && npm run dev</code>
|
||||
</div>
|
||||
|
||||
{/* Embedded Demo */}
|
||||
<div className="relative bg-slate-900 rounded-lg overflow-hidden" style={{ height: '600px' }}>
|
||||
{!demoLoaded && (
|
||||
<div className="absolute inset-0 flex items-center justify-center">
|
||||
<button
|
||||
onClick={() => setDemoLoaded(true)}
|
||||
className="px-6 py-3 bg-teal-600 text-white rounded-lg hover:bg-teal-700 transition-colors flex items-center gap-2"
|
||||
>
|
||||
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M14.752 11.168l-3.197-2.132A1 1 0 0010 9.87v4.263a1 1 0 001.555.832l3.197-2.132a1 1 0 000-1.664z" />
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
Voice Demo laden
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{demoLoaded && (
|
||||
<iframe
|
||||
src="https://macmini:3001/voice-test?embed=true"
|
||||
className="w-full h-full border-0"
|
||||
title="Voice Demo"
|
||||
allow="microphone"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Task States Tab */}
|
||||
{activeTab === 'tasks' && (
|
||||
<div className="space-y-6">
|
||||
<h3 className="text-lg font-semibold text-slate-900">Task State Machine (TaskOrchestrator)</h3>
|
||||
|
||||
{/* State Diagram */}
|
||||
<div className="bg-slate-50 rounded-lg p-6 font-mono text-sm overflow-x-auto">
|
||||
<pre className="text-slate-700">{`
|
||||
DRAFT → QUEUED → RUNNING → READY
|
||||
│
|
||||
┌───────────┴───────────┐
|
||||
│ │
|
||||
APPROVED REJECTED
|
||||
│ │
|
||||
COMPLETED DRAFT (revision)
|
||||
|
||||
Any State → EXPIRED (TTL)
|
||||
Any State → PAUSED (User Interrupt)
|
||||
`}</pre>
|
||||
</div>
|
||||
|
||||
{/* States Table */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{TASK_STATES.map((state) => (
|
||||
<div key={state.state} className={`${state.color} rounded-lg p-4`}>
|
||||
<div className="font-semibold text-lg">{state.state}</div>
|
||||
<p className="text-sm mt-1">{state.description}</p>
|
||||
{state.next.length > 0 && (
|
||||
<div className="mt-2 text-xs">
|
||||
<span className="opacity-75">Naechste:</span>{' '}
|
||||
{state.next.join(', ')}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Intents Tab */}
|
||||
{activeTab === 'intents' && (
|
||||
<div className="space-y-6">
|
||||
<h3 className="text-lg font-semibold text-slate-900">Intent Types (22 unterstuetzte Typen)</h3>
|
||||
|
||||
{INTENT_GROUPS.map((group) => (
|
||||
<div key={group.group} className={`${group.color} border rounded-lg p-4`}>
|
||||
<h4 className="font-semibold text-slate-800 mb-3">{group.group}</h4>
|
||||
<div className="space-y-2">
|
||||
{group.intents.map((intent) => (
|
||||
<div key={intent.type} className="bg-white rounded-lg p-3 shadow-sm">
|
||||
<div className="flex items-start justify-between">
|
||||
<div>
|
||||
<code className="text-sm font-mono text-teal-700 bg-teal-50 px-2 py-0.5 rounded">
|
||||
{intent.type}
|
||||
</code>
|
||||
<p className="text-sm text-slate-600 mt-1">{intent.description}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-2 text-xs text-slate-500 italic">
|
||||
Beispiel: "{intent.example}"
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* DSGVO Tab */}
|
||||
{activeTab === 'dsgvo' && (
|
||||
<div className="space-y-6">
|
||||
<h3 className="text-lg font-semibold text-slate-900">DSGVO-Compliance</h3>
|
||||
|
||||
{/* Key Principles */}
|
||||
<div className="bg-green-50 border border-green-200 rounded-lg p-4">
|
||||
<h4 className="font-semibold text-green-800 mb-2">Kernprinzipien</h4>
|
||||
<ul className="list-disc list-inside text-sm text-green-700 space-y-1">
|
||||
<li><strong>Audio NIEMALS persistiert</strong> - Nur transient im RAM</li>
|
||||
<li><strong>Namespace-Verschluesselung</strong> - Key nur auf Lehrergeraet</li>
|
||||
<li><strong>Keine Klartext-PII serverseitig</strong> - Nur verschluesselt oder pseudonymisiert</li>
|
||||
<li><strong>TTL-basierte Auto-Loeschung</strong> - 7/30/90 Tage je nach Kategorie</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
{/* Data Categories Table */}
|
||||
<div className="bg-white border border-slate-200 rounded-lg overflow-hidden">
|
||||
<table className="min-w-full divide-y divide-slate-200">
|
||||
<thead className="bg-slate-50">
|
||||
<tr>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-slate-500 uppercase">Kategorie</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-slate-500 uppercase">Verarbeitung</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-slate-500 uppercase">Speicherort</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-slate-500 uppercase">TTL</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-slate-500 uppercase">Risiko</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-slate-200">
|
||||
{DSGVO_CATEGORIES.map((cat) => (
|
||||
<tr key={cat.category}>
|
||||
<td className="px-4 py-3">
|
||||
<span className="mr-2">{cat.icon}</span>
|
||||
<span className="font-medium">{cat.category}</span>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-sm text-slate-600">{cat.processing}</td>
|
||||
<td className="px-4 py-3 text-sm text-slate-600">{cat.storage}</td>
|
||||
<td className="px-4 py-3 text-sm text-slate-600">{cat.ttl}</td>
|
||||
<td className="px-4 py-3">
|
||||
<span className={`px-2 py-1 rounded text-xs font-medium ${
|
||||
cat.risk === 'low' ? 'bg-green-100 text-green-700' :
|
||||
cat.risk === 'medium' ? 'bg-yellow-100 text-yellow-700' :
|
||||
'bg-red-100 text-red-700'
|
||||
}`}>
|
||||
{cat.risk.toUpperCase()}
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* Audit Log Info */}
|
||||
<div className="bg-slate-50 border border-slate-200 rounded-lg p-4">
|
||||
<h4 className="font-semibold text-slate-800 mb-2">Audit Logs (ohne PII)</h4>
|
||||
<div className="grid grid-cols-2 gap-4 text-sm">
|
||||
<div>
|
||||
<span className="text-green-600 font-medium">Erlaubt:</span>
|
||||
<ul className="list-disc list-inside text-slate-600 mt-1">
|
||||
<li>ref_id (truncated)</li>
|
||||
<li>content_type</li>
|
||||
<li>size_bytes</li>
|
||||
<li>ttl_hours</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-red-600 font-medium">Verboten:</span>
|
||||
<ul className="list-disc list-inside text-slate-600 mt-1">
|
||||
<li>user_name</li>
|
||||
<li>content / transcript</li>
|
||||
<li>email</li>
|
||||
<li>student_name</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* API Tab */}
|
||||
{activeTab === 'api' && (
|
||||
<div className="space-y-6">
|
||||
<h3 className="text-lg font-semibold text-slate-900">Voice Service API (Port 8091)</h3>
|
||||
|
||||
{/* REST Endpoints */}
|
||||
<div className="bg-white border border-slate-200 rounded-lg overflow-hidden">
|
||||
<table className="min-w-full divide-y divide-slate-200">
|
||||
<thead className="bg-slate-50">
|
||||
<tr>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-slate-500 uppercase">Methode</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-slate-500 uppercase">Endpoint</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-slate-500 uppercase">Beschreibung</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-slate-200">
|
||||
{API_ENDPOINTS.map((ep, idx) => (
|
||||
<tr key={idx}>
|
||||
<td className="px-4 py-3">
|
||||
<span className={`px-2 py-1 rounded text-xs font-medium ${
|
||||
ep.method === 'GET' ? 'bg-green-100 text-green-700' :
|
||||
ep.method === 'POST' ? 'bg-blue-100 text-blue-700' :
|
||||
ep.method === 'PUT' ? 'bg-yellow-100 text-yellow-700' :
|
||||
ep.method === 'DELETE' ? 'bg-red-100 text-red-700' :
|
||||
'bg-purple-100 text-purple-700'
|
||||
}`}>
|
||||
{ep.method}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3 font-mono text-sm">{ep.path}</td>
|
||||
<td className="px-4 py-3 text-sm text-slate-600">{ep.description}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* WebSocket Protocol */}
|
||||
<div className="bg-slate-50 rounded-lg p-4">
|
||||
<h4 className="font-semibold text-slate-800 mb-3">WebSocket Protocol</h4>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 text-sm">
|
||||
<div className="bg-white rounded-lg p-3 border border-slate-200">
|
||||
<div className="font-medium text-slate-700 mb-2">Client → Server</div>
|
||||
<ul className="list-disc list-inside text-slate-600 space-y-1">
|
||||
<li><code className="bg-slate-100 px-1 rounded">Binary</code>: Int16 PCM Audio (24kHz, 80ms)</li>
|
||||
<li><code className="bg-slate-100 px-1 rounded">JSON</code>: {`{type: "config|end_turn|interrupt"}`}</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div className="bg-white rounded-lg p-3 border border-slate-200">
|
||||
<div className="font-medium text-slate-700 mb-2">Server → Client</div>
|
||||
<ul className="list-disc list-inside text-slate-600 space-y-1">
|
||||
<li><code className="bg-slate-100 px-1 rounded">Binary</code>: Audio Response (base64)</li>
|
||||
<li><code className="bg-slate-100 px-1 rounded">JSON</code>: {`{type: "transcript|intent|status|error"}`}</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Example curl commands */}
|
||||
<div className="bg-slate-900 rounded-lg p-4 text-sm">
|
||||
<h4 className="font-semibold text-slate-300 mb-3">Beispiel: Session erstellen</h4>
|
||||
<pre className="text-green-400 overflow-x-auto">{`curl -X POST https://macmini:8091/api/v1/sessions \\
|
||||
-H "Content-Type: application/json" \\
|
||||
-d '{
|
||||
"namespace_id": "ns-12345678abcdef12345678abcdef12",
|
||||
"key_hash": "sha256:dGVzdGtleWhhc2h0ZXN0a2V5aGFzaHRlc3Q=",
|
||||
"device_type": "pwa"
|
||||
}'`}</pre>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
'use client'
|
||||
|
||||
import { getCategoryById } from '@/lib/navigation'
|
||||
import { ModuleCard } from '@/components/common/ModuleCard'
|
||||
import { PagePurpose } from '@/components/common/PagePurpose'
|
||||
|
||||
export default function CommunicationPage() {
|
||||
const category = getCategoryById('communication')
|
||||
|
||||
if (!category) {
|
||||
return <div>Kategorie nicht gefunden</div>
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* Page Purpose */}
|
||||
<PagePurpose
|
||||
title={category.name}
|
||||
purpose="Diese Kategorie umfasst alle Kommunikations- und Benachrichtigungsmodule. Hier ueberwachen Sie Matrix-Raeume, verwalten E-Mail-Konten und konfigurieren Alert-Feeds."
|
||||
audience={['Admins', 'Support', 'Marketing']}
|
||||
architecture={{
|
||||
services: ['synapse (Matrix)', 'mailpit (Dev SMTP)', 'backend (Python)'],
|
||||
databases: ['PostgreSQL', 'synapse-db'],
|
||||
}}
|
||||
collapsible={true}
|
||||
defaultCollapsed={false}
|
||||
/>
|
||||
|
||||
{/* Modules Grid */}
|
||||
<h2 className="text-lg font-semibold text-slate-900 mb-4">Module</h2>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{category.modules.map((module) => (
|
||||
<ModuleCard key={module.id} module={module} category={category} />
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Info Section */}
|
||||
<div className="mt-8 bg-green-50 border border-green-200 rounded-xl p-6">
|
||||
<h3 className="font-semibold text-green-800 flex items-center gap-2">
|
||||
<span>📬</span>
|
||||
Ende-zu-Ende-Verschluesselung
|
||||
</h3>
|
||||
<p className="text-sm text-green-700 mt-2">
|
||||
Matrix-Kommunikation ist standardmaessig Ende-zu-Ende verschluesselt.
|
||||
Jitsi-Konferenzen werden nicht auf dem Server gespeichert (optional: Aufnahme mit Jibri).
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,635 @@
|
||||
'use client'
|
||||
|
||||
/**
|
||||
* Video & Chat Admin Page
|
||||
*
|
||||
* Matrix & Jitsi Monitoring Dashboard
|
||||
* Provides system statistics, active calls, user metrics, and service health
|
||||
* Migrated from website/app/admin/communication
|
||||
*/
|
||||
|
||||
import { useEffect, useState, useCallback } from 'react'
|
||||
import Link from 'next/link'
|
||||
import { PagePurpose } from '@/components/common/PagePurpose'
|
||||
import { getModuleByHref } from '@/lib/navigation'
|
||||
|
||||
interface MatrixStats {
|
||||
total_users: number
|
||||
active_users: number
|
||||
total_rooms: number
|
||||
active_rooms: number
|
||||
messages_today: number
|
||||
messages_this_week: number
|
||||
status: 'online' | 'offline' | 'degraded'
|
||||
}
|
||||
|
||||
interface JitsiStats {
|
||||
active_meetings: number
|
||||
total_participants: number
|
||||
meetings_today: number
|
||||
average_duration_minutes: number
|
||||
peak_concurrent_users: number
|
||||
total_minutes_today: number
|
||||
status: 'online' | 'offline' | 'degraded'
|
||||
}
|
||||
|
||||
interface TrafficStats {
|
||||
matrix: {
|
||||
bandwidth_in_mb: number
|
||||
bandwidth_out_mb: number
|
||||
messages_per_minute: number
|
||||
media_uploads_today: number
|
||||
media_size_mb: number
|
||||
}
|
||||
jitsi: {
|
||||
bandwidth_in_mb: number
|
||||
bandwidth_out_mb: number
|
||||
video_streams_active: number
|
||||
audio_streams_active: number
|
||||
estimated_hourly_gb: number
|
||||
}
|
||||
total: {
|
||||
bandwidth_in_mb: number
|
||||
bandwidth_out_mb: number
|
||||
estimated_monthly_gb: number
|
||||
}
|
||||
}
|
||||
|
||||
interface CommunicationStats {
|
||||
matrix: MatrixStats
|
||||
jitsi: JitsiStats
|
||||
traffic?: TrafficStats
|
||||
last_updated: string
|
||||
}
|
||||
|
||||
interface ActiveMeeting {
|
||||
room_name: string
|
||||
display_name: string
|
||||
participants: number
|
||||
started_at: string
|
||||
duration_minutes: number
|
||||
}
|
||||
|
||||
interface RecentRoom {
|
||||
room_id: string
|
||||
name: string
|
||||
member_count: number
|
||||
last_activity: string
|
||||
room_type: 'class' | 'parent' | 'staff' | 'general'
|
||||
}
|
||||
|
||||
export default function VideoChatPage() {
|
||||
const [stats, setStats] = useState<CommunicationStats | null>(null)
|
||||
const [activeMeetings, setActiveMeetings] = useState<ActiveMeeting[]>([])
|
||||
const [recentRooms, setRecentRooms] = useState<RecentRoom[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
const moduleInfo = getModuleByHref('/communication/video-chat')
|
||||
|
||||
// Use local API proxy
|
||||
const fetchStats = useCallback(async () => {
|
||||
try {
|
||||
const response = await fetch('/api/admin/communication/stats')
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}`)
|
||||
}
|
||||
const data = await response.json()
|
||||
setStats(data)
|
||||
setActiveMeetings(data.active_meetings || [])
|
||||
setRecentRooms(data.recent_rooms || [])
|
||||
setError(null)
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Verbindungsfehler')
|
||||
// Set mock data for display purposes when API unavailable
|
||||
setStats({
|
||||
matrix: {
|
||||
total_users: 0,
|
||||
active_users: 0,
|
||||
total_rooms: 0,
|
||||
active_rooms: 0,
|
||||
messages_today: 0,
|
||||
messages_this_week: 0,
|
||||
status: 'offline'
|
||||
},
|
||||
jitsi: {
|
||||
active_meetings: 0,
|
||||
total_participants: 0,
|
||||
meetings_today: 0,
|
||||
average_duration_minutes: 0,
|
||||
peak_concurrent_users: 0,
|
||||
total_minutes_today: 0,
|
||||
status: 'offline'
|
||||
},
|
||||
last_updated: new Date().toISOString()
|
||||
})
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
fetchStats()
|
||||
}, [fetchStats])
|
||||
|
||||
// Auto-refresh every 15 seconds
|
||||
useEffect(() => {
|
||||
const interval = setInterval(fetchStats, 15000)
|
||||
return () => clearInterval(interval)
|
||||
}, [fetchStats])
|
||||
|
||||
const getStatusBadge = (status: string) => {
|
||||
const baseClasses = 'px-3 py-1 rounded-full text-xs font-semibold uppercase'
|
||||
switch (status) {
|
||||
case 'online':
|
||||
return `${baseClasses} bg-green-100 text-green-800`
|
||||
case 'degraded':
|
||||
return `${baseClasses} bg-yellow-100 text-yellow-800`
|
||||
case 'offline':
|
||||
return `${baseClasses} bg-red-100 text-red-800`
|
||||
default:
|
||||
return `${baseClasses} bg-slate-100 text-slate-600`
|
||||
}
|
||||
}
|
||||
|
||||
const getRoomTypeBadge = (type: string) => {
|
||||
const baseClasses = 'px-2 py-0.5 rounded text-xs font-medium'
|
||||
switch (type) {
|
||||
case 'class':
|
||||
return `${baseClasses} bg-blue-100 text-blue-700`
|
||||
case 'parent':
|
||||
return `${baseClasses} bg-purple-100 text-purple-700`
|
||||
case 'staff':
|
||||
return `${baseClasses} bg-orange-100 text-orange-700`
|
||||
default:
|
||||
return `${baseClasses} bg-slate-100 text-slate-600`
|
||||
}
|
||||
}
|
||||
|
||||
const formatDuration = (minutes: number) => {
|
||||
if (minutes < 60) return `${Math.round(minutes)} Min.`
|
||||
const hours = Math.floor(minutes / 60)
|
||||
const mins = Math.round(minutes % 60)
|
||||
return `${hours}h ${mins}m`
|
||||
}
|
||||
|
||||
const formatTimeAgo = (dateStr: string) => {
|
||||
const date = new Date(dateStr)
|
||||
const now = new Date()
|
||||
const diffMs = now.getTime() - date.getTime()
|
||||
const diffMins = Math.floor(diffMs / 60000)
|
||||
|
||||
if (diffMins < 1) return 'gerade eben'
|
||||
if (diffMins < 60) return `vor ${diffMins} Min.`
|
||||
if (diffMins < 1440) return `vor ${Math.floor(diffMins / 60)} Std.`
|
||||
return `vor ${Math.floor(diffMins / 1440)} Tagen`
|
||||
}
|
||||
|
||||
// Traffic estimation helpers for SysEleven planning
|
||||
const calculateEstimatedTraffic = (direction: 'in' | 'out'): number => {
|
||||
const messages = stats?.matrix?.messages_today || 0
|
||||
const callMinutes = stats?.jitsi?.total_minutes_today || 0
|
||||
const participants = stats?.jitsi?.total_participants || 0
|
||||
|
||||
const messageTrafficMB = messages * 0.002
|
||||
const videoTrafficMB = callMinutes * participants * 0.011
|
||||
|
||||
if (direction === 'in') {
|
||||
return messageTrafficMB * 0.3 + videoTrafficMB * 0.4
|
||||
}
|
||||
return messageTrafficMB * 0.7 + videoTrafficMB * 0.6
|
||||
}
|
||||
|
||||
const calculateHourlyEstimate = (): number => {
|
||||
const activeParticipants = stats?.jitsi?.total_participants || 0
|
||||
return activeParticipants * 0.675
|
||||
}
|
||||
|
||||
const calculateMonthlyEstimate = (): number => {
|
||||
const dailyCallMinutes = stats?.jitsi?.total_minutes_today || 0
|
||||
const avgParticipants = stats?.jitsi?.peak_concurrent_users || 1
|
||||
const monthlyMinutes = dailyCallMinutes * 22
|
||||
return (monthlyMinutes * avgParticipants * 11) / 1024
|
||||
}
|
||||
|
||||
const getResourceRecommendation = (): string => {
|
||||
const peakUsers = stats?.jitsi?.peak_concurrent_users || 0
|
||||
const monthlyGB = calculateMonthlyEstimate()
|
||||
|
||||
if (monthlyGB < 10 || peakUsers < 5) {
|
||||
return 'Starter (1 vCPU, 2GB RAM, 100GB Traffic)'
|
||||
} else if (monthlyGB < 50 || peakUsers < 20) {
|
||||
return 'Standard (2 vCPU, 4GB RAM, 500GB Traffic)'
|
||||
} else if (monthlyGB < 200 || peakUsers < 50) {
|
||||
return 'Professional (4 vCPU, 8GB RAM, 2TB Traffic)'
|
||||
} else {
|
||||
return 'Enterprise (8+ vCPU, 16GB+ RAM, Unlimited Traffic)'
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* Page Purpose */}
|
||||
<PagePurpose
|
||||
title={moduleInfo?.module.name || 'Video & Chat'}
|
||||
purpose={moduleInfo?.module.purpose || 'Matrix & Jitsi Monitoring Dashboard'}
|
||||
audience={moduleInfo?.module.audience || ['Admins', 'DevOps']}
|
||||
architecture={{
|
||||
services: ['synapse (Matrix)', 'jitsi-meet', 'prosody', 'jvb'],
|
||||
databases: ['PostgreSQL', 'synapse-db'],
|
||||
}}
|
||||
collapsible={true}
|
||||
defaultCollapsed={true}
|
||||
/>
|
||||
|
||||
{/* Quick Actions */}
|
||||
<div className="flex gap-3 mb-6">
|
||||
<Link
|
||||
href="/communication/video-chat/wizard"
|
||||
className="px-4 py-2 bg-green-600 text-white rounded-lg hover:bg-green-700 transition-colors text-sm font-medium"
|
||||
>
|
||||
Test Wizard starten
|
||||
</Link>
|
||||
<button
|
||||
onClick={fetchStats}
|
||||
disabled={loading}
|
||||
className="px-4 py-2 border border-slate-300 rounded-lg hover:bg-slate-50 disabled:opacity-50 text-sm"
|
||||
>
|
||||
{loading ? 'Lade...' : 'Aktualisieren'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Service Status Overview */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6 mb-6">
|
||||
{/* Matrix Status Card */}
|
||||
<div className="bg-white rounded-xl border border-slate-200 p-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 bg-purple-100 rounded-lg flex items-center justify-center">
|
||||
<svg className="w-6 h-6 text-purple-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z" />
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-semibold text-slate-900">Matrix (Synapse)</h3>
|
||||
<p className="text-sm text-slate-500">E2EE Messaging</p>
|
||||
</div>
|
||||
</div>
|
||||
<span className={getStatusBadge(stats?.matrix.status || 'offline')}>
|
||||
{stats?.matrix.status || 'offline'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<div>
|
||||
<div className="text-2xl font-bold text-slate-900">{stats?.matrix.total_users || 0}</div>
|
||||
<div className="text-xs text-slate-500">Benutzer</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-2xl font-bold text-slate-900">{stats?.matrix.active_users || 0}</div>
|
||||
<div className="text-xs text-slate-500">Aktiv</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-2xl font-bold text-slate-900">{stats?.matrix.total_rooms || 0}</div>
|
||||
<div className="text-xs text-slate-500">Raeume</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-4 pt-4 border-t border-slate-100">
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-slate-500">Nachrichten heute</span>
|
||||
<span className="font-medium">{stats?.matrix.messages_today || 0}</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-sm mt-1">
|
||||
<span className="text-slate-500">Diese Woche</span>
|
||||
<span className="font-medium">{stats?.matrix.messages_this_week || 0}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Jitsi Status Card */}
|
||||
<div className="bg-white rounded-xl border border-slate-200 p-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 bg-blue-100 rounded-lg flex items-center justify-center">
|
||||
<svg className="w-6 h-6 text-blue-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 10l4.553-2.276A1 1 0 0121 8.618v6.764a1 1 0 01-1.447.894L15 14M5 18h8a2 2 0 002-2V8a2 2 0 00-2-2H5a2 2 0 00-2 2v8a2 2 0 002 2z" />
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-semibold text-slate-900">Jitsi Meet</h3>
|
||||
<p className="text-sm text-slate-500">Videokonferenzen</p>
|
||||
</div>
|
||||
</div>
|
||||
<span className={getStatusBadge(stats?.jitsi.status || 'offline')}>
|
||||
{stats?.jitsi.status || 'offline'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<div>
|
||||
<div className="text-2xl font-bold text-green-600">{stats?.jitsi.active_meetings || 0}</div>
|
||||
<div className="text-xs text-slate-500">Live Calls</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-2xl font-bold text-slate-900">{stats?.jitsi.total_participants || 0}</div>
|
||||
<div className="text-xs text-slate-500">Teilnehmer</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-2xl font-bold text-slate-900">{stats?.jitsi.meetings_today || 0}</div>
|
||||
<div className="text-xs text-slate-500">Calls heute</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-4 pt-4 border-t border-slate-100">
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-slate-500">Durchschnittliche Dauer</span>
|
||||
<span className="font-medium">{formatDuration(stats?.jitsi.average_duration_minutes || 0)}</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-sm mt-1">
|
||||
<span className="text-slate-500">Peak gleichzeitig</span>
|
||||
<span className="font-medium">{stats?.jitsi.peak_concurrent_users || 0} Nutzer</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Traffic & Bandwidth Statistics */}
|
||||
<div className="bg-white rounded-xl border border-slate-200 p-6 mb-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 bg-emerald-100 rounded-lg flex items-center justify-center">
|
||||
<svg className="w-6 h-6 text-emerald-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 7h8m0 0v8m0-8l-8 8-4-4-6 6" />
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-semibold text-slate-900">Traffic & Bandbreite</h3>
|
||||
<p className="text-sm text-slate-500">SysEleven Ressourcenplanung</p>
|
||||
</div>
|
||||
</div>
|
||||
<span className="px-3 py-1 rounded-full text-xs font-semibold uppercase bg-emerald-100 text-emerald-800">
|
||||
Live
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 mb-4">
|
||||
<div className="bg-slate-50 rounded-lg p-4">
|
||||
<div className="text-xs text-slate-500 mb-1">Eingehend (heute)</div>
|
||||
<div className="text-2xl font-bold text-slate-900">
|
||||
{stats?.traffic?.total?.bandwidth_in_mb?.toFixed(1) || calculateEstimatedTraffic('in').toFixed(1)} MB
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-slate-50 rounded-lg p-4">
|
||||
<div className="text-xs text-slate-500 mb-1">Ausgehend (heute)</div>
|
||||
<div className="text-2xl font-bold text-slate-900">
|
||||
{stats?.traffic?.total?.bandwidth_out_mb?.toFixed(1) || calculateEstimatedTraffic('out').toFixed(1)} MB
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-slate-50 rounded-lg p-4">
|
||||
<div className="text-xs text-slate-500 mb-1">Geschaetzt/Stunde</div>
|
||||
<div className="text-2xl font-bold text-blue-600">
|
||||
{stats?.traffic?.jitsi?.estimated_hourly_gb?.toFixed(2) || calculateHourlyEstimate().toFixed(2)} GB
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-slate-50 rounded-lg p-4">
|
||||
<div className="text-xs text-slate-500 mb-1">Geschaetzt/Monat</div>
|
||||
<div className="text-2xl font-bold text-emerald-600">
|
||||
{stats?.traffic?.total?.estimated_monthly_gb?.toFixed(1) || calculateMonthlyEstimate().toFixed(1)} GB
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{/* Matrix Traffic */}
|
||||
<div className="border border-slate-200 rounded-lg p-4">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<div className="w-3 h-3 bg-purple-500 rounded-full"></div>
|
||||
<span className="text-sm font-medium text-slate-700">Matrix Messaging</span>
|
||||
</div>
|
||||
<div className="space-y-2 text-sm">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-slate-500">Nachrichten/Min</span>
|
||||
<span className="font-medium">{stats?.traffic?.matrix?.messages_per_minute || Math.round((stats?.matrix?.messages_today || 0) / (new Date().getHours() || 1) / 60)}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-slate-500">Media Uploads heute</span>
|
||||
<span className="font-medium">{stats?.traffic?.matrix?.media_uploads_today || 0}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-slate-500">Media Groesse</span>
|
||||
<span className="font-medium">{stats?.traffic?.matrix?.media_size_mb?.toFixed(1) || '0.0'} MB</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Jitsi Traffic */}
|
||||
<div className="border border-slate-200 rounded-lg p-4">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<div className="w-3 h-3 bg-blue-500 rounded-full"></div>
|
||||
<span className="text-sm font-medium text-slate-700">Jitsi Video</span>
|
||||
</div>
|
||||
<div className="space-y-2 text-sm">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-slate-500">Video Streams aktiv</span>
|
||||
<span className="font-medium">{stats?.traffic?.jitsi?.video_streams_active || (stats?.jitsi?.total_participants || 0)}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-slate-500">Audio Streams aktiv</span>
|
||||
<span className="font-medium">{stats?.traffic?.jitsi?.audio_streams_active || (stats?.jitsi?.total_participants || 0)}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-slate-500">Bitrate geschaetzt</span>
|
||||
<span className="font-medium">{((stats?.jitsi?.total_participants || 0) * 1.5).toFixed(1)} Mbps</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* SysEleven Recommendation */}
|
||||
<div className="mt-4 p-4 bg-emerald-50 border border-emerald-200 rounded-lg">
|
||||
<h4 className="text-sm font-semibold text-emerald-800 mb-2">SysEleven Empfehlung</h4>
|
||||
<div className="text-sm text-emerald-700">
|
||||
<p>Basierend auf aktuellem Traffic: <strong>{getResourceRecommendation()}</strong></p>
|
||||
<p className="mt-1 text-xs text-emerald-600">
|
||||
Peak Teilnehmer: {stats?.jitsi?.peak_concurrent_users || 0} |
|
||||
Durchschnittliche Call-Dauer: {stats?.jitsi?.average_duration_minutes?.toFixed(0) || 0} Min. |
|
||||
Calls heute: {stats?.jitsi?.meetings_today || 0}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Active Meetings */}
|
||||
<div className="bg-white rounded-xl border border-slate-200 p-6 mb-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h3 className="font-semibold text-slate-900">Aktive Meetings</h3>
|
||||
</div>
|
||||
|
||||
{activeMeetings.length === 0 ? (
|
||||
<div className="text-center py-8 text-slate-500">
|
||||
<svg className="w-12 h-12 mx-auto mb-3 text-slate-300" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 10l4.553-2.276A1 1 0 0121 8.618v6.764a1 1 0 01-1.447.894L15 14M5 18h8a2 2 0 002-2V8a2 2 0 00-2-2H5a2 2 0 00-2 2v8a2 2 0 002 2z" />
|
||||
</svg>
|
||||
<p>Keine aktiven Meetings</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full">
|
||||
<thead>
|
||||
<tr className="text-left text-xs text-slate-500 uppercase border-b border-slate-200">
|
||||
<th className="pb-3 pr-4">Meeting</th>
|
||||
<th className="pb-3 pr-4">Teilnehmer</th>
|
||||
<th className="pb-3 pr-4">Gestartet</th>
|
||||
<th className="pb-3">Dauer</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-slate-100">
|
||||
{activeMeetings.map((meeting, idx) => (
|
||||
<tr key={idx} className="text-sm">
|
||||
<td className="py-3 pr-4">
|
||||
<div className="font-medium text-slate-900">{meeting.display_name}</div>
|
||||
<div className="text-xs text-slate-500">{meeting.room_name}</div>
|
||||
</td>
|
||||
<td className="py-3 pr-4">
|
||||
<span className="inline-flex items-center gap-1">
|
||||
<span className="w-2 h-2 bg-green-500 rounded-full animate-pulse" />
|
||||
{meeting.participants}
|
||||
</span>
|
||||
</td>
|
||||
<td className="py-3 pr-4 text-slate-500">{formatTimeAgo(meeting.started_at)}</td>
|
||||
<td className="py-3 font-medium">{formatDuration(meeting.duration_minutes)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Recent Chat Rooms & Usage Stats */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-6">
|
||||
<div className="bg-white rounded-xl border border-slate-200 p-6">
|
||||
<h3 className="font-semibold text-slate-900 mb-4">Aktive Chat-Raeume</h3>
|
||||
|
||||
{recentRooms.length === 0 ? (
|
||||
<div className="text-center py-6 text-slate-500">
|
||||
<p>Keine aktiven Raeume</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{recentRooms.slice(0, 5).map((room, idx) => (
|
||||
<div key={idx} className="flex items-center justify-between p-3 bg-slate-50 rounded-lg">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-8 h-8 bg-slate-200 rounded-lg flex items-center justify-center">
|
||||
<svg className="w-4 h-4 text-slate-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<div className="font-medium text-slate-900 text-sm">{room.name}</div>
|
||||
<div className="text-xs text-slate-500">{room.member_count} Mitglieder</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={getRoomTypeBadge(room.room_type)}>{room.room_type}</span>
|
||||
<span className="text-xs text-slate-400">{formatTimeAgo(room.last_activity)}</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Usage Statistics */}
|
||||
<div className="bg-white rounded-xl border border-slate-200 p-6">
|
||||
<h3 className="font-semibold text-slate-900 mb-4">Nutzungsstatistiken</h3>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<div className="flex justify-between text-sm mb-1">
|
||||
<span className="text-slate-600">Call-Minuten heute</span>
|
||||
<span className="font-semibold">{stats?.jitsi.total_minutes_today || 0} Min.</span>
|
||||
</div>
|
||||
<div className="w-full bg-slate-100 rounded-full h-2">
|
||||
<div
|
||||
className="bg-blue-600 h-2 rounded-full transition-all"
|
||||
style={{ width: `${Math.min((stats?.jitsi.total_minutes_today || 0) / 500 * 100, 100)}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="flex justify-between text-sm mb-1">
|
||||
<span className="text-slate-600">Aktive Chat-Raeume</span>
|
||||
<span className="font-semibold">{stats?.matrix.active_rooms || 0} / {stats?.matrix.total_rooms || 0}</span>
|
||||
</div>
|
||||
<div className="w-full bg-slate-100 rounded-full h-2">
|
||||
<div
|
||||
className="bg-purple-600 h-2 rounded-full transition-all"
|
||||
style={{ width: `${stats?.matrix.total_rooms ? ((stats.matrix.active_rooms / stats.matrix.total_rooms) * 100) : 0}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="flex justify-between text-sm mb-1">
|
||||
<span className="text-slate-600">Aktive Nutzer</span>
|
||||
<span className="font-semibold">{stats?.matrix.active_users || 0} / {stats?.matrix.total_users || 0}</span>
|
||||
</div>
|
||||
<div className="w-full bg-slate-100 rounded-full h-2">
|
||||
<div
|
||||
className="bg-green-600 h-2 rounded-full transition-all"
|
||||
style={{ width: `${stats?.matrix.total_users ? ((stats.matrix.active_users / stats.matrix.total_users) * 100) : 0}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Quick Actions */}
|
||||
<div className="mt-6 pt-4 border-t border-slate-100">
|
||||
<h4 className="text-sm font-medium text-slate-700 mb-3">Schnellaktionen</h4>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<a
|
||||
href="http://localhost:8448/_synapse/admin"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="px-3 py-1.5 text-sm bg-purple-100 text-purple-700 rounded-lg hover:bg-purple-200 transition-colors"
|
||||
>
|
||||
Synapse Admin
|
||||
</a>
|
||||
<a
|
||||
href="http://localhost:8443"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="px-3 py-1.5 text-sm bg-blue-100 text-blue-700 rounded-lg hover:bg-blue-200 transition-colors"
|
||||
>
|
||||
Jitsi Meet
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Connection Info */}
|
||||
<div className="bg-blue-50 border border-blue-200 rounded-xl p-4">
|
||||
<div className="flex gap-3">
|
||||
<svg className="w-5 h-5 text-blue-600 flex-shrink-0 mt-0.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
<div>
|
||||
<h4 className="font-semibold text-blue-900">Service Konfiguration</h4>
|
||||
<p className="text-sm text-blue-800 mt-1">
|
||||
<strong>Matrix Homeserver:</strong> http://localhost:8448 (Synapse)<br />
|
||||
<strong>Jitsi Meet:</strong> http://localhost:8443<br />
|
||||
<strong>Auto-Refresh:</strong> Alle 15 Sekunden
|
||||
</p>
|
||||
{error && (
|
||||
<p className="text-sm text-red-600 mt-2">
|
||||
<strong>Fehler:</strong> {error} - Backend nicht erreichbar
|
||||
</p>
|
||||
)}
|
||||
{stats?.last_updated && (
|
||||
<p className="text-xs text-blue-600 mt-2">
|
||||
Letzte Aktualisierung: {new Date(stats.last_updated).toLocaleString('de-DE')}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,366 @@
|
||||
'use client'
|
||||
|
||||
/**
|
||||
* Video & Chat Wizard Page
|
||||
*
|
||||
* Interactive learning and testing wizard for Matrix & Jitsi integration
|
||||
* Migrated from website/app/admin/communication/wizard
|
||||
*/
|
||||
|
||||
import { useState } from 'react'
|
||||
import Link from 'next/link'
|
||||
import {
|
||||
WizardStepper,
|
||||
WizardNavigation,
|
||||
EducationCard,
|
||||
ArchitectureContext,
|
||||
TestRunner,
|
||||
TestSummary,
|
||||
type WizardStep,
|
||||
type TestCategoryResult,
|
||||
type FullTestResults,
|
||||
type EducationContent,
|
||||
type ArchitectureContextType,
|
||||
} from '@/components/wizard'
|
||||
|
||||
// ==============================================
|
||||
// Constants
|
||||
// ==============================================
|
||||
|
||||
const BACKEND_URL = process.env.NEXT_PUBLIC_BACKEND_URL || 'http://localhost:8000'
|
||||
|
||||
const STEPS: WizardStep[] = [
|
||||
{ id: 'welcome', name: 'Willkommen', icon: '👋', status: 'pending' },
|
||||
{ id: 'api-health', name: 'API Status', icon: '💚', status: 'pending', category: 'api-health' },
|
||||
{ id: 'matrix', name: 'Matrix', icon: '💬', status: 'pending', category: 'matrix' },
|
||||
{ id: 'jitsi', name: 'Jitsi', icon: '📹', status: 'pending', category: 'jitsi' },
|
||||
{ id: 'summary', name: 'Zusammenfassung', icon: '📊', status: 'pending' },
|
||||
]
|
||||
|
||||
const EDUCATION_CONTENT: Record<string, EducationContent> = {
|
||||
'welcome': {
|
||||
title: 'Willkommen zum Video & Chat Wizard',
|
||||
content: [
|
||||
'Sichere Kommunikation ist das Rueckgrat moderner Bildungsplattformen.',
|
||||
'',
|
||||
'BreakPilot nutzt zwei Open-Source Systeme:',
|
||||
'• Matrix Synapse: Dezentraler Messenger (Ende-zu-Ende verschluesselt)',
|
||||
'• Jitsi Meet: Video-Konferenzen (WebRTC-basiert)',
|
||||
'',
|
||||
'Beide Systeme sind DSGVO-konform und self-hosted.',
|
||||
'',
|
||||
'In diesem Wizard testen wir:',
|
||||
'• Matrix Homeserver und Federation',
|
||||
'• Jitsi Video-Konferenz Server',
|
||||
'• Integration mit der Schulverwaltung',
|
||||
],
|
||||
},
|
||||
'api-health': {
|
||||
title: 'Communication API - Backend Integration',
|
||||
content: [
|
||||
'Die Communication API verbindet Matrix und Jitsi mit BreakPilot.',
|
||||
'',
|
||||
'Funktionen:',
|
||||
'• Automatische Raum-Erstellung fuer Klassen',
|
||||
'• Eltern-Lehrer DM-Raeume',
|
||||
'• Meeting-Planung mit Kalender-Integration',
|
||||
'• Benachrichtigungen bei neuen Nachrichten',
|
||||
'',
|
||||
'Endpunkte:',
|
||||
'• /api/v1/communication/admin/stats',
|
||||
'• /api/v1/communication/admin/matrix/users',
|
||||
'• /api/v1/communication/rooms',
|
||||
],
|
||||
},
|
||||
'matrix': {
|
||||
title: 'Matrix Synapse - Dezentraler Messenger',
|
||||
content: [
|
||||
'Matrix ist ein offenes Protokoll fuer sichere Kommunikation.',
|
||||
'',
|
||||
'Vorteile gegenueber WhatsApp/Teams:',
|
||||
'• Ende-zu-Ende Verschluesselung (E2EE)',
|
||||
'• Dezentral: Kein Single Point of Failure',
|
||||
'• Federation: Kommunikation mit anderen Schulen',
|
||||
'• Self-Hosted: Volle Datenkontrolle',
|
||||
'',
|
||||
'Raum-Typen in BreakPilot:',
|
||||
'• Klassen-Info (Ankuendigungen)',
|
||||
'• Elternvertreter-Raum',
|
||||
'• Lehrer-Eltern DM',
|
||||
'• Fachgruppen',
|
||||
],
|
||||
},
|
||||
'jitsi': {
|
||||
title: 'Jitsi Meet - Video-Konferenzen',
|
||||
content: [
|
||||
'Jitsi ist eine Open-Source Alternative zu Zoom/Teams.',
|
||||
'',
|
||||
'Features:',
|
||||
'• WebRTC: Keine Software-Installation noetig',
|
||||
'• Bildschirmfreigabe und Whiteboard',
|
||||
'• Breakout-Raeume fuer Gruppenarbeit',
|
||||
'• Aufzeichnung (optional, lokal)',
|
||||
'',
|
||||
'Anwendungsfaelle:',
|
||||
'• Elternsprechtage (online)',
|
||||
'• Fernunterricht bei Schulausfall',
|
||||
'• Lehrerkonferenzen',
|
||||
'• Foerdergespraeche',
|
||||
],
|
||||
},
|
||||
'summary': {
|
||||
title: 'Test-Zusammenfassung',
|
||||
content: [
|
||||
'Hier sehen Sie eine Uebersicht aller durchgefuehrten Tests:',
|
||||
'• Matrix Homeserver Verfuegbarkeit',
|
||||
'• Jitsi Server Status',
|
||||
'• API-Integration',
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
const ARCHITECTURE_CONTEXTS: Record<string, ArchitectureContextType> = {
|
||||
'api-health': {
|
||||
layer: 'api',
|
||||
services: ['backend', 'consent-service'],
|
||||
dependencies: ['PostgreSQL', 'Matrix Synapse', 'Jitsi'],
|
||||
dataFlow: ['Browser', 'FastAPI', 'Go Service', 'Matrix/Jitsi'],
|
||||
},
|
||||
'matrix': {
|
||||
layer: 'service',
|
||||
services: ['matrix'],
|
||||
dependencies: ['PostgreSQL', 'Federation', 'TURN Server'],
|
||||
dataFlow: ['Element Client', 'Matrix Synapse', 'Federation', 'PostgreSQL'],
|
||||
},
|
||||
'jitsi': {
|
||||
layer: 'service',
|
||||
services: ['jitsi'],
|
||||
dependencies: ['Prosody XMPP', 'JVB', 'TURN/STUN'],
|
||||
dataFlow: ['Browser', 'Nginx', 'Prosody', 'Jitsi Videobridge'],
|
||||
},
|
||||
}
|
||||
|
||||
// ==============================================
|
||||
// Main Component
|
||||
// ==============================================
|
||||
|
||||
export default function VideoChatWizardPage() {
|
||||
const [currentStep, setCurrentStep] = useState(0)
|
||||
const [steps, setSteps] = useState<WizardStep[]>(STEPS)
|
||||
const [categoryResults, setCategoryResults] = useState<Record<string, TestCategoryResult>>({})
|
||||
const [fullResults, setFullResults] = useState<FullTestResults | null>(null)
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
const currentStepData = steps[currentStep]
|
||||
const isTestStep = currentStepData?.category !== undefined
|
||||
const isWelcome = currentStepData?.id === 'welcome'
|
||||
const isSummary = currentStepData?.id === 'summary'
|
||||
|
||||
const runCategoryTest = async (category: string) => {
|
||||
setIsLoading(true)
|
||||
setError(null)
|
||||
|
||||
try {
|
||||
const response = await fetch(`${BACKEND_URL}/api/admin/communication-tests/${category}`, {
|
||||
method: 'POST',
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}: ${response.statusText}`)
|
||||
}
|
||||
|
||||
const result: TestCategoryResult = await response.json()
|
||||
setCategoryResults((prev) => ({ ...prev, [category]: result }))
|
||||
|
||||
setSteps((prev) =>
|
||||
prev.map((step) =>
|
||||
step.category === category
|
||||
? { ...step, status: result.failed === 0 ? 'completed' : 'failed' }
|
||||
: step
|
||||
)
|
||||
)
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Unbekannter Fehler')
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const runAllTests = async () => {
|
||||
setIsLoading(true)
|
||||
setError(null)
|
||||
|
||||
try {
|
||||
const response = await fetch(`${BACKEND_URL}/api/admin/communication-tests/run-all`, {
|
||||
method: 'POST',
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}: ${response.statusText}`)
|
||||
}
|
||||
|
||||
const results: FullTestResults = await response.json()
|
||||
setFullResults(results)
|
||||
|
||||
setSteps((prev) =>
|
||||
prev.map((step) => {
|
||||
if (step.category) {
|
||||
const catResult = results.categories.find((c) => c.category === step.category)
|
||||
if (catResult) {
|
||||
return { ...step, status: catResult.failed === 0 ? 'completed' : 'failed' }
|
||||
}
|
||||
}
|
||||
return step
|
||||
})
|
||||
)
|
||||
|
||||
const newCategoryResults: Record<string, TestCategoryResult> = {}
|
||||
results.categories.forEach((cat) => {
|
||||
newCategoryResults[cat.category] = cat
|
||||
})
|
||||
setCategoryResults(newCategoryResults)
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Unbekannter Fehler')
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const goToNext = () => {
|
||||
if (currentStep < steps.length - 1) {
|
||||
setSteps((prev) =>
|
||||
prev.map((step, idx) =>
|
||||
idx === currentStep && step.status === 'pending'
|
||||
? { ...step, status: 'completed' }
|
||||
: step
|
||||
)
|
||||
)
|
||||
setCurrentStep((prev) => prev + 1)
|
||||
}
|
||||
}
|
||||
|
||||
const goToPrev = () => {
|
||||
if (currentStep > 0) {
|
||||
setCurrentStep((prev) => prev - 1)
|
||||
}
|
||||
}
|
||||
|
||||
const handleStepClick = (index: number) => {
|
||||
if (index <= currentStep || steps[index - 1]?.status !== 'pending') {
|
||||
setCurrentStep(index)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* Header */}
|
||||
<div className="bg-white rounded-lg border border-slate-200 p-4 mb-6 flex items-center justify-between">
|
||||
<div className="flex items-center">
|
||||
<span className="text-3xl mr-3">💬</span>
|
||||
<div>
|
||||
<h2 className="text-lg font-bold text-gray-800">Video & Chat Test Wizard</h2>
|
||||
<p className="text-sm text-gray-600">Matrix Messenger & Jitsi Video</p>
|
||||
</div>
|
||||
</div>
|
||||
<Link href="/communication/video-chat" className="text-blue-600 hover:text-blue-800 text-sm">
|
||||
← Zurueck zu Video & Chat
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* Stepper */}
|
||||
<div className="bg-white rounded-lg border border-slate-200 p-6 mb-6">
|
||||
<WizardStepper steps={steps} currentStep={currentStep} onStepClick={handleStepClick} />
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="bg-white rounded-lg border border-slate-200 p-6">
|
||||
<div className="flex items-center mb-6">
|
||||
<span className="text-3xl mr-3">{currentStepData?.icon}</span>
|
||||
<div>
|
||||
<h2 className="text-xl font-bold text-gray-800">
|
||||
Schritt {currentStep + 1}: {currentStepData?.name}
|
||||
</h2>
|
||||
<p className="text-gray-500 text-sm">
|
||||
{currentStep + 1} von {steps.length}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<EducationCard content={EDUCATION_CONTENT[currentStepData?.id || '']} />
|
||||
|
||||
{isTestStep && currentStepData?.category && ARCHITECTURE_CONTEXTS[currentStepData.category] && (
|
||||
<ArchitectureContext
|
||||
context={ARCHITECTURE_CONTEXTS[currentStepData.category]}
|
||||
currentStep={currentStepData.name}
|
||||
/>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<div className="bg-red-50 border border-red-200 text-red-700 rounded-lg p-4 mb-6">
|
||||
<strong>Fehler:</strong> {error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isWelcome && (
|
||||
<div className="text-center py-8">
|
||||
<button
|
||||
onClick={goToNext}
|
||||
className="bg-blue-600 text-white px-8 py-3 rounded-lg font-medium hover:bg-blue-700 transition-colors"
|
||||
>
|
||||
Wizard starten
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isTestStep && currentStepData?.category && (
|
||||
<TestRunner
|
||||
category={currentStepData.category}
|
||||
categoryResult={categoryResults[currentStepData.category]}
|
||||
isLoading={isLoading}
|
||||
onRunTests={() => runCategoryTest(currentStepData.category!)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{isSummary && (
|
||||
<div>
|
||||
{!fullResults ? (
|
||||
<div className="text-center py-8">
|
||||
<p className="text-gray-600 mb-4">
|
||||
Fuehren Sie alle Tests aus um eine Zusammenfassung zu sehen.
|
||||
</p>
|
||||
<button
|
||||
onClick={runAllTests}
|
||||
disabled={isLoading}
|
||||
className={`px-6 py-3 rounded-lg font-medium transition-colors ${
|
||||
isLoading
|
||||
? 'bg-gray-400 cursor-not-allowed'
|
||||
: 'bg-blue-600 text-white hover:bg-blue-700'
|
||||
}`}
|
||||
>
|
||||
{isLoading ? 'Alle Tests laufen...' : 'Alle Tests ausfuehren'}
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<TestSummary results={fullResults} />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<WizardNavigation
|
||||
currentStep={currentStep}
|
||||
totalSteps={steps.length}
|
||||
onPrev={goToPrev}
|
||||
onNext={goToNext}
|
||||
showNext={!isSummary}
|
||||
isLoading={isLoading}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="text-center text-gray-500 text-sm mt-6">
|
||||
Diese Tests pruefen die Matrix- und Jitsi-Integration.
|
||||
Bei Fragen wenden Sie sich an das IT-Team.
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
'use client'
|
||||
|
||||
import { SDKProvider } from '@/lib/sdk/context'
|
||||
import { CatalogManagerContent } from '@/components/catalog-manager/CatalogManagerContent'
|
||||
|
||||
export default function AdminCatalogManagerPage() {
|
||||
return (
|
||||
<SDKProvider>
|
||||
<CatalogManagerContent />
|
||||
</SDKProvider>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import { navigation, metaModules } from '@/lib/navigation'
|
||||
import { getStoredRole, isCategoryVisibleForRole, RoleId } from '@/lib/roles'
|
||||
import { CategoryCard } from '@/components/common/ModuleCard'
|
||||
import { InfoNote } from '@/components/common/InfoBox'
|
||||
import { ServiceStatus } from '@/components/common/ServiceStatus'
|
||||
import { NightModeWidget } from '@/components/dashboard/NightModeWidget'
|
||||
import Link from 'next/link'
|
||||
|
||||
interface Stats {
|
||||
activeDocuments: number
|
||||
openDSR: number
|
||||
registeredUsers: number
|
||||
totalConsents: number
|
||||
gpuInstances: number
|
||||
}
|
||||
|
||||
export default function DashboardPage() {
|
||||
const [stats, setStats] = useState<Stats>({
|
||||
activeDocuments: 0,
|
||||
openDSR: 0,
|
||||
registeredUsers: 0,
|
||||
totalConsents: 0,
|
||||
gpuInstances: 0,
|
||||
})
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [currentRole, setCurrentRole] = useState<RoleId | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
const role = getStoredRole()
|
||||
setCurrentRole(role)
|
||||
|
||||
// Load stats
|
||||
const loadStats = async () => {
|
||||
try {
|
||||
const response = await fetch('http://localhost:8081/api/v1/admin/stats')
|
||||
if (response.ok) {
|
||||
const data = await response.json()
|
||||
setStats({
|
||||
activeDocuments: data.documents_count || 0,
|
||||
openDSR: data.open_dsr_count || 0,
|
||||
registeredUsers: data.users_count || 0,
|
||||
totalConsents: data.consents_count || 0,
|
||||
gpuInstances: 0,
|
||||
})
|
||||
}
|
||||
} catch (error) {
|
||||
console.log('Stats not available')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
loadStats()
|
||||
}, [])
|
||||
|
||||
const statCards = [
|
||||
{ label: 'Aktive Dokumente', value: stats.activeDocuments, color: 'text-green-600' },
|
||||
{ label: 'Offene DSR', value: stats.openDSR, color: stats.openDSR > 0 ? 'text-orange-600' : 'text-slate-600' },
|
||||
{ label: 'Registrierte Nutzer', value: stats.registeredUsers, color: 'text-blue-600' },
|
||||
{ label: 'Zustimmungen', value: stats.totalConsents, color: 'text-purple-600' },
|
||||
{ label: 'GPU Instanzen', value: stats.gpuInstances, color: 'text-pink-600' },
|
||||
]
|
||||
|
||||
const visibleCategories = currentRole
|
||||
? navigation.filter(cat => isCategoryVisibleForRole(cat.id, currentRole))
|
||||
: navigation
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* Stats Grid */}
|
||||
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-5 gap-4 mb-8">
|
||||
{statCards.map((stat) => (
|
||||
<div key={stat.label} className="bg-white rounded-xl border border-slate-200 p-4 shadow-sm">
|
||||
<div className={`text-3xl font-bold ${stat.color}`}>
|
||||
{loading ? '-' : stat.value}
|
||||
</div>
|
||||
<div className="text-sm text-slate-500 mt-1">{stat.label}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Categories */}
|
||||
<h2 className="text-lg font-semibold text-slate-900 mb-4">Bereiche</h2>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4 mb-8">
|
||||
{visibleCategories.map((category) => (
|
||||
<CategoryCard key={category.id} category={category} />
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Quick Links */}
|
||||
<h2 className="text-lg font-semibold text-slate-900 mb-4">Schnellzugriff</h2>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4 mb-8">
|
||||
{metaModules.filter(m => m.id !== 'dashboard').map((module) => (
|
||||
<Link
|
||||
key={module.id}
|
||||
href={module.href}
|
||||
className="flex items-center gap-3 p-4 bg-white rounded-xl border border-slate-200 hover:border-primary-300 hover:shadow-md transition-all"
|
||||
>
|
||||
<div className="w-10 h-10 bg-slate-100 rounded-lg flex items-center justify-center">
|
||||
{module.id === 'onboarding' && '📖'}
|
||||
{module.id === 'backlog' && '📋'}
|
||||
{module.id === 'rbac' && '👥'}
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-medium text-slate-900">{module.name}</h3>
|
||||
<p className="text-sm text-slate-500">{module.description}</p>
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Infrastructure & System Status */}
|
||||
<h2 className="text-lg font-semibold text-slate-900 mb-4">Infrastruktur</h2>
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-8">
|
||||
{/* Night Mode Widget */}
|
||||
<NightModeWidget />
|
||||
|
||||
{/* System Status */}
|
||||
<ServiceStatus />
|
||||
</div>
|
||||
|
||||
{/* Recent Activity */}
|
||||
<h2 className="text-lg font-semibold text-slate-900 mb-4">Aktivitaet</h2>
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
{/* Recent DSR */}
|
||||
<div className="bg-white rounded-xl border border-slate-200 shadow-sm">
|
||||
<div className="px-4 py-3 border-b border-slate-200 flex items-center justify-between">
|
||||
<h3 className="font-semibold text-slate-900">Neueste Datenschutzanfragen</h3>
|
||||
<Link href="/sdk/dsr" className="text-sm text-primary-600 hover:text-primary-700">
|
||||
Alle anzeigen
|
||||
</Link>
|
||||
</div>
|
||||
<div className="p-4">
|
||||
<p className="text-sm text-slate-500 text-center py-4">
|
||||
Keine offenen Anfragen
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Info Box */}
|
||||
<div className="mt-8">
|
||||
<InfoNote title="Admin v2 - Neues Frontend">
|
||||
<p>
|
||||
Dieses neue Admin-Frontend bietet eine verbesserte Navigation mit Kategorien und Rollen-basiertem Zugriff.
|
||||
Das alte Admin-Frontend ist weiterhin unter Port 3000 verfuegbar.
|
||||
</p>
|
||||
</InfoNote>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,271 @@
|
||||
import { DevPortalLayout, ApiEndpoint, CodeBlock, ParameterTable, InfoBox } from '@/components/developers/DevPortalLayout'
|
||||
|
||||
export default function ExportApiPage() {
|
||||
return (
|
||||
<DevPortalLayout
|
||||
title="Export API"
|
||||
description="Exportieren Sie Compliance-Daten in verschiedenen Formaten"
|
||||
>
|
||||
<h2>Uebersicht</h2>
|
||||
<p>
|
||||
Die Export API ermoeglicht den Download aller Compliance-Daten in
|
||||
verschiedenen Formaten fuer Audits, Dokumentation und Archivierung.
|
||||
</p>
|
||||
|
||||
<h2>Unterstuetzte Formate</h2>
|
||||
<div className="my-4 overflow-x-auto not-prose">
|
||||
<table className="min-w-full divide-y divide-gray-200">
|
||||
<thead className="bg-gray-50">
|
||||
<tr>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Format</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Beschreibung</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Use Case</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="bg-white divide-y divide-gray-200 text-sm">
|
||||
<tr>
|
||||
<td className="px-4 py-3 font-mono">json</td>
|
||||
<td className="px-4 py-3 text-gray-600">Kompletter State als JSON</td>
|
||||
<td className="px-4 py-3 text-gray-600">Backup, Migration, API-Integration</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="px-4 py-3 font-mono">pdf</td>
|
||||
<td className="px-4 py-3 text-gray-600">Formatierter PDF-Report</td>
|
||||
<td className="px-4 py-3 text-gray-600">Audits, Management-Reports</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="px-4 py-3 font-mono">zip</td>
|
||||
<td className="px-4 py-3 text-gray-600">Alle Dokumente als ZIP-Archiv</td>
|
||||
<td className="px-4 py-3 text-gray-600">Vollstaendige Dokumentation</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<h2>GET /export</h2>
|
||||
<p>Exportiert den aktuellen State im gewuenschten Format.</p>
|
||||
|
||||
<h3>Query-Parameter</h3>
|
||||
<ParameterTable
|
||||
parameters={[
|
||||
{
|
||||
name: 'format',
|
||||
type: 'string',
|
||||
required: true,
|
||||
description: 'Export-Format: json, pdf, zip',
|
||||
},
|
||||
{
|
||||
name: 'tenantId',
|
||||
type: 'string',
|
||||
required: true,
|
||||
description: 'Tenant-ID',
|
||||
},
|
||||
{
|
||||
name: 'sections',
|
||||
type: 'string',
|
||||
required: false,
|
||||
description: 'Kommaseparierte Liste: useCases,risks,controls,dsfa,toms,vvt (default: alle)',
|
||||
},
|
||||
{
|
||||
name: 'phase',
|
||||
type: 'number',
|
||||
required: false,
|
||||
description: 'Nur bestimmte Phase exportieren: 1 oder 2',
|
||||
},
|
||||
{
|
||||
name: 'language',
|
||||
type: 'string',
|
||||
required: false,
|
||||
description: 'Sprache fuer PDF: de, en (default: de)',
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
<h2>JSON Export</h2>
|
||||
|
||||
<h3>Request</h3>
|
||||
<CodeBlock language="bash" filename="cURL">
|
||||
{`curl -X GET "https://api.breakpilot.io/sdk/v1/export?format=json&tenantId=your-tenant-id" \\
|
||||
-H "Authorization: Bearer YOUR_API_KEY" \\
|
||||
-o compliance-export.json`}
|
||||
</CodeBlock>
|
||||
|
||||
<h3>Response</h3>
|
||||
<CodeBlock language="json" filename="compliance-export.json">
|
||||
{`{
|
||||
"exportedAt": "2026-02-04T12:00:00Z",
|
||||
"version": "1.0.0",
|
||||
"tenantId": "your-tenant-id",
|
||||
"state": {
|
||||
"currentPhase": 2,
|
||||
"currentStep": "dsfa",
|
||||
"completedSteps": [...],
|
||||
"useCases": [...],
|
||||
"risks": [...],
|
||||
"controls": [...],
|
||||
"dsfa": {...},
|
||||
"toms": [...],
|
||||
"vvt": [...]
|
||||
},
|
||||
"meta": {
|
||||
"completionPercentage": 75,
|
||||
"lastModified": "2026-02-04T11:55:00Z"
|
||||
}
|
||||
}`}
|
||||
</CodeBlock>
|
||||
|
||||
<h2>PDF Export</h2>
|
||||
|
||||
<h3>Request</h3>
|
||||
<CodeBlock language="bash" filename="cURL">
|
||||
{`curl -X GET "https://api.breakpilot.io/sdk/v1/export?format=pdf&tenantId=your-tenant-id§ions=dsfa,toms" \\
|
||||
-H "Authorization: Bearer YOUR_API_KEY" \\
|
||||
-o compliance-report.pdf`}
|
||||
</CodeBlock>
|
||||
|
||||
<h3>PDF Inhalt</h3>
|
||||
<p>Das generierte PDF enthaelt:</p>
|
||||
<ul>
|
||||
<li>Deckblatt mit Tenant-Info und Exportdatum</li>
|
||||
<li>Inhaltsverzeichnis</li>
|
||||
<li>Executive Summary mit Fortschritt</li>
|
||||
<li>Use Case Uebersicht</li>
|
||||
<li>Risikoanalyse mit Matrix-Visualisierung</li>
|
||||
<li>DSFA (falls generiert)</li>
|
||||
<li>TOM-Katalog</li>
|
||||
<li>VVT-Auszug</li>
|
||||
<li>Checkpoint-Status</li>
|
||||
</ul>
|
||||
|
||||
<InfoBox type="info" title="PDF Styling">
|
||||
Das PDF folgt einem professionellen Audit-Layout mit Corporate Design.
|
||||
Enterprise-Kunden koennen ein Custom-Logo und Farbschema konfigurieren.
|
||||
</InfoBox>
|
||||
|
||||
<h2>ZIP Export</h2>
|
||||
|
||||
<h3>Request</h3>
|
||||
<CodeBlock language="bash" filename="cURL">
|
||||
{`curl -X GET "https://api.breakpilot.io/sdk/v1/export?format=zip&tenantId=your-tenant-id" \\
|
||||
-H "Authorization: Bearer YOUR_API_KEY" \\
|
||||
-o compliance-export.zip`}
|
||||
</CodeBlock>
|
||||
|
||||
<h3>ZIP Struktur</h3>
|
||||
<CodeBlock language="text" filename="compliance-export.zip">
|
||||
{`compliance-export/
|
||||
├── README.md
|
||||
├── state.json # Kompletter State
|
||||
├── summary.pdf # Executive Summary
|
||||
├── use-cases/
|
||||
│ ├── uc-1-ki-analyse.json
|
||||
│ └── uc-2-chatbot.json
|
||||
├── risks/
|
||||
│ ├── risk-matrix.pdf
|
||||
│ └── risks.json
|
||||
├── documents/
|
||||
│ ├── dsfa.pdf
|
||||
│ ├── dsfa.json
|
||||
│ ├── toms.pdf
|
||||
│ ├── toms.json
|
||||
│ ├── vvt.pdf
|
||||
│ └── vvt.json
|
||||
├── checkpoints/
|
||||
│ └── checkpoint-status.json
|
||||
└── audit-trail/
|
||||
└── changes.json`}
|
||||
</CodeBlock>
|
||||
|
||||
<h2>SDK Integration</h2>
|
||||
<CodeBlock language="typescript" filename="export-examples.ts">
|
||||
{`import { useSDK, exportToPDF, exportToZIP, downloadExport } from '@breakpilot/compliance-sdk'
|
||||
|
||||
// Option 1: Ueber den Hook
|
||||
function ExportButton() {
|
||||
const { exportState } = useSDK()
|
||||
|
||||
const handlePDFExport = async () => {
|
||||
const blob = await exportState('pdf')
|
||||
downloadExport(blob, 'compliance-report.pdf')
|
||||
}
|
||||
|
||||
const handleZIPExport = async () => {
|
||||
const blob = await exportState('zip')
|
||||
downloadExport(blob, 'compliance-export.zip')
|
||||
}
|
||||
|
||||
const handleJSONExport = async () => {
|
||||
const blob = await exportState('json')
|
||||
downloadExport(blob, 'compliance-state.json')
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex gap-2">
|
||||
<button onClick={handlePDFExport}>PDF Export</button>
|
||||
<button onClick={handleZIPExport}>ZIP Export</button>
|
||||
<button onClick={handleJSONExport}>JSON Export</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Option 2: Direkte Funktionen
|
||||
async function exportManually(state: SDKState) {
|
||||
// PDF generieren
|
||||
const pdfBlob = await exportToPDF(state)
|
||||
downloadExport(pdfBlob, \`compliance-\${Date.now()}.pdf\`)
|
||||
|
||||
// ZIP generieren
|
||||
const zipBlob = await exportToZIP(state)
|
||||
downloadExport(zipBlob, \`compliance-\${Date.now()}.zip\`)
|
||||
}`}
|
||||
</CodeBlock>
|
||||
|
||||
<h2>Command Bar Integration</h2>
|
||||
<p>
|
||||
Exporte sind auch ueber die Command Bar verfuegbar:
|
||||
</p>
|
||||
<CodeBlock language="text" filename="Command Bar">
|
||||
{`Cmd+K → "pdf" → "Als PDF exportieren"
|
||||
Cmd+K → "zip" → "Als ZIP exportieren"
|
||||
Cmd+K → "json" → "Als JSON exportieren"`}
|
||||
</CodeBlock>
|
||||
|
||||
<h2>Automatisierte Exports</h2>
|
||||
<p>
|
||||
Fuer regelmaessige Backups oder CI/CD-Integration:
|
||||
</p>
|
||||
<CodeBlock language="bash" filename="Cron Job">
|
||||
{`# Taeglicher Backup-Export um 02:00 Uhr
|
||||
0 2 * * * curl -X GET "https://api.breakpilot.io/sdk/v1/export?format=zip&tenantId=my-tenant" \\
|
||||
-H "Authorization: Bearer $API_KEY" \\
|
||||
-o "/backups/compliance-$(date +%Y%m%d).zip"`}
|
||||
</CodeBlock>
|
||||
|
||||
<InfoBox type="warning" title="Dateigröße">
|
||||
ZIP-Exporte koennen bei umfangreichen States mehrere MB gross werden.
|
||||
Die API hat ein Timeout von 60 Sekunden. Bei sehr grossen States
|
||||
verwenden Sie den asynchronen Export-Endpoint (Enterprise).
|
||||
</InfoBox>
|
||||
|
||||
<h2>Fehlerbehandlung</h2>
|
||||
<CodeBlock language="typescript" filename="error-handling.ts">
|
||||
{`import { exportState } from '@breakpilot/compliance-sdk'
|
||||
|
||||
try {
|
||||
const blob = await exportState('pdf')
|
||||
downloadExport(blob, 'report.pdf')
|
||||
} catch (error) {
|
||||
if (error.code === 'EMPTY_STATE') {
|
||||
console.error('Keine Daten zum Exportieren vorhanden')
|
||||
} else if (error.code === 'GENERATION_FAILED') {
|
||||
console.error('PDF-Generierung fehlgeschlagen:', error.message)
|
||||
} else if (error.code === 'TIMEOUT') {
|
||||
console.error('Export-Timeout - versuchen Sie ZIP fuer grosse States')
|
||||
} else {
|
||||
console.error('Unbekannter Fehler:', error)
|
||||
}
|
||||
}`}
|
||||
</CodeBlock>
|
||||
</DevPortalLayout>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,381 @@
|
||||
import { DevPortalLayout, ApiEndpoint, CodeBlock, ParameterTable, InfoBox } from '@/components/developers/DevPortalLayout'
|
||||
|
||||
export default function GenerateApiPage() {
|
||||
return (
|
||||
<DevPortalLayout
|
||||
title="Generation API"
|
||||
description="Automatische Generierung von Compliance-Dokumenten"
|
||||
>
|
||||
<h2>Uebersicht</h2>
|
||||
<p>
|
||||
Die Generation API nutzt LLM-Technologie (Claude) zur automatischen Erstellung
|
||||
von Compliance-Dokumenten basierend auf Ihrem SDK-State:
|
||||
</p>
|
||||
<ul>
|
||||
<li><strong>DSFA</strong> - Datenschutz-Folgenabschaetzung</li>
|
||||
<li><strong>TOM</strong> - Technische und Organisatorische Massnahmen</li>
|
||||
<li><strong>VVT</strong> - Verarbeitungsverzeichnis nach Art. 30 DSGVO</li>
|
||||
</ul>
|
||||
|
||||
<InfoBox type="info" title="LLM-Model">
|
||||
Die Generierung verwendet Claude 3.5 Sonnet fuer optimale Qualitaet
|
||||
bei deutschen Rechtstexten. RAG-Context wird automatisch einbezogen.
|
||||
</InfoBox>
|
||||
|
||||
<h2>POST /generate/dsfa</h2>
|
||||
<p>Generiert eine Datenschutz-Folgenabschaetzung basierend auf dem aktuellen State.</p>
|
||||
|
||||
<h3>Request Body</h3>
|
||||
<ParameterTable
|
||||
parameters={[
|
||||
{
|
||||
name: 'tenantId',
|
||||
type: 'string',
|
||||
required: true,
|
||||
description: 'Tenant-ID fuer State-Zugriff',
|
||||
},
|
||||
{
|
||||
name: 'useCaseId',
|
||||
type: 'string',
|
||||
required: false,
|
||||
description: 'Optional: Nur fuer bestimmten Use Case generieren',
|
||||
},
|
||||
{
|
||||
name: 'includeRisks',
|
||||
type: 'boolean',
|
||||
required: false,
|
||||
description: 'Risiken aus Risk Matrix einbeziehen (default: true)',
|
||||
},
|
||||
{
|
||||
name: 'includeControls',
|
||||
type: 'boolean',
|
||||
required: false,
|
||||
description: 'Bestehende Controls referenzieren (default: true)',
|
||||
},
|
||||
{
|
||||
name: 'language',
|
||||
type: 'string',
|
||||
required: false,
|
||||
description: 'Sprache: de, en (default: de)',
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
<h3>Request</h3>
|
||||
<CodeBlock language="bash" filename="cURL">
|
||||
{`curl -X POST "https://api.breakpilot.io/sdk/v1/generate/dsfa" \\
|
||||
-H "Authorization: Bearer YOUR_API_KEY" \\
|
||||
-H "Content-Type: application/json" \\
|
||||
-d '{
|
||||
"tenantId": "your-tenant-id",
|
||||
"useCaseId": "uc-ki-kundenanalyse",
|
||||
"includeRisks": true,
|
||||
"includeControls": true,
|
||||
"language": "de"
|
||||
}'`}
|
||||
</CodeBlock>
|
||||
|
||||
<h3>Response (200 OK)</h3>
|
||||
<CodeBlock language="json" filename="Response">
|
||||
{`{
|
||||
"success": true,
|
||||
"data": {
|
||||
"dsfa": {
|
||||
"id": "dsfa-2026-02-04-abc123",
|
||||
"version": "1.0",
|
||||
"status": "DRAFT",
|
||||
"createdAt": "2026-02-04T12:00:00Z",
|
||||
"useCase": {
|
||||
"id": "uc-ki-kundenanalyse",
|
||||
"name": "KI-gestuetzte Kundenanalyse",
|
||||
"description": "Analyse von Kundenverhalten mittels ML..."
|
||||
},
|
||||
"sections": {
|
||||
"systematicDescription": {
|
||||
"title": "1. Systematische Beschreibung",
|
||||
"content": "Die geplante Verarbeitungstaetigkeit umfasst..."
|
||||
},
|
||||
"necessityAssessment": {
|
||||
"title": "2. Bewertung der Notwendigkeit",
|
||||
"content": "Die Verarbeitung ist notwendig fuer..."
|
||||
},
|
||||
"riskAssessment": {
|
||||
"title": "3. Risikobewertung",
|
||||
"risks": [
|
||||
{
|
||||
"id": "risk-1",
|
||||
"title": "Unbefugter Datenzugriff",
|
||||
"severity": "HIGH",
|
||||
"likelihood": 3,
|
||||
"impact": 4,
|
||||
"description": "...",
|
||||
"mitigations": ["Verschluesselung", "Zugriffskontrolle"]
|
||||
}
|
||||
]
|
||||
},
|
||||
"mitigationMeasures": {
|
||||
"title": "4. Abhilfemassnahmen",
|
||||
"controls": [...]
|
||||
},
|
||||
"stakeholderConsultation": {
|
||||
"title": "5. Einbeziehung Betroffener",
|
||||
"content": "..."
|
||||
},
|
||||
"dpoOpinion": {
|
||||
"title": "6. Stellungnahme des DSB",
|
||||
"content": "Ausstehend - Freigabe erforderlich"
|
||||
}
|
||||
},
|
||||
"conclusion": {
|
||||
"overallRisk": "MEDIUM",
|
||||
"recommendation": "PROCEED_WITH_CONDITIONS",
|
||||
"conditions": [
|
||||
"Implementierung der TOM-Empfehlungen",
|
||||
"Regelmaessige Ueberpruefung"
|
||||
]
|
||||
}
|
||||
},
|
||||
"generationMeta": {
|
||||
"model": "claude-3.5-sonnet",
|
||||
"ragContextUsed": true,
|
||||
"tokensUsed": 4250,
|
||||
"durationMs": 8500
|
||||
}
|
||||
}
|
||||
}`}
|
||||
</CodeBlock>
|
||||
|
||||
<h2>POST /generate/tom</h2>
|
||||
<p>Generiert technische und organisatorische Massnahmen.</p>
|
||||
|
||||
<h3>Request Body</h3>
|
||||
<ParameterTable
|
||||
parameters={[
|
||||
{
|
||||
name: 'tenantId',
|
||||
type: 'string',
|
||||
required: true,
|
||||
description: 'Tenant-ID',
|
||||
},
|
||||
{
|
||||
name: 'categories',
|
||||
type: 'string[]',
|
||||
required: false,
|
||||
description: 'TOM-Kategorien: access_control, encryption, pseudonymization, etc.',
|
||||
},
|
||||
{
|
||||
name: 'basedOnRisks',
|
||||
type: 'boolean',
|
||||
required: false,
|
||||
description: 'TOMs basierend auf Risk Matrix generieren (default: true)',
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
<h3>Request</h3>
|
||||
<CodeBlock language="bash" filename="cURL">
|
||||
{`curl -X POST "https://api.breakpilot.io/sdk/v1/generate/tom" \\
|
||||
-H "Authorization: Bearer YOUR_API_KEY" \\
|
||||
-H "Content-Type: application/json" \\
|
||||
-d '{
|
||||
"tenantId": "your-tenant-id",
|
||||
"categories": ["access_control", "encryption", "backup"],
|
||||
"basedOnRisks": true
|
||||
}'`}
|
||||
</CodeBlock>
|
||||
|
||||
<h3>Response (200 OK)</h3>
|
||||
<CodeBlock language="json" filename="Response">
|
||||
{`{
|
||||
"success": true,
|
||||
"data": {
|
||||
"toms": [
|
||||
{
|
||||
"id": "tom-1",
|
||||
"category": "access_control",
|
||||
"categoryLabel": "Zugangskontrolle",
|
||||
"title": "Multi-Faktor-Authentifizierung",
|
||||
"description": "Implementierung von MFA fuer alle Systemzugaenge",
|
||||
"technicalMeasures": [
|
||||
"TOTP-basierte 2FA",
|
||||
"Hardware Security Keys (FIDO2)"
|
||||
],
|
||||
"organizationalMeasures": [
|
||||
"Schulung der Mitarbeiter",
|
||||
"Dokumentation der Zugaenge"
|
||||
],
|
||||
"article32Reference": "Art. 32 Abs. 1 lit. b DSGVO",
|
||||
"priority": "HIGH",
|
||||
"implementationStatus": "PLANNED"
|
||||
},
|
||||
{
|
||||
"id": "tom-2",
|
||||
"category": "encryption",
|
||||
"categoryLabel": "Verschluesselung",
|
||||
"title": "Transportverschluesselung",
|
||||
"description": "TLS 1.3 fuer alle Datenuebert\\\\ragungen",
|
||||
"technicalMeasures": [
|
||||
"TLS 1.3 mit PFS",
|
||||
"HSTS Header"
|
||||
],
|
||||
"organizationalMeasures": [
|
||||
"Zertifikatsmanagement",
|
||||
"Regelmaessige Audits"
|
||||
],
|
||||
"article32Reference": "Art. 32 Abs. 1 lit. a DSGVO",
|
||||
"priority": "CRITICAL",
|
||||
"implementationStatus": "IMPLEMENTED"
|
||||
}
|
||||
],
|
||||
"summary": {
|
||||
"totalMeasures": 20,
|
||||
"byCategory": {
|
||||
"access_control": 5,
|
||||
"encryption": 4,
|
||||
"backup": 3,
|
||||
"monitoring": 4,
|
||||
"incident_response": 4
|
||||
},
|
||||
"implementationProgress": {
|
||||
"implemented": 12,
|
||||
"in_progress": 5,
|
||||
"planned": 3
|
||||
}
|
||||
}
|
||||
}
|
||||
}`}
|
||||
</CodeBlock>
|
||||
|
||||
<h2>POST /generate/vvt</h2>
|
||||
<p>Generiert ein Verarbeitungsverzeichnis nach Art. 30 DSGVO.</p>
|
||||
|
||||
<h3>Request Body</h3>
|
||||
<ParameterTable
|
||||
parameters={[
|
||||
{
|
||||
name: 'tenantId',
|
||||
type: 'string',
|
||||
required: true,
|
||||
description: 'Tenant-ID',
|
||||
},
|
||||
{
|
||||
name: 'organizationInfo',
|
||||
type: 'object',
|
||||
required: false,
|
||||
description: 'Organisationsdaten (Name, Anschrift, DSB-Kontakt)',
|
||||
},
|
||||
{
|
||||
name: 'includeRetentionPolicies',
|
||||
type: 'boolean',
|
||||
required: false,
|
||||
description: 'Loeschfristen einbeziehen (default: true)',
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
<h3>Request</h3>
|
||||
<CodeBlock language="bash" filename="cURL">
|
||||
{`curl -X POST "https://api.breakpilot.io/sdk/v1/generate/vvt" \\
|
||||
-H "Authorization: Bearer YOUR_API_KEY" \\
|
||||
-H "Content-Type: application/json" \\
|
||||
-d '{
|
||||
"tenantId": "your-tenant-id",
|
||||
"organizationInfo": {
|
||||
"name": "Beispiel GmbH",
|
||||
"address": "Musterstrasse 1, 10115 Berlin",
|
||||
"dpoContact": "datenschutz@beispiel.de"
|
||||
},
|
||||
"includeRetentionPolicies": true
|
||||
}'`}
|
||||
</CodeBlock>
|
||||
|
||||
<h3>Response (200 OK)</h3>
|
||||
<CodeBlock language="json" filename="Response">
|
||||
{`{
|
||||
"success": true,
|
||||
"data": {
|
||||
"vvt": {
|
||||
"id": "vvt-2026-02-04",
|
||||
"version": "1.0",
|
||||
"organization": {
|
||||
"name": "Beispiel GmbH",
|
||||
"address": "Musterstrasse 1, 10115 Berlin",
|
||||
"dpoContact": "datenschutz@beispiel.de"
|
||||
},
|
||||
"processingActivities": [
|
||||
{
|
||||
"id": "pa-1",
|
||||
"name": "Kundendatenverarbeitung",
|
||||
"purpose": "Vertragserfuellung und Kundenservice",
|
||||
"legalBasis": "Art. 6 Abs. 1 lit. b DSGVO",
|
||||
"dataCategories": ["Kontaktdaten", "Vertragsdaten", "Zahlungsdaten"],
|
||||
"dataSubjects": ["Kunden", "Interessenten"],
|
||||
"recipients": ["Zahlungsdienstleister", "Versanddienstleister"],
|
||||
"thirdCountryTransfers": {
|
||||
"exists": false,
|
||||
"countries": [],
|
||||
"safeguards": null
|
||||
},
|
||||
"retentionPeriod": "10 Jahre nach Vertragsende (HGB)",
|
||||
"technicalMeasures": ["Verschluesselung", "Zugriffskontrolle"]
|
||||
}
|
||||
],
|
||||
"lastUpdated": "2026-02-04T12:00:00Z"
|
||||
}
|
||||
}
|
||||
}`}
|
||||
</CodeBlock>
|
||||
|
||||
<h2>SDK Integration</h2>
|
||||
<CodeBlock language="typescript" filename="document-generation.ts">
|
||||
{`import { getSDKBackendClient } from '@breakpilot/compliance-sdk'
|
||||
|
||||
const client = getSDKBackendClient()
|
||||
|
||||
// DSFA generieren
|
||||
async function generateDSFA(useCaseId: string) {
|
||||
const dsfa = await client.generateDSFA({
|
||||
useCaseId,
|
||||
includeRisks: true,
|
||||
includeControls: true,
|
||||
})
|
||||
|
||||
console.log('DSFA generiert:', dsfa.id)
|
||||
console.log('Gesamtrisiko:', dsfa.conclusion.overallRisk)
|
||||
return dsfa
|
||||
}
|
||||
|
||||
// TOMs generieren
|
||||
async function generateTOMs() {
|
||||
const toms = await client.generateTOM({
|
||||
categories: ['access_control', 'encryption'],
|
||||
basedOnRisks: true,
|
||||
})
|
||||
|
||||
console.log(\`\${toms.length} TOMs generiert\`)
|
||||
return toms
|
||||
}
|
||||
|
||||
// VVT generieren
|
||||
async function generateVVT() {
|
||||
const vvt = await client.generateVVT({
|
||||
organizationInfo: {
|
||||
name: 'Beispiel GmbH',
|
||||
address: 'Musterstrasse 1',
|
||||
dpoContact: 'dpo@beispiel.de',
|
||||
},
|
||||
})
|
||||
|
||||
console.log(\`VVT mit \${vvt.processingActivities.length} Verarbeitungen\`)
|
||||
return vvt
|
||||
}`}
|
||||
</CodeBlock>
|
||||
|
||||
<InfoBox type="warning" title="Kosten">
|
||||
Die Dokumentengenerierung verbraucht LLM-Tokens. Durchschnittliche Kosten:
|
||||
DSFA ~5.000 Tokens, TOMs ~3.000 Tokens, VVT ~4.000 Tokens.
|
||||
Enterprise-Kunden haben unbegrenzte Generierungen.
|
||||
</InfoBox>
|
||||
</DevPortalLayout>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,239 @@
|
||||
import Link from 'next/link'
|
||||
import { DevPortalLayout, ApiEndpoint, InfoBox } from '@/components/developers/DevPortalLayout'
|
||||
|
||||
export default function ApiReferencePage() {
|
||||
return (
|
||||
<DevPortalLayout
|
||||
title="API Reference"
|
||||
description="Vollständige REST API Dokumentation"
|
||||
>
|
||||
<h2>Base URL</h2>
|
||||
<p>
|
||||
Alle API-Endpunkte sind unter folgender Basis-URL erreichbar:
|
||||
</p>
|
||||
<div className="bg-gray-100 p-4 rounded-lg font-mono text-sm my-4">
|
||||
https://api.breakpilot.io/sdk/v1
|
||||
</div>
|
||||
<p>
|
||||
Für Self-Hosted-Installationen verwenden Sie Ihre eigene Domain.
|
||||
</p>
|
||||
|
||||
<h2>Authentifizierung</h2>
|
||||
<p>
|
||||
Alle API-Anfragen erfordern einen gültigen API Key im Header:
|
||||
</p>
|
||||
<div className="bg-gray-100 p-4 rounded-lg font-mono text-sm my-4">
|
||||
Authorization: Bearer YOUR_API_KEY
|
||||
</div>
|
||||
|
||||
<InfoBox type="info" title="Tenant-ID">
|
||||
Die Tenant-ID wird aus dem API Key abgeleitet oder kann explizit
|
||||
als Query-Parameter oder im Request-Body mitgegeben werden.
|
||||
</InfoBox>
|
||||
|
||||
<h2>API Endpoints</h2>
|
||||
|
||||
<h3>State Management</h3>
|
||||
<p>
|
||||
Verwalten Sie den SDK-State für Ihren Tenant.
|
||||
</p>
|
||||
|
||||
<ApiEndpoint
|
||||
method="GET"
|
||||
path="/state/{tenantId}"
|
||||
description="Lädt den aktuellen SDK-State für einen Tenant"
|
||||
/>
|
||||
<ApiEndpoint
|
||||
method="POST"
|
||||
path="/state"
|
||||
description="Speichert den SDK-State (mit Versionierung)"
|
||||
/>
|
||||
<ApiEndpoint
|
||||
method="DELETE"
|
||||
path="/state/{tenantId}"
|
||||
description="Löscht den State für einen Tenant"
|
||||
/>
|
||||
|
||||
<p>
|
||||
<Link href="/developers/api/state" className="text-blue-600 hover:underline">
|
||||
→ Vollständige State API Dokumentation
|
||||
</Link>
|
||||
</p>
|
||||
|
||||
<h3>RAG Search</h3>
|
||||
<p>
|
||||
Durchsuchen Sie den Compliance-Korpus (DSGVO, AI Act, NIS2).
|
||||
</p>
|
||||
|
||||
<ApiEndpoint
|
||||
method="GET"
|
||||
path="/rag/search"
|
||||
description="Semantische Suche im Legal Corpus"
|
||||
/>
|
||||
<ApiEndpoint
|
||||
method="GET"
|
||||
path="/rag/status"
|
||||
description="Status des RAG-Systems und Corpus-Informationen"
|
||||
/>
|
||||
|
||||
<p>
|
||||
<Link href="/developers/api/rag" className="text-blue-600 hover:underline">
|
||||
→ Vollständige RAG API Dokumentation
|
||||
</Link>
|
||||
</p>
|
||||
|
||||
<h3>Document Generation</h3>
|
||||
<p>
|
||||
Generieren Sie Compliance-Dokumente automatisch.
|
||||
</p>
|
||||
|
||||
<ApiEndpoint
|
||||
method="POST"
|
||||
path="/generate/dsfa"
|
||||
description="Generiert eine Datenschutz-Folgenabschätzung"
|
||||
/>
|
||||
<ApiEndpoint
|
||||
method="POST"
|
||||
path="/generate/tom"
|
||||
description="Generiert technische und organisatorische Maßnahmen"
|
||||
/>
|
||||
<ApiEndpoint
|
||||
method="POST"
|
||||
path="/generate/vvt"
|
||||
description="Generiert ein Verarbeitungsverzeichnis"
|
||||
/>
|
||||
|
||||
<p>
|
||||
<Link href="/developers/api/generate" className="text-blue-600 hover:underline">
|
||||
→ Vollständige Generation API Dokumentation
|
||||
</Link>
|
||||
</p>
|
||||
|
||||
<h3>Export</h3>
|
||||
<p>
|
||||
Exportieren Sie den Compliance-Stand in verschiedenen Formaten.
|
||||
</p>
|
||||
|
||||
<ApiEndpoint
|
||||
method="GET"
|
||||
path="/export"
|
||||
description="Exportiert den State (JSON, PDF, ZIP)"
|
||||
/>
|
||||
|
||||
<p>
|
||||
<Link href="/developers/api/export" className="text-blue-600 hover:underline">
|
||||
→ Vollständige Export API Dokumentation
|
||||
</Link>
|
||||
</p>
|
||||
|
||||
<h2>Response Format</h2>
|
||||
<p>
|
||||
Alle Responses folgen einem einheitlichen Format:
|
||||
</p>
|
||||
|
||||
<h3>Erfolgreiche Response</h3>
|
||||
<div className="bg-gray-900 text-gray-100 p-4 rounded-lg font-mono text-sm my-4">
|
||||
{`{
|
||||
"success": true,
|
||||
"data": { ... },
|
||||
"meta": {
|
||||
"version": 1,
|
||||
"timestamp": "2026-02-04T12:00:00Z"
|
||||
}
|
||||
}`}
|
||||
</div>
|
||||
|
||||
<h3>Fehler Response</h3>
|
||||
<div className="bg-gray-900 text-gray-100 p-4 rounded-lg font-mono text-sm my-4">
|
||||
{`{
|
||||
"success": false,
|
||||
"error": {
|
||||
"code": "VALIDATION_ERROR",
|
||||
"message": "Tenant ID is required",
|
||||
"details": { ... }
|
||||
}
|
||||
}`}
|
||||
</div>
|
||||
|
||||
<h2>Error Codes</h2>
|
||||
<div className="my-4 overflow-x-auto not-prose">
|
||||
<table className="min-w-full divide-y divide-gray-200">
|
||||
<thead className="bg-gray-50">
|
||||
<tr>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">HTTP Status</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Code</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Beschreibung</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="bg-white divide-y divide-gray-200 text-sm">
|
||||
<tr>
|
||||
<td className="px-4 py-3">400</td>
|
||||
<td className="px-4 py-3 font-mono text-red-600">VALIDATION_ERROR</td>
|
||||
<td className="px-4 py-3 text-gray-600">Ungültige Request-Daten</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="px-4 py-3">401</td>
|
||||
<td className="px-4 py-3 font-mono text-red-600">UNAUTHORIZED</td>
|
||||
<td className="px-4 py-3 text-gray-600">Fehlender oder ungültiger API Key</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="px-4 py-3">403</td>
|
||||
<td className="px-4 py-3 font-mono text-red-600">FORBIDDEN</td>
|
||||
<td className="px-4 py-3 text-gray-600">Keine Berechtigung für diese Ressource</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="px-4 py-3">404</td>
|
||||
<td className="px-4 py-3 font-mono text-red-600">NOT_FOUND</td>
|
||||
<td className="px-4 py-3 text-gray-600">Ressource nicht gefunden</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="px-4 py-3">409</td>
|
||||
<td className="px-4 py-3 font-mono text-red-600">CONFLICT</td>
|
||||
<td className="px-4 py-3 text-gray-600">Versions-Konflikt (Optimistic Locking)</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="px-4 py-3">429</td>
|
||||
<td className="px-4 py-3 font-mono text-red-600">RATE_LIMITED</td>
|
||||
<td className="px-4 py-3 text-gray-600">Zu viele Anfragen</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="px-4 py-3">500</td>
|
||||
<td className="px-4 py-3 font-mono text-red-600">INTERNAL_ERROR</td>
|
||||
<td className="px-4 py-3 text-gray-600">Interner Server-Fehler</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<h2>Rate Limits</h2>
|
||||
<div className="my-4 overflow-x-auto not-prose">
|
||||
<table className="min-w-full divide-y divide-gray-200">
|
||||
<thead className="bg-gray-50">
|
||||
<tr>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Plan</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Requests/Minute</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Requests/Tag</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="bg-white divide-y divide-gray-200 text-sm">
|
||||
<tr>
|
||||
<td className="px-4 py-3">Starter</td>
|
||||
<td className="px-4 py-3">60</td>
|
||||
<td className="px-4 py-3">10.000</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="px-4 py-3">Professional</td>
|
||||
<td className="px-4 py-3">300</td>
|
||||
<td className="px-4 py-3">100.000</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="px-4 py-3">Enterprise</td>
|
||||
<td className="px-4 py-3">Unbegrenzt</td>
|
||||
<td className="px-4 py-3">Unbegrenzt</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</DevPortalLayout>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,248 @@
|
||||
import { DevPortalLayout, ApiEndpoint, CodeBlock, ParameterTable, InfoBox } from '@/components/developers/DevPortalLayout'
|
||||
|
||||
export default function RAGApiPage() {
|
||||
return (
|
||||
<DevPortalLayout
|
||||
title="RAG API"
|
||||
description="Semantische Suche im Legal Corpus (DSGVO, AI Act, NIS2)"
|
||||
>
|
||||
<h2>Uebersicht</h2>
|
||||
<p>
|
||||
Die RAG (Retrieval-Augmented Generation) API ermoeglicht semantische Suche
|
||||
im Compliance-Korpus. Der Korpus enthaelt:
|
||||
</p>
|
||||
<ul>
|
||||
<li>DSGVO (Datenschutz-Grundverordnung)</li>
|
||||
<li>AI Act (EU KI-Verordnung)</li>
|
||||
<li>NIS2 (Netzwerk- und Informationssicherheit)</li>
|
||||
<li>ePrivacy-Verordnung</li>
|
||||
<li>Bundesdatenschutzgesetz (BDSG)</li>
|
||||
</ul>
|
||||
|
||||
<InfoBox type="info" title="Embedding-Modell">
|
||||
Die Suche verwendet BGE-M3 Embeddings fuer praezise semantische Aehnlichkeit.
|
||||
Die Vektoren werden in Qdrant gespeichert.
|
||||
</InfoBox>
|
||||
|
||||
<h2>GET /rag/search</h2>
|
||||
<p>Durchsucht den Legal Corpus semantisch.</p>
|
||||
|
||||
<h3>Query-Parameter</h3>
|
||||
<ParameterTable
|
||||
parameters={[
|
||||
{
|
||||
name: 'q',
|
||||
type: 'string',
|
||||
required: true,
|
||||
description: 'Die Suchanfrage (z.B. "Einwilligung personenbezogene Daten")',
|
||||
},
|
||||
{
|
||||
name: 'top_k',
|
||||
type: 'number',
|
||||
required: false,
|
||||
description: 'Anzahl der Ergebnisse (default: 5, max: 20)',
|
||||
},
|
||||
{
|
||||
name: 'corpus',
|
||||
type: 'string',
|
||||
required: false,
|
||||
description: 'Einschraenkung auf bestimmten Corpus: dsgvo, ai_act, nis2, all (default: all)',
|
||||
},
|
||||
{
|
||||
name: 'min_score',
|
||||
type: 'number',
|
||||
required: false,
|
||||
description: 'Minimaler Relevanz-Score 0-1 (default: 0.5)',
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
<h3>Request</h3>
|
||||
<CodeBlock language="bash" filename="cURL">
|
||||
{`curl -X GET "https://api.breakpilot.io/sdk/v1/rag/search?q=Einwilligung%20DSGVO&top_k=5" \\
|
||||
-H "Authorization: Bearer YOUR_API_KEY"`}
|
||||
</CodeBlock>
|
||||
|
||||
<h3>Response (200 OK)</h3>
|
||||
<CodeBlock language="json" filename="Response">
|
||||
{`{
|
||||
"success": true,
|
||||
"data": {
|
||||
"query": "Einwilligung DSGVO",
|
||||
"results": [
|
||||
{
|
||||
"id": "dsgvo-art-7",
|
||||
"title": "Art. 7 DSGVO - Bedingungen fuer die Einwilligung",
|
||||
"content": "Beruht die Verarbeitung auf einer Einwilligung, muss der Verantwortliche nachweisen koennen, dass die betroffene Person in die Verarbeitung ihrer personenbezogenen Daten eingewilligt hat...",
|
||||
"corpus": "dsgvo",
|
||||
"article": "Art. 7",
|
||||
"score": 0.92,
|
||||
"metadata": {
|
||||
"chapter": "II",
|
||||
"section": "Einwilligung",
|
||||
"url": "https://dsgvo-gesetz.de/art-7-dsgvo/"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "dsgvo-art-6-1-a",
|
||||
"title": "Art. 6 Abs. 1 lit. a DSGVO - Einwilligung als Rechtsgrundlage",
|
||||
"content": "Die Verarbeitung ist nur rechtmaessig, wenn mindestens eine der nachstehenden Bedingungen erfuellt ist: a) Die betroffene Person hat ihre Einwilligung...",
|
||||
"corpus": "dsgvo",
|
||||
"article": "Art. 6",
|
||||
"score": 0.88,
|
||||
"metadata": {
|
||||
"chapter": "II",
|
||||
"section": "Rechtmaessigkeit",
|
||||
"url": "https://dsgvo-gesetz.de/art-6-dsgvo/"
|
||||
}
|
||||
}
|
||||
],
|
||||
"total_results": 2,
|
||||
"search_time_ms": 45
|
||||
},
|
||||
"meta": {
|
||||
"corpus_version": "2026-01",
|
||||
"embedding_model": "bge-m3"
|
||||
}
|
||||
}`}
|
||||
</CodeBlock>
|
||||
|
||||
<h2>GET /rag/status</h2>
|
||||
<p>Gibt Status-Informationen ueber das RAG-System zurueck.</p>
|
||||
|
||||
<h3>Request</h3>
|
||||
<CodeBlock language="bash" filename="cURL">
|
||||
{`curl -X GET "https://api.breakpilot.io/sdk/v1/rag/status" \\
|
||||
-H "Authorization: Bearer YOUR_API_KEY"`}
|
||||
</CodeBlock>
|
||||
|
||||
<h3>Response (200 OK)</h3>
|
||||
<CodeBlock language="json" filename="Response">
|
||||
{`{
|
||||
"success": true,
|
||||
"data": {
|
||||
"status": "healthy",
|
||||
"corpus": {
|
||||
"dsgvo": {
|
||||
"documents": 99,
|
||||
"chunks": 1250,
|
||||
"last_updated": "2026-01-15T00:00:00Z"
|
||||
},
|
||||
"ai_act": {
|
||||
"documents": 89,
|
||||
"chunks": 980,
|
||||
"last_updated": "2026-01-20T00:00:00Z"
|
||||
},
|
||||
"nis2": {
|
||||
"documents": 46,
|
||||
"chunks": 520,
|
||||
"last_updated": "2026-01-10T00:00:00Z"
|
||||
}
|
||||
},
|
||||
"embedding_service": {
|
||||
"status": "online",
|
||||
"model": "bge-m3",
|
||||
"dimension": 1024
|
||||
},
|
||||
"vector_db": {
|
||||
"type": "qdrant",
|
||||
"collections": 3,
|
||||
"total_vectors": 2750
|
||||
}
|
||||
}
|
||||
}`}
|
||||
</CodeBlock>
|
||||
|
||||
<h2>SDK Integration</h2>
|
||||
<p>
|
||||
Verwenden Sie den SDK-Client fuer einfache RAG-Suche:
|
||||
</p>
|
||||
<CodeBlock language="typescript" filename="rag-search.ts">
|
||||
{`import { getSDKBackendClient, isLegalQuery } from '@breakpilot/compliance-sdk'
|
||||
|
||||
const client = getSDKBackendClient()
|
||||
|
||||
// Pruefen ob die Query rechtliche Inhalte betrifft
|
||||
if (isLegalQuery('Was ist eine Einwilligung?')) {
|
||||
// RAG-Suche durchfuehren
|
||||
const results = await client.search('Einwilligung DSGVO', 5)
|
||||
|
||||
results.forEach(result => {
|
||||
console.log(\`[\${result.corpus}] \${result.title}\`)
|
||||
console.log(\`Score: \${result.score}\`)
|
||||
console.log(\`URL: \${result.metadata.url}\`)
|
||||
console.log('---')
|
||||
})
|
||||
}`}
|
||||
</CodeBlock>
|
||||
|
||||
<h2>Keyword-Erkennung</h2>
|
||||
<p>
|
||||
Die Funktion <code>isLegalQuery</code> erkennt automatisch rechtliche Anfragen:
|
||||
</p>
|
||||
<CodeBlock language="typescript" filename="keyword-detection.ts">
|
||||
{`import { isLegalQuery } from '@breakpilot/compliance-sdk'
|
||||
|
||||
// Gibt true zurueck fuer:
|
||||
isLegalQuery('DSGVO Art. 5') // true - Artikel-Referenz
|
||||
isLegalQuery('Einwilligung') // true - DSGVO-Begriff
|
||||
isLegalQuery('AI Act Hochrisiko') // true - AI Act Begriff
|
||||
isLegalQuery('NIS2 Richtlinie') // true - NIS2 Referenz
|
||||
isLegalQuery('personenbezogene Daten') // true - Datenschutz-Begriff
|
||||
|
||||
// Gibt false zurueck fuer:
|
||||
isLegalQuery('Wie ist das Wetter?') // false - Keine rechtliche Anfrage
|
||||
isLegalQuery('Programmiere mir X') // false - Technische Anfrage`}
|
||||
</CodeBlock>
|
||||
|
||||
<h2>Beispiel: Command Bar Integration</h2>
|
||||
<CodeBlock language="typescript" filename="command-bar-rag.tsx">
|
||||
{`import { useState } from 'react'
|
||||
import { getSDKBackendClient, isLegalQuery } from '@breakpilot/compliance-sdk'
|
||||
|
||||
function CommandBarSearch({ query }: { query: string }) {
|
||||
const [results, setResults] = useState([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (query.length > 3 && isLegalQuery(query)) {
|
||||
setLoading(true)
|
||||
const client = getSDKBackendClient()
|
||||
|
||||
client.search(query, 3).then(data => {
|
||||
setResults(data)
|
||||
setLoading(false)
|
||||
})
|
||||
}
|
||||
}, [query])
|
||||
|
||||
if (!isLegalQuery(query)) return null
|
||||
|
||||
return (
|
||||
<div className="rag-results">
|
||||
{loading ? (
|
||||
<p>Suche im Legal Corpus...</p>
|
||||
) : (
|
||||
results.map(result => (
|
||||
<div key={result.id} className="result-card">
|
||||
<h4>{result.title}</h4>
|
||||
<p>{result.content.slice(0, 200)}...</p>
|
||||
<a href={result.metadata.url} target="_blank">
|
||||
Volltext lesen
|
||||
</a>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}`}
|
||||
</CodeBlock>
|
||||
|
||||
<InfoBox type="warning" title="Rate Limits">
|
||||
Die RAG-Suche ist auf 100 Anfragen/Minute (Professional) bzw.
|
||||
unbegrenzt (Enterprise) limitiert. Implementieren Sie Client-Side
|
||||
Debouncing fuer Echtzeit-Suche.
|
||||
</InfoBox>
|
||||
</DevPortalLayout>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,266 @@
|
||||
import { DevPortalLayout, ApiEndpoint, CodeBlock, ParameterTable, InfoBox } from '@/components/developers/DevPortalLayout'
|
||||
|
||||
export default function StateApiPage() {
|
||||
return (
|
||||
<DevPortalLayout
|
||||
title="State API"
|
||||
description="Verwalten Sie den SDK-State für Ihren Tenant"
|
||||
>
|
||||
<h2>Übersicht</h2>
|
||||
<p>
|
||||
Die State API ermöglicht das Speichern und Abrufen des kompletten SDK-States.
|
||||
Der State enthält alle Compliance-Daten: Use Cases, Risiken, Controls,
|
||||
Checkpoints und mehr.
|
||||
</p>
|
||||
|
||||
<InfoBox type="info" title="Versionierung">
|
||||
Der State wird mit optimistischem Locking gespeichert. Bei jedem Speichern
|
||||
wird die Version erhöht. Bei Konflikten erhalten Sie einen 409-Fehler.
|
||||
</InfoBox>
|
||||
|
||||
<h2>GET /state/{'{tenantId}'}</h2>
|
||||
<p>Lädt den aktuellen SDK-State für einen Tenant.</p>
|
||||
|
||||
<h3>Request</h3>
|
||||
<CodeBlock language="bash" filename="cURL">
|
||||
{`curl -X GET "https://api.breakpilot.io/sdk/v1/state/your-tenant-id" \\
|
||||
-H "Authorization: Bearer YOUR_API_KEY"`}
|
||||
</CodeBlock>
|
||||
|
||||
<h3>Response (200 OK)</h3>
|
||||
<CodeBlock language="json" filename="Response">
|
||||
{`{
|
||||
"success": true,
|
||||
"data": {
|
||||
"version": "1.0.0",
|
||||
"lastModified": "2026-02-04T12:00:00Z",
|
||||
"tenantId": "your-tenant-id",
|
||||
"userId": "user-123",
|
||||
"subscription": "PROFESSIONAL",
|
||||
"currentPhase": 1,
|
||||
"currentStep": "use-case-workshop",
|
||||
"completedSteps": ["use-case-workshop", "screening"],
|
||||
"checkpoints": {
|
||||
"CP-UC": {
|
||||
"checkpointId": "CP-UC",
|
||||
"passed": true,
|
||||
"validatedAt": "2026-02-01T10:00:00Z",
|
||||
"validatedBy": "user-123",
|
||||
"errors": [],
|
||||
"warnings": []
|
||||
}
|
||||
},
|
||||
"useCases": [
|
||||
{
|
||||
"id": "uc-1",
|
||||
"name": "KI-Kundenanalyse",
|
||||
"description": "...",
|
||||
"category": "Marketing",
|
||||
"stepsCompleted": 5,
|
||||
"assessmentResult": {
|
||||
"riskLevel": "HIGH",
|
||||
"dsfaRequired": true,
|
||||
"aiActClassification": "LIMITED"
|
||||
}
|
||||
}
|
||||
],
|
||||
"risks": [...],
|
||||
"controls": [...],
|
||||
"dsfa": {...},
|
||||
"toms": [...],
|
||||
"vvt": [...]
|
||||
},
|
||||
"meta": {
|
||||
"version": 5,
|
||||
"etag": "W/\\"abc123\\""
|
||||
}
|
||||
}`}
|
||||
</CodeBlock>
|
||||
|
||||
<h3>Response (404 Not Found)</h3>
|
||||
<CodeBlock language="json" filename="Response">
|
||||
{`{
|
||||
"success": false,
|
||||
"error": {
|
||||
"code": "NOT_FOUND",
|
||||
"message": "No state found for tenant your-tenant-id"
|
||||
}
|
||||
}`}
|
||||
</CodeBlock>
|
||||
|
||||
<h2>POST /state</h2>
|
||||
<p>Speichert den SDK-State. Unterstützt Versionierung und optimistisches Locking.</p>
|
||||
|
||||
<h3>Request Body</h3>
|
||||
<ParameterTable
|
||||
parameters={[
|
||||
{
|
||||
name: 'tenantId',
|
||||
type: 'string',
|
||||
required: true,
|
||||
description: 'Eindeutige Tenant-ID',
|
||||
},
|
||||
{
|
||||
name: 'userId',
|
||||
type: 'string',
|
||||
required: false,
|
||||
description: 'User-ID für Audit-Trail',
|
||||
},
|
||||
{
|
||||
name: 'state',
|
||||
type: 'SDKState',
|
||||
required: true,
|
||||
description: 'Der komplette SDK-State',
|
||||
},
|
||||
{
|
||||
name: 'expectedVersion',
|
||||
type: 'number',
|
||||
required: false,
|
||||
description: 'Erwartete Version für optimistisches Locking',
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
<h3>Request</h3>
|
||||
<CodeBlock language="bash" filename="cURL">
|
||||
{`curl -X POST "https://api.breakpilot.io/sdk/v1/state" \\
|
||||
-H "Authorization: Bearer YOUR_API_KEY" \\
|
||||
-H "Content-Type: application/json" \\
|
||||
-H "If-Match: W/\\"abc123\\"" \\
|
||||
-d '{
|
||||
"tenantId": "your-tenant-id",
|
||||
"userId": "user-123",
|
||||
"state": {
|
||||
"currentPhase": 1,
|
||||
"currentStep": "risks",
|
||||
"useCases": [...],
|
||||
"risks": [...]
|
||||
}
|
||||
}'`}
|
||||
</CodeBlock>
|
||||
|
||||
<h3>Response (200 OK)</h3>
|
||||
<CodeBlock language="json" filename="Response">
|
||||
{`{
|
||||
"success": true,
|
||||
"data": {
|
||||
"tenantId": "your-tenant-id",
|
||||
"version": 6,
|
||||
"updatedAt": "2026-02-04T12:05:00Z"
|
||||
},
|
||||
"meta": {
|
||||
"etag": "W/\\"def456\\""
|
||||
}
|
||||
}`}
|
||||
</CodeBlock>
|
||||
|
||||
<h3>Response (409 Conflict)</h3>
|
||||
<CodeBlock language="json" filename="Response">
|
||||
{`{
|
||||
"success": false,
|
||||
"error": {
|
||||
"code": "CONFLICT",
|
||||
"message": "Version conflict: expected 5, but current is 6",
|
||||
"details": {
|
||||
"expectedVersion": 5,
|
||||
"currentVersion": 6
|
||||
}
|
||||
}
|
||||
}`}
|
||||
</CodeBlock>
|
||||
|
||||
<InfoBox type="warning" title="Konfliktbehandlung">
|
||||
Bei einem 409-Fehler sollten Sie den State erneut laden, Ihre Änderungen
|
||||
mergen und erneut speichern.
|
||||
</InfoBox>
|
||||
|
||||
<h2>DELETE /state/{'{tenantId}'}</h2>
|
||||
<p>Löscht den kompletten State für einen Tenant.</p>
|
||||
|
||||
<h3>Request</h3>
|
||||
<CodeBlock language="bash" filename="cURL">
|
||||
{`curl -X DELETE "https://api.breakpilot.io/sdk/v1/state/your-tenant-id" \\
|
||||
-H "Authorization: Bearer YOUR_API_KEY"`}
|
||||
</CodeBlock>
|
||||
|
||||
<h3>Response (200 OK)</h3>
|
||||
<CodeBlock language="json" filename="Response">
|
||||
{`{
|
||||
"success": true,
|
||||
"data": {
|
||||
"tenantId": "your-tenant-id",
|
||||
"deleted": true
|
||||
}
|
||||
}`}
|
||||
</CodeBlock>
|
||||
|
||||
<h2>State-Struktur</h2>
|
||||
<p>Der SDKState enthält alle Compliance-Daten:</p>
|
||||
|
||||
<CodeBlock language="typescript" filename="types.ts">
|
||||
{`interface SDKState {
|
||||
// Metadata
|
||||
version: string
|
||||
lastModified: Date
|
||||
|
||||
// Tenant & User
|
||||
tenantId: string
|
||||
userId: string
|
||||
subscription: 'FREE' | 'STARTER' | 'PROFESSIONAL' | 'ENTERPRISE'
|
||||
|
||||
// Progress
|
||||
currentPhase: 1 | 2
|
||||
currentStep: string
|
||||
completedSteps: string[]
|
||||
checkpoints: Record<string, CheckpointStatus>
|
||||
|
||||
// Phase 1 Data
|
||||
useCases: UseCaseAssessment[]
|
||||
activeUseCase: string | null
|
||||
screening: ScreeningResult | null
|
||||
modules: ServiceModule[]
|
||||
requirements: Requirement[]
|
||||
controls: Control[]
|
||||
evidence: Evidence[]
|
||||
checklist: ChecklistItem[]
|
||||
risks: Risk[]
|
||||
|
||||
// Phase 2 Data
|
||||
aiActClassification: AIActResult | null
|
||||
obligations: Obligation[]
|
||||
dsfa: DSFA | null
|
||||
toms: TOM[]
|
||||
retentionPolicies: RetentionPolicy[]
|
||||
vvt: ProcessingActivity[]
|
||||
documents: LegalDocument[]
|
||||
cookieBanner: CookieBannerConfig | null
|
||||
consents: ConsentRecord[]
|
||||
dsrConfig: DSRConfig | null
|
||||
escalationWorkflows: EscalationWorkflow[]
|
||||
|
||||
// UI State
|
||||
preferences: UserPreferences
|
||||
}`}
|
||||
</CodeBlock>
|
||||
|
||||
<h2>Beispiel: SDK Integration</h2>
|
||||
<CodeBlock language="typescript" filename="sdk-client.ts">
|
||||
{`import { getSDKApiClient } from '@breakpilot/compliance-sdk'
|
||||
|
||||
const client = getSDKApiClient('your-tenant-id')
|
||||
|
||||
// State laden
|
||||
const state = await client.getState()
|
||||
console.log('Current step:', state.currentStep)
|
||||
console.log('Use cases:', state.useCases.length)
|
||||
|
||||
// State speichern
|
||||
await client.saveState({
|
||||
...state,
|
||||
currentStep: 'risks',
|
||||
risks: [...state.risks, newRisk],
|
||||
})`}
|
||||
</CodeBlock>
|
||||
</DevPortalLayout>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
import { DevPortalLayout, InfoBox } from '@/components/developers/DevPortalLayout'
|
||||
|
||||
export default function ChangelogPage() {
|
||||
return (
|
||||
<DevPortalLayout
|
||||
title="Changelog"
|
||||
description="Versionshistorie und Aenderungen des AI Compliance SDK"
|
||||
>
|
||||
<h2>Versionierung</h2>
|
||||
<p>
|
||||
Das SDK folgt Semantic Versioning (SemVer):
|
||||
<code className="mx-1">MAJOR.MINOR.PATCH</code>
|
||||
</p>
|
||||
<ul>
|
||||
<li><strong>MAJOR:</strong> Breaking Changes</li>
|
||||
<li><strong>MINOR:</strong> Neue Features, abwaertskompatibel</li>
|
||||
<li><strong>PATCH:</strong> Bugfixes</li>
|
||||
</ul>
|
||||
|
||||
{/* Version 1.2.0 */}
|
||||
<div className="mt-8 border-l-4 border-green-500 pl-4">
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
<span className="px-3 py-1 bg-green-100 text-green-800 rounded-full text-sm font-medium">
|
||||
v1.2.0
|
||||
</span>
|
||||
<span className="text-slate-500 text-sm">2026-02-04</span>
|
||||
<span className="px-2 py-0.5 bg-green-500 text-white rounded text-xs">Latest</span>
|
||||
</div>
|
||||
|
||||
<h3 className="text-lg font-semibold text-slate-900 mb-2">Neue Features</h3>
|
||||
<ul className="list-disc list-inside text-slate-700 space-y-1 mb-4">
|
||||
<li>Demo-Daten Seeding ueber API (nicht mehr hardcodiert)</li>
|
||||
<li>Playwright E2E Tests fuer alle 19 SDK-Schritte</li>
|
||||
<li>Command Bar RAG-Integration mit Live-Suche</li>
|
||||
<li>Developer Portal mit API-Dokumentation</li>
|
||||
<li>TOM-Katalog mit 20 vorkonfigurierten Massnahmen</li>
|
||||
<li>VVT-Templates fuer gaengige Verarbeitungstaetigkeiten</li>
|
||||
</ul>
|
||||
|
||||
<h3 className="text-lg font-semibold text-slate-900 mb-2">Verbesserungen</h3>
|
||||
<ul className="list-disc list-inside text-slate-700 space-y-1 mb-4">
|
||||
<li>Performance-Optimierung beim State-Loading</li>
|
||||
<li>Bessere TypeScript-Typen fuer alle Exports</li>
|
||||
<li>Verbesserte Fehlerbehandlung bei API-Calls</li>
|
||||
</ul>
|
||||
|
||||
<h3 className="text-lg font-semibold text-slate-900 mb-2">Bugfixes</h3>
|
||||
<ul className="list-disc list-inside text-slate-700 space-y-1">
|
||||
<li>Fix: Checkpoint-Validierung bei leeren Arrays</li>
|
||||
<li>Fix: Multi-Tab-Sync bei Safari</li>
|
||||
<li>Fix: Export-Dateiname mit Sonderzeichen</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
{/* Version 1.1.0 */}
|
||||
<div className="mt-8 border-l-4 border-blue-500 pl-4">
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
<span className="px-3 py-1 bg-blue-100 text-blue-800 rounded-full text-sm font-medium">
|
||||
v1.1.0
|
||||
</span>
|
||||
<span className="text-slate-500 text-sm">2026-01-20</span>
|
||||
</div>
|
||||
|
||||
<h3 className="text-lg font-semibold text-slate-900 mb-2">Neue Features</h3>
|
||||
<ul className="list-disc list-inside text-slate-700 space-y-1 mb-4">
|
||||
<li>Backend-Sync mit PostgreSQL-Persistierung</li>
|
||||
<li>SDK Backend (Go) mit RAG + LLM-Integration</li>
|
||||
<li>Automatische DSFA-Generierung via Claude API</li>
|
||||
<li>Export nach PDF, ZIP, JSON</li>
|
||||
</ul>
|
||||
|
||||
<h3 className="text-lg font-semibold text-slate-900 mb-2">Verbesserungen</h3>
|
||||
<ul className="list-disc list-inside text-slate-700 space-y-1 mb-4">
|
||||
<li>Offline-Support mit localStorage Fallback</li>
|
||||
<li>Optimistic Locking fuer Konfliktbehandlung</li>
|
||||
<li>BroadcastChannel fuer Multi-Tab-Sync</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
{/* Version 1.0.0 */}
|
||||
<div className="mt-8 border-l-4 border-slate-400 pl-4">
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
<span className="px-3 py-1 bg-slate-100 text-slate-800 rounded-full text-sm font-medium">
|
||||
v1.0.0
|
||||
</span>
|
||||
<span className="text-slate-500 text-sm">2026-01-01</span>
|
||||
</div>
|
||||
|
||||
<h3 className="text-lg font-semibold text-slate-900 mb-2">Initial Release</h3>
|
||||
<ul className="list-disc list-inside text-slate-700 space-y-1 mb-4">
|
||||
<li>SDKProvider mit React Context</li>
|
||||
<li>useSDK Hook mit vollstaendigem State-Zugriff</li>
|
||||
<li>19-Schritte Compliance-Workflow (Phase 1 + 2)</li>
|
||||
<li>Checkpoint-Validierung</li>
|
||||
<li>Risk Matrix mit Score-Berechnung</li>
|
||||
<li>TypeScript-Support mit allen Types</li>
|
||||
<li>Utility Functions fuer Navigation und Berechnung</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
{/* Breaking Changes Notice */}
|
||||
<InfoBox type="warning" title="Upgrade-Hinweise">
|
||||
<p className="mb-2">
|
||||
Bei Major-Version-Updates (z.B. 1.x → 2.x) koennen Breaking Changes auftreten.
|
||||
Pruefen Sie die Migration Guides vor dem Upgrade.
|
||||
</p>
|
||||
<p>
|
||||
Das SDK speichert die State-Version im localStorage. Bei inkompatiblen
|
||||
Aenderungen wird automatisch eine Migration durchgefuehrt.
|
||||
</p>
|
||||
</InfoBox>
|
||||
|
||||
<h2>Geplante Features</h2>
|
||||
<div className="my-4 overflow-x-auto not-prose">
|
||||
<table className="min-w-full divide-y divide-gray-200">
|
||||
<thead className="bg-gray-50">
|
||||
<tr>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Feature</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Version</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Status</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="bg-white divide-y divide-gray-200 text-sm">
|
||||
<tr>
|
||||
<td className="px-4 py-3">Multi-Tenant-Support</td>
|
||||
<td className="px-4 py-3 font-mono">v1.3.0</td>
|
||||
<td className="px-4 py-3"><span className="px-2 py-1 bg-yellow-100 text-yellow-800 rounded text-xs">In Entwicklung</span></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="px-4 py-3">Workflow-Customization</td>
|
||||
<td className="px-4 py-3 font-mono">v1.3.0</td>
|
||||
<td className="px-4 py-3"><span className="px-2 py-1 bg-blue-100 text-blue-800 rounded text-xs">Geplant</span></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="px-4 py-3">Audit-Trail Export</td>
|
||||
<td className="px-4 py-3 font-mono">v1.4.0</td>
|
||||
<td className="px-4 py-3"><span className="px-2 py-1 bg-blue-100 text-blue-800 rounded text-xs">Geplant</span></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="px-4 py-3">White-Label Branding</td>
|
||||
<td className="px-4 py-3 font-mono">v2.0.0</td>
|
||||
<td className="px-4 py-3"><span className="px-2 py-1 bg-slate-100 text-slate-800 rounded text-xs">Roadmap</span></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<h2>Feedback & Issues</h2>
|
||||
<p>
|
||||
Fuer Bug-Reports und Feature-Requests nutzen Sie bitte:
|
||||
</p>
|
||||
<ul>
|
||||
<li>
|
||||
<strong>GitHub Issues:</strong>{' '}
|
||||
<code>github.com/breakpilot/compliance-sdk/issues</code>
|
||||
</li>
|
||||
<li>
|
||||
<strong>Support:</strong>{' '}
|
||||
<code>support@breakpilot.io</code>
|
||||
</li>
|
||||
</ul>
|
||||
</DevPortalLayout>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,203 @@
|
||||
import Link from 'next/link'
|
||||
import { DevPortalLayout, CodeBlock, InfoBox, ParameterTable } from '@/components/developers/DevPortalLayout'
|
||||
|
||||
export default function GettingStartedPage() {
|
||||
return (
|
||||
<DevPortalLayout
|
||||
title="Quick Start"
|
||||
description="Starten Sie in 5 Minuten mit dem AI Compliance SDK"
|
||||
>
|
||||
<h2>1. Installation</h2>
|
||||
<p>
|
||||
Installieren Sie das SDK über Ihren bevorzugten Paketmanager:
|
||||
</p>
|
||||
<CodeBlock language="bash" filename="Terminal">
|
||||
{`npm install @breakpilot/compliance-sdk
|
||||
# oder
|
||||
yarn add @breakpilot/compliance-sdk
|
||||
# oder
|
||||
pnpm add @breakpilot/compliance-sdk`}
|
||||
</CodeBlock>
|
||||
|
||||
<h2>2. API Key erhalten</h2>
|
||||
<p>
|
||||
Nach dem Abo-Abschluss erhalten Sie Ihren API Key im{' '}
|
||||
<Link href="/settings" className="text-blue-600 hover:underline">
|
||||
Einstellungsbereich
|
||||
</Link>.
|
||||
</p>
|
||||
|
||||
<InfoBox type="warning" title="Sicherheitshinweis">
|
||||
Speichern Sie den API Key niemals im Frontend-Code. Verwenden Sie
|
||||
Umgebungsvariablen auf dem Server.
|
||||
</InfoBox>
|
||||
|
||||
<h2>3. Provider einrichten</h2>
|
||||
<p>
|
||||
Wrappen Sie Ihre App mit dem SDKProvider:
|
||||
</p>
|
||||
<CodeBlock language="typescript" filename="app/layout.tsx">
|
||||
{`import { SDKProvider } from '@breakpilot/compliance-sdk'
|
||||
|
||||
export default function RootLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
}) {
|
||||
return (
|
||||
<html lang="de">
|
||||
<body>
|
||||
<SDKProvider
|
||||
tenantId={process.env.TENANT_ID}
|
||||
apiKey={process.env.BREAKPILOT_API_KEY}
|
||||
enableBackendSync={true}
|
||||
>
|
||||
{children}
|
||||
</SDKProvider>
|
||||
</body>
|
||||
</html>
|
||||
)
|
||||
}`}
|
||||
</CodeBlock>
|
||||
|
||||
<h3>Provider Props</h3>
|
||||
<ParameterTable
|
||||
parameters={[
|
||||
{
|
||||
name: 'tenantId',
|
||||
type: 'string',
|
||||
required: true,
|
||||
description: 'Ihre eindeutige Tenant-ID',
|
||||
},
|
||||
{
|
||||
name: 'apiKey',
|
||||
type: 'string',
|
||||
required: false,
|
||||
description: 'API Key für Backend-Sync (serverseitig)',
|
||||
},
|
||||
{
|
||||
name: 'userId',
|
||||
type: 'string',
|
||||
required: false,
|
||||
description: 'Optional: Benutzer-ID für Audit-Trail',
|
||||
},
|
||||
{
|
||||
name: 'enableBackendSync',
|
||||
type: 'boolean',
|
||||
required: false,
|
||||
description: 'Aktiviert Synchronisation mit dem Backend (default: false)',
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
<h2>4. SDK verwenden</h2>
|
||||
<p>
|
||||
Nutzen Sie den useSDK Hook in Ihren Komponenten:
|
||||
</p>
|
||||
<CodeBlock language="typescript" filename="components/Dashboard.tsx">
|
||||
{`'use client'
|
||||
|
||||
import { useSDK } from '@breakpilot/compliance-sdk'
|
||||
|
||||
export function ComplianceDashboard() {
|
||||
const {
|
||||
state,
|
||||
completionPercentage,
|
||||
goToStep,
|
||||
currentStep,
|
||||
} = useSDK()
|
||||
|
||||
return (
|
||||
<div className="p-6">
|
||||
<h1 className="text-2xl font-bold">
|
||||
Compliance Fortschritt: {completionPercentage}%
|
||||
</h1>
|
||||
|
||||
<div className="mt-4">
|
||||
<p>Aktueller Schritt: {currentStep?.name}</p>
|
||||
<p>Phase: {state.currentPhase}</p>
|
||||
<p>Use Cases: {state.useCases.length}</p>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 flex gap-4">
|
||||
<button
|
||||
onClick={() => goToStep('use-case-workshop')}
|
||||
className="px-4 py-2 bg-blue-600 text-white rounded"
|
||||
>
|
||||
Use Case Workshop
|
||||
</button>
|
||||
<button
|
||||
onClick={() => goToStep('risks')}
|
||||
className="px-4 py-2 bg-blue-600 text-white rounded"
|
||||
>
|
||||
Risikoanalyse
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}`}
|
||||
</CodeBlock>
|
||||
|
||||
<h2>5. Erste Schritte im Workflow</h2>
|
||||
<p>
|
||||
Das SDK führt Sie durch einen 19-Schritte-Workflow in 2 Phasen:
|
||||
</p>
|
||||
|
||||
<div className="my-6 not-prose">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="p-4 border border-gray-200 rounded-lg">
|
||||
<h4 className="font-semibold mb-2">Phase 1: Assessment</h4>
|
||||
<ol className="text-sm text-gray-600 space-y-1 list-decimal list-inside">
|
||||
<li>Use Case Workshop</li>
|
||||
<li>System Screening</li>
|
||||
<li>Compliance Modules</li>
|
||||
<li>Requirements</li>
|
||||
<li>Controls</li>
|
||||
<li>Evidence</li>
|
||||
<li>Audit Checklist</li>
|
||||
<li>Risk Matrix</li>
|
||||
</ol>
|
||||
</div>
|
||||
<div className="p-4 border border-gray-200 rounded-lg">
|
||||
<h4 className="font-semibold mb-2">Phase 2: Dokumentation</h4>
|
||||
<ol className="text-sm text-gray-600 space-y-1 list-decimal list-inside">
|
||||
<li>AI Act Klassifizierung</li>
|
||||
<li>Pflichtenübersicht</li>
|
||||
<li>DSFA</li>
|
||||
<li>TOMs</li>
|
||||
<li>Löschfristen</li>
|
||||
<li>VVT</li>
|
||||
<li>Rechtliche Vorlagen</li>
|
||||
<li>Cookie Banner</li>
|
||||
<li>Einwilligungen</li>
|
||||
<li>DSR Portal</li>
|
||||
<li>Escalations</li>
|
||||
</ol>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h2>6. Nächste Schritte</h2>
|
||||
<ul>
|
||||
<li>
|
||||
<Link href="/developers/sdk/configuration" className="text-blue-600 hover:underline">
|
||||
SDK Konfiguration
|
||||
</Link>
|
||||
{' '}- Alle Konfigurationsoptionen
|
||||
</li>
|
||||
<li>
|
||||
<Link href="/developers/api/state" className="text-blue-600 hover:underline">
|
||||
State API
|
||||
</Link>
|
||||
{' '}- Verstehen Sie das State Management
|
||||
</li>
|
||||
<li>
|
||||
<Link href="/developers/guides/phase1" className="text-blue-600 hover:underline">
|
||||
Phase 1 Guide
|
||||
</Link>
|
||||
{' '}- Kompletter Workflow für das Assessment
|
||||
</li>
|
||||
</ul>
|
||||
</DevPortalLayout>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,227 @@
|
||||
import Link from 'next/link'
|
||||
import { DevPortalLayout, InfoBox } from '@/components/developers/DevPortalLayout'
|
||||
|
||||
export default function GuidesPage() {
|
||||
return (
|
||||
<DevPortalLayout
|
||||
title="Entwickler-Guides"
|
||||
description="Schritt-fuer-Schritt Anleitungen fuer die SDK-Integration"
|
||||
>
|
||||
<h2>Workflow-Guides</h2>
|
||||
<p>
|
||||
Das AI Compliance SDK fuehrt durch einen strukturierten 19-Schritte-Workflow
|
||||
in zwei Phasen. Diese Guides erklaeren jeden Schritt im Detail.
|
||||
</p>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6 my-8">
|
||||
<Link
|
||||
href="/developers/guides/phase1"
|
||||
className="block p-6 bg-blue-50 border border-blue-200 rounded-xl hover:border-blue-400 transition-colors"
|
||||
>
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<div className="w-12 h-12 bg-blue-600 text-white rounded-xl flex items-center justify-center text-xl font-bold">
|
||||
1
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-blue-900">Phase 1: Assessment</h3>
|
||||
<p className="text-sm text-blue-600">8 Schritte</p>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-blue-800 text-sm">
|
||||
Use Case Workshop, System Screening, Module-Auswahl, Requirements,
|
||||
Controls, Evidence, Checkliste, Risk Matrix.
|
||||
</p>
|
||||
</Link>
|
||||
|
||||
<Link
|
||||
href="/developers/guides/phase2"
|
||||
className="block p-6 bg-green-50 border border-green-200 rounded-xl hover:border-green-400 transition-colors"
|
||||
>
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<div className="w-12 h-12 bg-green-600 text-white rounded-xl flex items-center justify-center text-xl font-bold">
|
||||
2
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-green-900">Phase 2: Dokumentation</h3>
|
||||
<p className="text-sm text-green-600">11 Schritte</p>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-green-800 text-sm">
|
||||
AI Act Klassifizierung, Pflichten, DSFA, TOMs, Loeschfristen,
|
||||
VVT, Rechtliche Vorlagen, Cookie Banner, DSR Portal.
|
||||
</p>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<h2>Workflow-Uebersicht</h2>
|
||||
<div className="my-6 not-prose">
|
||||
<div className="bg-slate-50 rounded-xl p-6 border border-slate-200">
|
||||
<h4 className="font-semibold mb-4 text-slate-900">Phase 1: Assessment (8 Schritte)</h4>
|
||||
<ol className="grid grid-cols-2 md:grid-cols-4 gap-3 text-sm">
|
||||
<li className="p-3 bg-white rounded-lg border border-slate-200">
|
||||
<span className="text-blue-600 font-mono">01</span>
|
||||
<p className="font-medium">Use Case Workshop</p>
|
||||
</li>
|
||||
<li className="p-3 bg-white rounded-lg border border-slate-200">
|
||||
<span className="text-blue-600 font-mono">02</span>
|
||||
<p className="font-medium">System Screening</p>
|
||||
</li>
|
||||
<li className="p-3 bg-white rounded-lg border border-slate-200">
|
||||
<span className="text-blue-600 font-mono">03</span>
|
||||
<p className="font-medium">Compliance Modules</p>
|
||||
</li>
|
||||
<li className="p-3 bg-white rounded-lg border border-slate-200">
|
||||
<span className="text-blue-600 font-mono">04</span>
|
||||
<p className="font-medium">Requirements</p>
|
||||
</li>
|
||||
<li className="p-3 bg-white rounded-lg border border-slate-200">
|
||||
<span className="text-blue-600 font-mono">05</span>
|
||||
<p className="font-medium">Controls</p>
|
||||
</li>
|
||||
<li className="p-3 bg-white rounded-lg border border-slate-200">
|
||||
<span className="text-blue-600 font-mono">06</span>
|
||||
<p className="font-medium">Evidence</p>
|
||||
</li>
|
||||
<li className="p-3 bg-white rounded-lg border border-slate-200">
|
||||
<span className="text-blue-600 font-mono">07</span>
|
||||
<p className="font-medium">Audit Checklist</p>
|
||||
</li>
|
||||
<li className="p-3 bg-white rounded-lg border border-slate-200">
|
||||
<span className="text-blue-600 font-mono">08</span>
|
||||
<p className="font-medium">Risk Matrix</p>
|
||||
</li>
|
||||
</ol>
|
||||
</div>
|
||||
|
||||
<div className="bg-slate-50 rounded-xl p-6 border border-slate-200 mt-4">
|
||||
<h4 className="font-semibold mb-4 text-slate-900">Phase 2: Dokumentation (11 Schritte)</h4>
|
||||
<ol className="grid grid-cols-2 md:grid-cols-4 gap-3 text-sm">
|
||||
<li className="p-3 bg-white rounded-lg border border-slate-200">
|
||||
<span className="text-green-600 font-mono">09</span>
|
||||
<p className="font-medium">AI Act Klassifizierung</p>
|
||||
</li>
|
||||
<li className="p-3 bg-white rounded-lg border border-slate-200">
|
||||
<span className="text-green-600 font-mono">10</span>
|
||||
<p className="font-medium">Pflichtenuebersicht</p>
|
||||
</li>
|
||||
<li className="p-3 bg-white rounded-lg border border-slate-200">
|
||||
<span className="text-green-600 font-mono">11</span>
|
||||
<p className="font-medium">DSFA</p>
|
||||
</li>
|
||||
<li className="p-3 bg-white rounded-lg border border-slate-200">
|
||||
<span className="text-green-600 font-mono">12</span>
|
||||
<p className="font-medium">TOMs</p>
|
||||
</li>
|
||||
<li className="p-3 bg-white rounded-lg border border-slate-200">
|
||||
<span className="text-green-600 font-mono">13</span>
|
||||
<p className="font-medium">Loeschfristen</p>
|
||||
</li>
|
||||
<li className="p-3 bg-white rounded-lg border border-slate-200">
|
||||
<span className="text-green-600 font-mono">14</span>
|
||||
<p className="font-medium">VVT</p>
|
||||
</li>
|
||||
<li className="p-3 bg-white rounded-lg border border-slate-200">
|
||||
<span className="text-green-600 font-mono">15</span>
|
||||
<p className="font-medium">Rechtliche Vorlagen</p>
|
||||
</li>
|
||||
<li className="p-3 bg-white rounded-lg border border-slate-200">
|
||||
<span className="text-green-600 font-mono">16</span>
|
||||
<p className="font-medium">Cookie Banner</p>
|
||||
</li>
|
||||
<li className="p-3 bg-white rounded-lg border border-slate-200">
|
||||
<span className="text-green-600 font-mono">17</span>
|
||||
<p className="font-medium">Einwilligungen</p>
|
||||
</li>
|
||||
<li className="p-3 bg-white rounded-lg border border-slate-200">
|
||||
<span className="text-green-600 font-mono">18</span>
|
||||
<p className="font-medium">DSR Portal</p>
|
||||
</li>
|
||||
<li className="p-3 bg-white rounded-lg border border-slate-200">
|
||||
<span className="text-green-600 font-mono">19</span>
|
||||
<p className="font-medium">Escalations</p>
|
||||
</li>
|
||||
</ol>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h2>Checkpoints</h2>
|
||||
<p>
|
||||
Das SDK validiert den Fortschritt an definierten Checkpoints:
|
||||
</p>
|
||||
<div className="my-4 overflow-x-auto not-prose">
|
||||
<table className="min-w-full divide-y divide-gray-200">
|
||||
<thead className="bg-gray-50">
|
||||
<tr>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Checkpoint</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Nach Schritt</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Validierung</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="bg-white divide-y divide-gray-200 text-sm">
|
||||
<tr>
|
||||
<td className="px-4 py-3 font-mono text-blue-600">CP-UC</td>
|
||||
<td className="px-4 py-3">Use Case Workshop</td>
|
||||
<td className="px-4 py-3 text-gray-600">Mind. 1 Use Case angelegt</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="px-4 py-3 font-mono text-blue-600">CP-SCREEN</td>
|
||||
<td className="px-4 py-3">System Screening</td>
|
||||
<td className="px-4 py-3 text-gray-600">Screening abgeschlossen</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="px-4 py-3 font-mono text-blue-600">CP-CTRL</td>
|
||||
<td className="px-4 py-3">Controls</td>
|
||||
<td className="px-4 py-3 text-gray-600">Alle Requirements haben Controls</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="px-4 py-3 font-mono text-blue-600">CP-RISK</td>
|
||||
<td className="px-4 py-3">Risk Matrix</td>
|
||||
<td className="px-4 py-3 text-gray-600">Alle Risiken bewertet</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="px-4 py-3 font-mono text-green-600">CP-DSFA</td>
|
||||
<td className="px-4 py-3">DSFA</td>
|
||||
<td className="px-4 py-3 text-gray-600">DSFA generiert (falls erforderlich)</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="px-4 py-3 font-mono text-green-600">CP-TOM</td>
|
||||
<td className="px-4 py-3">TOMs</td>
|
||||
<td className="px-4 py-3 text-gray-600">Mind. 10 TOMs definiert</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="px-4 py-3 font-mono text-green-600">CP-VVT</td>
|
||||
<td className="px-4 py-3">VVT</td>
|
||||
<td className="px-4 py-3 text-gray-600">VVT vollstaendig</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<InfoBox type="info" title="Checkpoint-Navigation">
|
||||
Nicht bestandene Checkpoints blockieren den Fortschritt zu spaetere Schritte.
|
||||
Verwenden Sie <code>validateCheckpoint()</code> um den Status zu pruefen.
|
||||
</InfoBox>
|
||||
|
||||
<h2>Best Practices</h2>
|
||||
<ul>
|
||||
<li>
|
||||
<strong>Speichern Sie regelmaessig:</strong> Der State wird automatisch
|
||||
im localStorage gespeichert, aber aktivieren Sie Backend-Sync fuer
|
||||
persistente Speicherung.
|
||||
</li>
|
||||
<li>
|
||||
<strong>Nutzen Sie die Command Bar:</strong> Cmd+K oeffnet schnelle
|
||||
Navigation, Export und RAG-Suche.
|
||||
</li>
|
||||
<li>
|
||||
<strong>Arbeiten Sie Use-Case-zentriert:</strong> Bearbeiten Sie
|
||||
einen Use Case vollstaendig, bevor Sie zum naechsten wechseln.
|
||||
</li>
|
||||
<li>
|
||||
<strong>Validieren Sie Checkpoints:</strong> Pruefen Sie vor dem
|
||||
Phasenwechsel, ob alle Checkpoints bestanden sind.
|
||||
</li>
|
||||
</ul>
|
||||
</DevPortalLayout>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,391 @@
|
||||
import { DevPortalLayout, CodeBlock, InfoBox } from '@/components/developers/DevPortalLayout'
|
||||
|
||||
export default function Phase1GuidePage() {
|
||||
return (
|
||||
<DevPortalLayout
|
||||
title="Phase 1: Assessment Guide"
|
||||
description="Schritt-fuer-Schritt durch die Assessment-Phase"
|
||||
>
|
||||
<h2>Uebersicht Phase 1</h2>
|
||||
<p>
|
||||
Phase 1 umfasst die Erfassung und Bewertung Ihrer KI-Anwendungsfaelle.
|
||||
Am Ende haben Sie eine vollstaendige Risikoanalyse und wissen, welche
|
||||
Compliance-Dokumente Sie benoetigen.
|
||||
</p>
|
||||
|
||||
<div className="my-6 p-4 bg-blue-50 border border-blue-200 rounded-xl">
|
||||
<h3 className="text-lg font-semibold text-blue-900 mb-2">Phase 1 Schritte</h3>
|
||||
<ol className="list-decimal list-inside text-blue-800 space-y-1">
|
||||
<li>Use Case Workshop</li>
|
||||
<li>System Screening</li>
|
||||
<li>Compliance Modules</li>
|
||||
<li>Requirements</li>
|
||||
<li>Controls</li>
|
||||
<li>Evidence</li>
|
||||
<li>Audit Checklist</li>
|
||||
<li>Risk Matrix</li>
|
||||
</ol>
|
||||
</div>
|
||||
|
||||
<h2>Schritt 1: Use Case Workshop</h2>
|
||||
<p>
|
||||
Erfassen Sie alle KI-Anwendungsfaelle in Ihrem Unternehmen.
|
||||
</p>
|
||||
|
||||
<h3>Code-Beispiel</h3>
|
||||
<CodeBlock language="typescript" filename="use-case-workshop.tsx">
|
||||
{`import { useSDK } from '@breakpilot/compliance-sdk'
|
||||
|
||||
function UseCaseForm() {
|
||||
const { updateUseCase, state } = useSDK()
|
||||
|
||||
const handleCreateUseCase = async () => {
|
||||
await updateUseCase({
|
||||
id: \`uc-\${Date.now()}\`,
|
||||
name: 'KI-gestuetzte Kundenanalyse',
|
||||
description: 'Analyse von Kundenverhalten mittels ML',
|
||||
category: 'Marketing',
|
||||
department: 'Marketing & Sales',
|
||||
dataTypes: ['Kundendaten', 'Verhaltensdaten', 'Transaktionen'],
|
||||
aiCapabilities: ['Profiling', 'Vorhersage'],
|
||||
stepsCompleted: 0,
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h2>Use Cases: {state.useCases.length}</h2>
|
||||
<button onClick={handleCreateUseCase}>
|
||||
Use Case hinzufuegen
|
||||
</button>
|
||||
|
||||
{state.useCases.map(uc => (
|
||||
<div key={uc.id}>
|
||||
<h3>{uc.name}</h3>
|
||||
<p>{uc.description}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}`}
|
||||
</CodeBlock>
|
||||
|
||||
<InfoBox type="info" title="Checkpoint CP-UC">
|
||||
Nach dem Use Case Workshop muss mindestens ein Use Case angelegt sein,
|
||||
um zum naechsten Schritt zu gelangen.
|
||||
</InfoBox>
|
||||
|
||||
<h2>Schritt 2: System Screening</h2>
|
||||
<p>
|
||||
Das Screening bewertet jeden Use Case hinsichtlich Datenschutz und AI Act.
|
||||
</p>
|
||||
|
||||
<h3>Code-Beispiel</h3>
|
||||
<CodeBlock language="typescript" filename="screening.tsx">
|
||||
{`import { useSDK } from '@breakpilot/compliance-sdk'
|
||||
|
||||
function ScreeningView() {
|
||||
const { state, dispatch } = useSDK()
|
||||
|
||||
const completeScreening = (useCaseId: string, result: ScreeningResult) => {
|
||||
dispatch({
|
||||
type: 'UPDATE_USE_CASE',
|
||||
payload: {
|
||||
id: useCaseId,
|
||||
screeningResult: result,
|
||||
// Ergebnis bestimmt weitere Pflichten
|
||||
assessmentResult: {
|
||||
riskLevel: result.aiActRisk,
|
||||
dsfaRequired: result.dsfaRequired,
|
||||
aiActClassification: result.aiActClassification,
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// Screening-Fragen beantworten
|
||||
const screeningQuestions = [
|
||||
'Werden personenbezogene Daten verarbeitet?',
|
||||
'Erfolgt automatisierte Entscheidungsfindung?',
|
||||
'Werden besondere Datenkategorien verarbeitet?',
|
||||
'Erfolgt Profiling?',
|
||||
'Werden Daten in Drittlaender uebermittelt?',
|
||||
]
|
||||
|
||||
return (
|
||||
<div>
|
||||
{screeningQuestions.map((question, i) => (
|
||||
<label key={i} className="block">
|
||||
<input type="checkbox" />
|
||||
{question}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}`}
|
||||
</CodeBlock>
|
||||
|
||||
<h2>Schritt 3: Compliance Modules</h2>
|
||||
<p>
|
||||
Basierend auf dem Screening werden relevante Compliance-Module aktiviert.
|
||||
</p>
|
||||
|
||||
<div className="my-4 overflow-x-auto not-prose">
|
||||
<table className="min-w-full divide-y divide-gray-200">
|
||||
<thead className="bg-gray-50">
|
||||
<tr>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Modul</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Aktiviert wenn</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="bg-white divide-y divide-gray-200 text-sm">
|
||||
<tr>
|
||||
<td className="px-4 py-3 font-medium">DSGVO Basis</td>
|
||||
<td className="px-4 py-3 text-gray-600">Immer (personenbezogene Daten)</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="px-4 py-3 font-medium">DSFA</td>
|
||||
<td className="px-4 py-3 text-gray-600">Hohes Risiko, Profiling, Art. 9 Daten</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="px-4 py-3 font-medium">AI Act</td>
|
||||
<td className="px-4 py-3 text-gray-600">KI-basierte Entscheidungen</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="px-4 py-3 font-medium">NIS2</td>
|
||||
<td className="px-4 py-3 text-gray-600">Kritische Infrastruktur</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<h2>Schritt 4: Requirements</h2>
|
||||
<p>
|
||||
Fuer jedes aktivierte Modul werden spezifische Anforderungen generiert.
|
||||
</p>
|
||||
|
||||
<CodeBlock language="typescript" filename="requirements.tsx">
|
||||
{`import { useSDK } from '@breakpilot/compliance-sdk'
|
||||
|
||||
function RequirementsView() {
|
||||
const { state } = useSDK()
|
||||
|
||||
// Requirements nach Modul gruppieren
|
||||
const byModule = state.requirements.reduce((acc, req) => {
|
||||
const module = req.module || 'general'
|
||||
if (!acc[module]) acc[module] = []
|
||||
acc[module].push(req)
|
||||
return acc
|
||||
}, {})
|
||||
|
||||
return (
|
||||
<div>
|
||||
{Object.entries(byModule).map(([module, reqs]) => (
|
||||
<div key={module}>
|
||||
<h3>{module}</h3>
|
||||
<ul>
|
||||
{reqs.map(req => (
|
||||
<li key={req.id}>
|
||||
<strong>{req.title}</strong>
|
||||
<p>{req.description}</p>
|
||||
<span>Status: {req.status}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}`}
|
||||
</CodeBlock>
|
||||
|
||||
<h2>Schritt 5: Controls</h2>
|
||||
<p>
|
||||
Definieren Sie Kontrollen fuer jede Anforderung.
|
||||
</p>
|
||||
|
||||
<CodeBlock language="typescript" filename="controls.tsx">
|
||||
{`import { useSDK } from '@breakpilot/compliance-sdk'
|
||||
|
||||
function ControlsView() {
|
||||
const { updateControl, state } = useSDK()
|
||||
|
||||
const addControl = (requirementId: string) => {
|
||||
updateControl({
|
||||
id: \`ctrl-\${Date.now()}\`,
|
||||
requirementId,
|
||||
title: 'Zugriffskontrolle implementieren',
|
||||
description: 'Role-based access control fuer alle Datenzugaenge',
|
||||
type: 'TECHNICAL',
|
||||
status: 'PLANNED',
|
||||
implementationDate: null,
|
||||
owner: 'IT-Abteilung',
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h2>Controls: {state.controls.length}</h2>
|
||||
|
||||
{state.requirements.map(req => (
|
||||
<div key={req.id}>
|
||||
<h3>{req.title}</h3>
|
||||
<p>Controls: {state.controls.filter(c => c.requirementId === req.id).length}</p>
|
||||
<button onClick={() => addControl(req.id)}>
|
||||
Control hinzufuegen
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}`}
|
||||
</CodeBlock>
|
||||
|
||||
<InfoBox type="warning" title="Checkpoint CP-CTRL">
|
||||
Jede Requirement muss mindestens ein Control haben, bevor Sie
|
||||
zur Evidence-Phase uebergehen koennen.
|
||||
</InfoBox>
|
||||
|
||||
<h2>Schritt 6: Evidence</h2>
|
||||
<p>
|
||||
Dokumentieren Sie Nachweise fuer implementierte Controls.
|
||||
</p>
|
||||
|
||||
<CodeBlock language="typescript" filename="evidence.tsx">
|
||||
{`import { useSDK } from '@breakpilot/compliance-sdk'
|
||||
|
||||
function EvidenceUpload({ controlId }: { controlId: string }) {
|
||||
const { dispatch } = useSDK()
|
||||
|
||||
const addEvidence = (file: File) => {
|
||||
dispatch({
|
||||
type: 'ADD_EVIDENCE',
|
||||
payload: {
|
||||
id: \`ev-\${Date.now()}\`,
|
||||
controlId,
|
||||
title: file.name,
|
||||
type: 'DOCUMENT',
|
||||
uploadedAt: new Date().toISOString(),
|
||||
fileType: file.type,
|
||||
// In Produktion: Upload zu Storage
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<input
|
||||
type="file"
|
||||
onChange={(e) => e.target.files?.[0] && addEvidence(e.target.files[0])}
|
||||
/>
|
||||
)
|
||||
}`}
|
||||
</CodeBlock>
|
||||
|
||||
<h2>Schritt 7: Audit Checklist</h2>
|
||||
<p>
|
||||
Die Checkliste fasst alle Compliance-Punkte zusammen.
|
||||
</p>
|
||||
|
||||
<h2>Schritt 8: Risk Matrix</h2>
|
||||
<p>
|
||||
Bewerten Sie alle identifizierten Risiken nach Likelihood und Impact.
|
||||
</p>
|
||||
|
||||
<CodeBlock language="typescript" filename="risk-matrix.tsx">
|
||||
{`import { useSDK, calculateRiskScore, getRiskSeverityFromScore } from '@breakpilot/compliance-sdk'
|
||||
|
||||
function RiskMatrix() {
|
||||
const { addRisk, state } = useSDK()
|
||||
|
||||
const createRisk = () => {
|
||||
const likelihood = 3 // 1-5
|
||||
const impact = 4 // 1-5
|
||||
const score = calculateRiskScore(likelihood, impact) // 12
|
||||
const severity = getRiskSeverityFromScore(score) // 'HIGH'
|
||||
|
||||
addRisk({
|
||||
id: \`risk-\${Date.now()}\`,
|
||||
title: 'Unbefugter Datenzugriff',
|
||||
description: 'Risiko durch unzureichende Zugriffskontrolle',
|
||||
likelihood,
|
||||
impact,
|
||||
inherentScore: score,
|
||||
severity,
|
||||
category: 'Security',
|
||||
mitigations: [],
|
||||
residualScore: null,
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h2>Risiken: {state.risks.length}</h2>
|
||||
|
||||
{/* 5x5 Matrix Visualisierung */}
|
||||
<div className="grid grid-cols-5 gap-1">
|
||||
{[5,4,3,2,1].map(likelihood => (
|
||||
[1,2,3,4,5].map(impact => {
|
||||
const score = likelihood * impact
|
||||
const risksHere = state.risks.filter(
|
||||
r => r.likelihood === likelihood && r.impact === impact
|
||||
)
|
||||
return (
|
||||
<div
|
||||
key={\`\${likelihood}-\${impact}\`}
|
||||
className={\`p-2 \${score >= 15 ? 'bg-red-500' : score >= 8 ? 'bg-yellow-500' : 'bg-green-500'}\`}
|
||||
>
|
||||
{risksHere.length > 0 && (
|
||||
<span className="text-white">{risksHere.length}</span>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})
|
||||
))}
|
||||
</div>
|
||||
|
||||
<button onClick={createRisk}>Risiko hinzufuegen</button>
|
||||
</div>
|
||||
)
|
||||
}`}
|
||||
</CodeBlock>
|
||||
|
||||
<InfoBox type="success" title="Phase 1 abgeschlossen">
|
||||
Nach erfolgreicher Bewertung aller Risiken koennen Sie zu Phase 2
|
||||
uebergehen. Der Checkpoint CP-RISK validiert, dass alle Risiken
|
||||
eine Severity-Bewertung haben.
|
||||
</InfoBox>
|
||||
|
||||
<h2>Navigation nach Phase 2</h2>
|
||||
<CodeBlock language="typescript" filename="phase-transition.tsx">
|
||||
{`import { useSDK } from '@breakpilot/compliance-sdk'
|
||||
|
||||
function PhaseTransition() {
|
||||
const { validateCheckpoint, goToStep, phase1Completion } = useSDK()
|
||||
|
||||
const handleContinueToPhase2 = async () => {
|
||||
// Alle Phase-1-Checkpoints pruefen
|
||||
const cpRisk = await validateCheckpoint('CP-RISK')
|
||||
|
||||
if (cpRisk.passed) {
|
||||
goToStep('ai-act-classification') // Erster Schritt Phase 2
|
||||
} else {
|
||||
console.error('Checkpoint nicht bestanden:', cpRisk.errors)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<p>Phase 1 Fortschritt: {phase1Completion}%</p>
|
||||
|
||||
{phase1Completion === 100 && (
|
||||
<button onClick={handleContinueToPhase2}>
|
||||
Weiter zu Phase 2
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}`}
|
||||
</CodeBlock>
|
||||
</DevPortalLayout>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,377 @@
|
||||
import { DevPortalLayout, CodeBlock, InfoBox } from '@/components/developers/DevPortalLayout'
|
||||
|
||||
export default function Phase2GuidePage() {
|
||||
return (
|
||||
<DevPortalLayout
|
||||
title="Phase 2: Dokumentation Guide"
|
||||
description="Schritt-fuer-Schritt durch die Dokumentations-Phase"
|
||||
>
|
||||
<h2>Uebersicht Phase 2</h2>
|
||||
<p>
|
||||
Phase 2 generiert alle erforderlichen Compliance-Dokumente basierend
|
||||
auf dem Assessment aus Phase 1. Die Dokumente koennen exportiert und
|
||||
fuer Audits verwendet werden.
|
||||
</p>
|
||||
|
||||
<div className="my-6 p-4 bg-green-50 border border-green-200 rounded-xl">
|
||||
<h3 className="text-lg font-semibold text-green-900 mb-2">Phase 2 Schritte</h3>
|
||||
<ol className="list-decimal list-inside text-green-800 space-y-1">
|
||||
<li>AI Act Klassifizierung</li>
|
||||
<li>Pflichtenuebersicht</li>
|
||||
<li>DSFA (Datenschutz-Folgenabschaetzung)</li>
|
||||
<li>TOMs (Technische/Organisatorische Massnahmen)</li>
|
||||
<li>Loeschfristen</li>
|
||||
<li>VVT (Verarbeitungsverzeichnis)</li>
|
||||
<li>Rechtliche Vorlagen</li>
|
||||
<li>Cookie Banner</li>
|
||||
<li>Einwilligungen</li>
|
||||
<li>DSR Portal</li>
|
||||
<li>Escalations</li>
|
||||
</ol>
|
||||
</div>
|
||||
|
||||
<h2>Schritt 9: AI Act Klassifizierung</h2>
|
||||
<p>
|
||||
Klassifizieren Sie jeden Use Case nach dem EU AI Act Risikosystem.
|
||||
</p>
|
||||
|
||||
<div className="my-4 overflow-x-auto not-prose">
|
||||
<table className="min-w-full divide-y divide-gray-200">
|
||||
<thead className="bg-gray-50">
|
||||
<tr>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Risikostufe</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Beschreibung</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Pflichten</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="bg-white divide-y divide-gray-200 text-sm">
|
||||
<tr>
|
||||
<td className="px-4 py-3 font-medium text-red-600">Verboten</td>
|
||||
<td className="px-4 py-3 text-gray-600">Social Scoring, Manipulative KI</td>
|
||||
<td className="px-4 py-3 text-gray-600">Nicht zulaessig</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="px-4 py-3 font-medium text-orange-600">Hochrisiko</td>
|
||||
<td className="px-4 py-3 text-gray-600">Biometrie, Medizin, kritische Infrastruktur</td>
|
||||
<td className="px-4 py-3 text-gray-600">Umfangreiche Dokumentation, Konformitaetsbewertung</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="px-4 py-3 font-medium text-yellow-600">Begrenzt</td>
|
||||
<td className="px-4 py-3 text-gray-600">Chatbots, Empfehlungssysteme</td>
|
||||
<td className="px-4 py-3 text-gray-600">Transparenzpflichten</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="px-4 py-3 font-medium text-green-600">Minimal</td>
|
||||
<td className="px-4 py-3 text-gray-600">Spam-Filter, Spiele</td>
|
||||
<td className="px-4 py-3 text-gray-600">Freiwillige Verhaltenskodizes</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<CodeBlock language="typescript" filename="ai-act-classification.tsx">
|
||||
{`import { useSDK } from '@breakpilot/compliance-sdk'
|
||||
import type { AIActRiskCategory } from '@breakpilot/compliance-sdk'
|
||||
|
||||
function AIActClassification() {
|
||||
const { state, dispatch } = useSDK()
|
||||
|
||||
const classifyUseCase = (useCaseId: string, classification: AIActRiskCategory) => {
|
||||
dispatch({
|
||||
type: 'UPDATE_USE_CASE',
|
||||
payload: {
|
||||
id: useCaseId,
|
||||
assessmentResult: {
|
||||
...state.useCases.find(uc => uc.id === useCaseId)?.assessmentResult,
|
||||
aiActClassification: classification,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
// Wenn Hochrisiko, zusaetzliche Pflichten aktivieren
|
||||
if (classification === 'HIGH_RISK') {
|
||||
dispatch({
|
||||
type: 'SET_AI_ACT_RESULT',
|
||||
payload: {
|
||||
classification,
|
||||
conformityRequired: true,
|
||||
documentationRequired: true,
|
||||
humanOversightRequired: true,
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
{state.useCases.map(uc => (
|
||||
<div key={uc.id}>
|
||||
<h3>{uc.name}</h3>
|
||||
<select
|
||||
value={uc.assessmentResult?.aiActClassification || ''}
|
||||
onChange={(e) => classifyUseCase(uc.id, e.target.value as AIActRiskCategory)}
|
||||
>
|
||||
<option value="">Bitte waehlen...</option>
|
||||
<option value="PROHIBITED">Verboten</option>
|
||||
<option value="HIGH_RISK">Hochrisiko</option>
|
||||
<option value="LIMITED">Begrenzt</option>
|
||||
<option value="MINIMAL">Minimal</option>
|
||||
</select>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}`}
|
||||
</CodeBlock>
|
||||
|
||||
<h2>Schritt 10: Pflichtenuebersicht</h2>
|
||||
<p>
|
||||
Basierend auf der Klassifizierung werden alle anwendbaren Pflichten angezeigt.
|
||||
</p>
|
||||
|
||||
<h2>Schritt 11: DSFA</h2>
|
||||
<p>
|
||||
Die Datenschutz-Folgenabschaetzung wird automatisch generiert.
|
||||
</p>
|
||||
|
||||
<CodeBlock language="typescript" filename="dsfa.tsx">
|
||||
{`import { useSDK, getSDKBackendClient } from '@breakpilot/compliance-sdk'
|
||||
|
||||
function DSFAGeneration() {
|
||||
const { state, dispatch } = useSDK()
|
||||
const [generating, setGenerating] = useState(false)
|
||||
|
||||
const generateDSFA = async () => {
|
||||
setGenerating(true)
|
||||
|
||||
const client = getSDKBackendClient()
|
||||
const dsfa = await client.generateDSFA({
|
||||
useCases: state.useCases,
|
||||
risks: state.risks,
|
||||
controls: state.controls,
|
||||
})
|
||||
|
||||
dispatch({
|
||||
type: 'SET_DSFA',
|
||||
payload: dsfa,
|
||||
})
|
||||
|
||||
setGenerating(false)
|
||||
}
|
||||
|
||||
// DSFA nur anzeigen wenn erforderlich
|
||||
const dsfaRequired = state.useCases.some(
|
||||
uc => uc.assessmentResult?.dsfaRequired
|
||||
)
|
||||
|
||||
if (!dsfaRequired) {
|
||||
return <p>Keine DSFA erforderlich fuer die aktuellen Use Cases.</p>
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
{state.dsfa ? (
|
||||
<div>
|
||||
<h3>DSFA generiert</h3>
|
||||
<p>Status: {state.dsfa.status}</p>
|
||||
<p>Gesamtrisiko: {state.dsfa.conclusion?.overallRisk}</p>
|
||||
|
||||
{/* DSFA-Sektionen anzeigen */}
|
||||
{Object.entries(state.dsfa.sections || {}).map(([key, section]) => (
|
||||
<div key={key}>
|
||||
<h4>{section.title}</h4>
|
||||
<p>{section.content}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<button onClick={generateDSFA} disabled={generating}>
|
||||
{generating ? 'Generiere DSFA...' : 'DSFA generieren'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}`}
|
||||
</CodeBlock>
|
||||
|
||||
<InfoBox type="info" title="Checkpoint CP-DSFA">
|
||||
Wenn eine DSFA erforderlich ist (basierend auf Screening), muss diese
|
||||
generiert werden, bevor Sie fortfahren koennen.
|
||||
</InfoBox>
|
||||
|
||||
<h2>Schritt 12: TOMs</h2>
|
||||
<p>
|
||||
Technische und Organisatorische Massnahmen nach Art. 32 DSGVO.
|
||||
</p>
|
||||
|
||||
<CodeBlock language="typescript" filename="toms.tsx">
|
||||
{`import { useSDK, getSDKBackendClient } from '@breakpilot/compliance-sdk'
|
||||
|
||||
function TOMsView() {
|
||||
const { state, dispatch } = useSDK()
|
||||
|
||||
const generateTOMs = async () => {
|
||||
const client = getSDKBackendClient()
|
||||
const toms = await client.generateTOM({
|
||||
risks: state.risks,
|
||||
controls: state.controls,
|
||||
})
|
||||
|
||||
dispatch({
|
||||
type: 'SET_TOMS',
|
||||
payload: toms,
|
||||
})
|
||||
}
|
||||
|
||||
const tomCategories = [
|
||||
{ id: 'access_control', label: 'Zugangskontrolle' },
|
||||
{ id: 'access_rights', label: 'Zugriffskontrolle' },
|
||||
{ id: 'transfer_control', label: 'Weitergabekontrolle' },
|
||||
{ id: 'input_control', label: 'Eingabekontrolle' },
|
||||
{ id: 'availability', label: 'Verfuegbarkeitskontrolle' },
|
||||
{ id: 'separation', label: 'Trennungsgebot' },
|
||||
]
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h2>TOMs: {state.toms.length}</h2>
|
||||
|
||||
{tomCategories.map(cat => {
|
||||
const tomsInCategory = state.toms.filter(t => t.category === cat.id)
|
||||
return (
|
||||
<div key={cat.id}>
|
||||
<h3>{cat.label} ({tomsInCategory.length})</h3>
|
||||
<ul>
|
||||
{tomsInCategory.map(tom => (
|
||||
<li key={tom.id}>
|
||||
<strong>{tom.title}</strong>
|
||||
<p>{tom.description}</p>
|
||||
<span>Status: {tom.implementationStatus}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
|
||||
<button onClick={generateTOMs}>TOMs generieren</button>
|
||||
</div>
|
||||
)
|
||||
}`}
|
||||
</CodeBlock>
|
||||
|
||||
<h2>Schritt 13: Loeschfristen</h2>
|
||||
<p>
|
||||
Definieren Sie Aufbewahrungsfristen fuer verschiedene Datenkategorien.
|
||||
</p>
|
||||
|
||||
<h2>Schritt 14: VVT</h2>
|
||||
<p>
|
||||
Das Verarbeitungsverzeichnis nach Art. 30 DSGVO.
|
||||
</p>
|
||||
|
||||
<CodeBlock language="typescript" filename="vvt.tsx">
|
||||
{`import { useSDK } from '@breakpilot/compliance-sdk'
|
||||
|
||||
function VVTView() {
|
||||
const { state, dispatch } = useSDK()
|
||||
|
||||
const addProcessingActivity = () => {
|
||||
dispatch({
|
||||
type: 'ADD_PROCESSING_ACTIVITY',
|
||||
payload: {
|
||||
id: \`pa-\${Date.now()}\`,
|
||||
name: 'Kundendatenverarbeitung',
|
||||
purpose: 'Vertragserfuellung',
|
||||
legalBasis: 'Art. 6 Abs. 1 lit. b DSGVO',
|
||||
dataCategories: ['Kontaktdaten', 'Vertragsdaten'],
|
||||
dataSubjects: ['Kunden'],
|
||||
recipients: [],
|
||||
retentionPeriod: '10 Jahre',
|
||||
technicalMeasures: ['Verschluesselung', 'Zugriffskontrolle'],
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h2>Verarbeitungstaetigkeiten: {state.vvt.length}</h2>
|
||||
|
||||
{state.vvt.map(activity => (
|
||||
<div key={activity.id} className="border p-4 rounded mb-4">
|
||||
<h3>{activity.name}</h3>
|
||||
<p><strong>Zweck:</strong> {activity.purpose}</p>
|
||||
<p><strong>Rechtsgrundlage:</strong> {activity.legalBasis}</p>
|
||||
<p><strong>Datenkategorien:</strong> {activity.dataCategories.join(', ')}</p>
|
||||
<p><strong>Betroffene:</strong> {activity.dataSubjects.join(', ')}</p>
|
||||
<p><strong>Loeschfrist:</strong> {activity.retentionPeriod}</p>
|
||||
</div>
|
||||
))}
|
||||
|
||||
<button onClick={addProcessingActivity}>
|
||||
Verarbeitungstaetigkeit hinzufuegen
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}`}
|
||||
</CodeBlock>
|
||||
|
||||
<h2>Schritt 15-19: Weitere Dokumentation</h2>
|
||||
<p>
|
||||
Die verbleibenden Schritte umfassen:
|
||||
</p>
|
||||
<ul>
|
||||
<li><strong>Rechtliche Vorlagen:</strong> AGB, Datenschutzerklaerung, etc.</li>
|
||||
<li><strong>Cookie Banner:</strong> Konfiguration fuer Cookie-Consent</li>
|
||||
<li><strong>Einwilligungen:</strong> Consent-Management fuer Betroffene</li>
|
||||
<li><strong>DSR Portal:</strong> Data Subject Request Handling</li>
|
||||
<li><strong>Escalations:</strong> Eskalationspfade fuer Datenschutzvorfaelle</li>
|
||||
</ul>
|
||||
|
||||
<h2>Export der Dokumentation</h2>
|
||||
<CodeBlock language="typescript" filename="export-all.tsx">
|
||||
{`import { useSDK } from '@breakpilot/compliance-sdk'
|
||||
|
||||
function ExportAll() {
|
||||
const { exportState, completionPercentage } = useSDK()
|
||||
|
||||
const handleExport = async (format: 'pdf' | 'zip' | 'json') => {
|
||||
const blob = await exportState(format)
|
||||
|
||||
// Download ausloesen
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = \`compliance-export.\${format === 'json' ? 'json' : format}\`
|
||||
a.click()
|
||||
URL.revokeObjectURL(url)
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h2>Compliance Fortschritt: {completionPercentage}%</h2>
|
||||
|
||||
<div className="flex gap-4 mt-4">
|
||||
<button onClick={() => handleExport('pdf')}>
|
||||
PDF Export
|
||||
</button>
|
||||
<button onClick={() => handleExport('zip')}>
|
||||
ZIP Export (alle Dokumente)
|
||||
</button>
|
||||
<button onClick={() => handleExport('json')}>
|
||||
JSON Backup
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}`}
|
||||
</CodeBlock>
|
||||
|
||||
<InfoBox type="success" title="Workflow abgeschlossen">
|
||||
Nach Abschluss aller 19 Schritte haben Sie eine vollstaendige
|
||||
Compliance-Dokumentation, die Sie fuer Audits und regulatorische
|
||||
Anforderungen verwenden koennen.
|
||||
</InfoBox>
|
||||
</DevPortalLayout>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { DevPortalLayout } from '@/components/developers/DevPortalLayout'
|
||||
|
||||
export default function DevelopersLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
}) {
|
||||
return <>{children}</>
|
||||
}
|
||||
@@ -0,0 +1,188 @@
|
||||
import Link from 'next/link'
|
||||
import { DevPortalLayout, CodeBlock, InfoBox } from '@/components/developers/DevPortalLayout'
|
||||
import { Zap, Code, Terminal, Book, ArrowRight } from 'lucide-react'
|
||||
|
||||
export default function DevelopersPage() {
|
||||
return (
|
||||
<DevPortalLayout
|
||||
title="AI Compliance SDK"
|
||||
description="Integrieren Sie Compliance-Automation in Ihre Anwendung"
|
||||
>
|
||||
{/* Quick Links */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 mb-12 not-prose">
|
||||
<Link
|
||||
href="/developers/getting-started"
|
||||
className="group p-6 border border-gray-200 rounded-xl hover:border-blue-300 hover:shadow-lg transition-all"
|
||||
>
|
||||
<div className="flex items-center gap-3 mb-3">
|
||||
<div className="w-10 h-10 rounded-lg bg-blue-100 flex items-center justify-center text-blue-600">
|
||||
<Zap className="w-5 h-5" />
|
||||
</div>
|
||||
<h3 className="font-semibold text-gray-900">Quick Start</h3>
|
||||
</div>
|
||||
<p className="text-sm text-gray-600 mb-3">
|
||||
Starten Sie in 5 Minuten mit dem AI Compliance SDK
|
||||
</p>
|
||||
<span className="text-sm text-blue-600 group-hover:underline flex items-center gap-1">
|
||||
Jetzt starten <ArrowRight className="w-4 h-4" />
|
||||
</span>
|
||||
</Link>
|
||||
|
||||
<Link
|
||||
href="/developers/api"
|
||||
className="group p-6 border border-gray-200 rounded-xl hover:border-blue-300 hover:shadow-lg transition-all"
|
||||
>
|
||||
<div className="flex items-center gap-3 mb-3">
|
||||
<div className="w-10 h-10 rounded-lg bg-green-100 flex items-center justify-center text-green-600">
|
||||
<Terminal className="w-5 h-5" />
|
||||
</div>
|
||||
<h3 className="font-semibold text-gray-900">API Reference</h3>
|
||||
</div>
|
||||
<p className="text-sm text-gray-600 mb-3">
|
||||
Vollständige API-Dokumentation aller Endpoints
|
||||
</p>
|
||||
<span className="text-sm text-blue-600 group-hover:underline flex items-center gap-1">
|
||||
API erkunden <ArrowRight className="w-4 h-4" />
|
||||
</span>
|
||||
</Link>
|
||||
|
||||
<Link
|
||||
href="/developers/sdk"
|
||||
className="group p-6 border border-gray-200 rounded-xl hover:border-blue-300 hover:shadow-lg transition-all"
|
||||
>
|
||||
<div className="flex items-center gap-3 mb-3">
|
||||
<div className="w-10 h-10 rounded-lg bg-purple-100 flex items-center justify-center text-purple-600">
|
||||
<Code className="w-5 h-5" />
|
||||
</div>
|
||||
<h3 className="font-semibold text-gray-900">SDK Documentation</h3>
|
||||
</div>
|
||||
<p className="text-sm text-gray-600 mb-3">
|
||||
TypeScript SDK für React und Next.js
|
||||
</p>
|
||||
<span className="text-sm text-blue-600 group-hover:underline flex items-center gap-1">
|
||||
Dokumentation lesen <ArrowRight className="w-4 h-4" />
|
||||
</span>
|
||||
</Link>
|
||||
|
||||
<Link
|
||||
href="/developers/guides"
|
||||
className="group p-6 border border-gray-200 rounded-xl hover:border-blue-300 hover:shadow-lg transition-all"
|
||||
>
|
||||
<div className="flex items-center gap-3 mb-3">
|
||||
<div className="w-10 h-10 rounded-lg bg-orange-100 flex items-center justify-center text-orange-600">
|
||||
<Book className="w-5 h-5" />
|
||||
</div>
|
||||
<h3 className="font-semibold text-gray-900">Guides</h3>
|
||||
</div>
|
||||
<p className="text-sm text-gray-600 mb-3">
|
||||
Schritt-für-Schritt-Anleitungen und Best Practices
|
||||
</p>
|
||||
<span className="text-sm text-blue-600 group-hover:underline flex items-center gap-1">
|
||||
Guides ansehen <ArrowRight className="w-4 h-4" />
|
||||
</span>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* Installation */}
|
||||
<h2>Installation</h2>
|
||||
<CodeBlock language="bash" filename="Terminal">
|
||||
{`npm install @breakpilot/compliance-sdk
|
||||
# oder
|
||||
yarn add @breakpilot/compliance-sdk
|
||||
# oder
|
||||
pnpm add @breakpilot/compliance-sdk`}
|
||||
</CodeBlock>
|
||||
|
||||
{/* Quick Example */}
|
||||
<h2>Schnellstart-Beispiel</h2>
|
||||
<CodeBlock language="typescript" filename="app.tsx">
|
||||
{`import { SDKProvider, useSDK } from '@breakpilot/compliance-sdk'
|
||||
|
||||
function App() {
|
||||
return (
|
||||
<SDKProvider
|
||||
tenantId="your-tenant-id"
|
||||
apiKey={process.env.BREAKPILOT_API_KEY}
|
||||
>
|
||||
<ComplianceDashboard />
|
||||
</SDKProvider>
|
||||
)
|
||||
}
|
||||
|
||||
function ComplianceDashboard() {
|
||||
const { state, goToStep, completionPercentage } = useSDK()
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h1>Compliance Status: {completionPercentage}%</h1>
|
||||
<p>Aktueller Schritt: {state.currentStep}</p>
|
||||
<button onClick={() => goToStep('risks')}>
|
||||
Zur Risikoanalyse
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}`}
|
||||
</CodeBlock>
|
||||
|
||||
<InfoBox type="info" title="Voraussetzungen">
|
||||
<ul className="list-disc list-inside space-y-1">
|
||||
<li>Node.js 18 oder höher</li>
|
||||
<li>React 18 oder höher</li>
|
||||
<li>Breakpilot API Key (erhältlich nach Abo-Abschluss)</li>
|
||||
</ul>
|
||||
</InfoBox>
|
||||
|
||||
{/* Features */}
|
||||
<h2>Hauptfunktionen</h2>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 not-prose">
|
||||
<div className="p-4 border border-gray-200 rounded-lg">
|
||||
<h4 className="font-medium text-gray-900 mb-2">19-Schritt-Workflow</h4>
|
||||
<p className="text-sm text-gray-600">
|
||||
Geführter Compliance-Prozess von Use Case bis DSR-Portal
|
||||
</p>
|
||||
</div>
|
||||
<div className="p-4 border border-gray-200 rounded-lg">
|
||||
<h4 className="font-medium text-gray-900 mb-2">RAG-basierte Suche</h4>
|
||||
<p className="text-sm text-gray-600">
|
||||
Durchsuchen Sie DSGVO, AI Act, NIS2 mit semantischer Suche
|
||||
</p>
|
||||
</div>
|
||||
<div className="p-4 border border-gray-200 rounded-lg">
|
||||
<h4 className="font-medium text-gray-900 mb-2">Dokumentengenerierung</h4>
|
||||
<p className="text-sm text-gray-600">
|
||||
Automatische Erstellung von DSFA, TOMs, VVT
|
||||
</p>
|
||||
</div>
|
||||
<div className="p-4 border border-gray-200 rounded-lg">
|
||||
<h4 className="font-medium text-gray-900 mb-2">Export</h4>
|
||||
<p className="text-sm text-gray-600">
|
||||
PDF, JSON, ZIP-Export für Audits und Dokumentation
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Next Steps */}
|
||||
<h2>Nächste Schritte</h2>
|
||||
<ol>
|
||||
<li>
|
||||
<Link href="/developers/getting-started" className="text-blue-600 hover:underline">
|
||||
Quick Start Guide
|
||||
</Link>
|
||||
{' '}- Erste Integration in 5 Minuten
|
||||
</li>
|
||||
<li>
|
||||
<Link href="/developers/api/state" className="text-blue-600 hover:underline">
|
||||
State API
|
||||
</Link>
|
||||
{' '}- Verstehen Sie das State Management
|
||||
</li>
|
||||
<li>
|
||||
<Link href="/developers/guides/phase1" className="text-blue-600 hover:underline">
|
||||
Phase 1 Workflow
|
||||
</Link>
|
||||
{' '}- Durchlaufen Sie den Compliance-Prozess
|
||||
</li>
|
||||
</ol>
|
||||
</DevPortalLayout>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,256 @@
|
||||
import { DevPortalLayout, CodeBlock, InfoBox, ParameterTable } from '@/components/developers/DevPortalLayout'
|
||||
|
||||
export default function SDKConfigurationPage() {
|
||||
return (
|
||||
<DevPortalLayout
|
||||
title="SDK Konfiguration"
|
||||
description="Alle Konfigurationsoptionen des AI Compliance SDK"
|
||||
>
|
||||
<h2>SDKProvider Props</h2>
|
||||
<p>
|
||||
Der SDKProvider akzeptiert folgende Konfigurationsoptionen:
|
||||
</p>
|
||||
<ParameterTable
|
||||
parameters={[
|
||||
{
|
||||
name: 'tenantId',
|
||||
type: 'string',
|
||||
required: true,
|
||||
description: 'Ihre eindeutige Tenant-ID (erhalten nach Abo-Abschluss)',
|
||||
},
|
||||
{
|
||||
name: 'apiKey',
|
||||
type: 'string',
|
||||
required: false,
|
||||
description: 'API Key fuer authentifizierte Anfragen (nur serverseitig verwenden)',
|
||||
},
|
||||
{
|
||||
name: 'userId',
|
||||
type: 'string',
|
||||
required: false,
|
||||
description: 'Benutzer-ID fuer Audit-Trail und Checkpoints',
|
||||
},
|
||||
{
|
||||
name: 'enableBackendSync',
|
||||
type: 'boolean',
|
||||
required: false,
|
||||
description: 'Aktiviert automatische Synchronisation mit dem Backend (default: false)',
|
||||
},
|
||||
{
|
||||
name: 'apiBaseUrl',
|
||||
type: 'string',
|
||||
required: false,
|
||||
description: 'Custom API URL fuer Self-Hosted Installationen',
|
||||
},
|
||||
{
|
||||
name: 'syncInterval',
|
||||
type: 'number',
|
||||
required: false,
|
||||
description: 'Intervall fuer Auto-Sync in Millisekunden (default: 30000)',
|
||||
},
|
||||
{
|
||||
name: 'enableOfflineSupport',
|
||||
type: 'boolean',
|
||||
required: false,
|
||||
description: 'Aktiviert localStorage Fallback bei Offline (default: true)',
|
||||
},
|
||||
{
|
||||
name: 'initialStep',
|
||||
type: 'string',
|
||||
required: false,
|
||||
description: 'Initialer Schritt beim ersten Laden (default: "advisory-board")',
|
||||
},
|
||||
{
|
||||
name: 'onError',
|
||||
type: '(error: Error) => void',
|
||||
required: false,
|
||||
description: 'Callback fuer Fehlerbehandlung',
|
||||
},
|
||||
{
|
||||
name: 'onStateChange',
|
||||
type: '(state: SDKState) => void',
|
||||
required: false,
|
||||
description: 'Callback bei State-Aenderungen',
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
<h2>Vollstaendiges Beispiel</h2>
|
||||
<CodeBlock language="typescript" filename="app/layout.tsx">
|
||||
{`'use client'
|
||||
|
||||
import { SDKProvider } from '@breakpilot/compliance-sdk'
|
||||
import { useRouter } from 'next/navigation'
|
||||
|
||||
export default function SDKLayout({ children }: { children: React.ReactNode }) {
|
||||
const router = useRouter()
|
||||
|
||||
return (
|
||||
<SDKProvider
|
||||
tenantId={process.env.NEXT_PUBLIC_TENANT_ID!}
|
||||
userId="user-123"
|
||||
enableBackendSync={true}
|
||||
syncInterval={60000} // Sync alle 60 Sekunden
|
||||
enableOfflineSupport={true}
|
||||
initialStep="use-case-workshop"
|
||||
onError={(error) => {
|
||||
console.error('SDK Error:', error)
|
||||
// Optional: Sentry oder anderes Error-Tracking
|
||||
}}
|
||||
onStateChange={(state) => {
|
||||
console.log('State changed:', state.currentStep)
|
||||
// Optional: Analytics-Events
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</SDKProvider>
|
||||
)
|
||||
}`}
|
||||
</CodeBlock>
|
||||
|
||||
<h2>Synchronisations-Strategien</h2>
|
||||
|
||||
<h3>1. Nur localStorage (Offline-Only)</h3>
|
||||
<CodeBlock language="typescript" filename="Offline-Only">
|
||||
{`<SDKProvider
|
||||
tenantId="my-tenant"
|
||||
enableBackendSync={false}
|
||||
enableOfflineSupport={true}
|
||||
>
|
||||
{children}
|
||||
</SDKProvider>`}
|
||||
</CodeBlock>
|
||||
<p>
|
||||
Ideal fuer: Lokale Entwicklung, Demos, Privacy-fokussierte Installationen.
|
||||
Daten werden nur im Browser gespeichert.
|
||||
</p>
|
||||
|
||||
<h3>2. Backend-Sync mit Fallback</h3>
|
||||
<CodeBlock language="typescript" filename="Backend + Fallback">
|
||||
{`<SDKProvider
|
||||
tenantId="my-tenant"
|
||||
enableBackendSync={true}
|
||||
enableOfflineSupport={true}
|
||||
syncInterval={30000}
|
||||
>
|
||||
{children}
|
||||
</SDKProvider>`}
|
||||
</CodeBlock>
|
||||
<p>
|
||||
Empfohlen fuer: Produktionsumgebungen. Daten werden mit dem Backend
|
||||
synchronisiert, localStorage dient als Fallback bei Netzwerkproblemen.
|
||||
</p>
|
||||
|
||||
<h3>3. Nur Backend (kein lokaler Cache)</h3>
|
||||
<CodeBlock language="typescript" filename="Backend-Only">
|
||||
{`<SDKProvider
|
||||
tenantId="my-tenant"
|
||||
enableBackendSync={true}
|
||||
enableOfflineSupport={false}
|
||||
>
|
||||
{children}
|
||||
</SDKProvider>`}
|
||||
</CodeBlock>
|
||||
<p>
|
||||
Ideal fuer: Strenge Compliance-Anforderungen, Multi-User-Szenarien.
|
||||
Daten werden nur im Backend gespeichert.
|
||||
</p>
|
||||
|
||||
<InfoBox type="warning" title="Backend-Only Modus">
|
||||
Im Backend-Only Modus ist eine aktive Internetverbindung erforderlich.
|
||||
Bei Netzwerkproblemen koennen Daten verloren gehen.
|
||||
</InfoBox>
|
||||
|
||||
<h2>API URL Konfiguration</h2>
|
||||
|
||||
<h3>Cloud-Version (Standard)</h3>
|
||||
<p>Keine zusaetzliche Konfiguration erforderlich:</p>
|
||||
<CodeBlock language="typescript" filename="Cloud">
|
||||
{`<SDKProvider tenantId="my-tenant">
|
||||
{/* Nutzt automatisch https://api.breakpilot.io/sdk/v1 */}
|
||||
</SDKProvider>`}
|
||||
</CodeBlock>
|
||||
|
||||
<h3>Self-Hosted</h3>
|
||||
<CodeBlock language="typescript" filename="Self-Hosted">
|
||||
{`<SDKProvider
|
||||
tenantId="my-tenant"
|
||||
apiBaseUrl="https://your-server.com/sdk/v1"
|
||||
>
|
||||
{children}
|
||||
</SDKProvider>`}
|
||||
</CodeBlock>
|
||||
|
||||
<h3>Lokale Entwicklung</h3>
|
||||
<CodeBlock language="typescript" filename="Local Dev">
|
||||
{`<SDKProvider
|
||||
tenantId="dev-tenant"
|
||||
apiBaseUrl="http://localhost:8085/sdk/v1"
|
||||
enableBackendSync={true}
|
||||
>
|
||||
{children}
|
||||
</SDKProvider>`}
|
||||
</CodeBlock>
|
||||
|
||||
<h2>Feature Flags</h2>
|
||||
<p>
|
||||
Das SDK unterstuetzt Feature Flags ueber Subscription-Levels:
|
||||
</p>
|
||||
<CodeBlock language="typescript" filename="Feature Checks">
|
||||
{`import { useSDK } from '@breakpilot/compliance-sdk'
|
||||
|
||||
function MyComponent() {
|
||||
const { state } = useSDK()
|
||||
|
||||
// Subscription-basierte Features
|
||||
const isEnterprise = state.subscription === 'ENTERPRISE'
|
||||
const isProfessional = ['PROFESSIONAL', 'ENTERPRISE'].includes(state.subscription)
|
||||
|
||||
// Feature-Gates
|
||||
const canExportPDF = isProfessional
|
||||
const canUseRAG = isProfessional
|
||||
const canCustomizeDSFA = isEnterprise
|
||||
const canUseAPI = isProfessional
|
||||
|
||||
return (
|
||||
<div>
|
||||
{canExportPDF && <button>PDF Export</button>}
|
||||
{canUseRAG && <RAGSearchPanel />}
|
||||
</div>
|
||||
)
|
||||
}`}
|
||||
</CodeBlock>
|
||||
|
||||
<h2>Logging & Debugging</h2>
|
||||
<p>
|
||||
Aktivieren Sie detailliertes Logging fuer die Entwicklung:
|
||||
</p>
|
||||
<CodeBlock language="typescript" filename="Debug Mode">
|
||||
{`// In Ihrer .env.local
|
||||
NEXT_PUBLIC_SDK_DEBUG=true
|
||||
|
||||
// Oder programmatisch
|
||||
<SDKProvider
|
||||
tenantId="my-tenant"
|
||||
onStateChange={(state) => {
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
console.log('[SDK] State Update:', {
|
||||
phase: state.currentPhase,
|
||||
step: state.currentStep,
|
||||
useCases: state.useCases.length,
|
||||
risks: state.risks.length,
|
||||
})
|
||||
}
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</SDKProvider>`}
|
||||
</CodeBlock>
|
||||
|
||||
<InfoBox type="info" title="React DevTools">
|
||||
Der SDK-State ist im React DevTools unter dem SDKProvider-Context sichtbar.
|
||||
Installieren Sie die React Developer Tools Browser-Extension fuer einfaches Debugging.
|
||||
</InfoBox>
|
||||
</DevPortalLayout>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,482 @@
|
||||
'use client'
|
||||
|
||||
import React, { useState } from 'react'
|
||||
import { SDKDocsSidebar } from '@/components/developers/SDKDocsSidebar'
|
||||
import { Copy, Check } from 'lucide-react'
|
||||
|
||||
function CopyButton({ text }: { text: string }) {
|
||||
const [copied, setCopied] = useState(false)
|
||||
|
||||
const handleCopy = async () => {
|
||||
await navigator.clipboard.writeText(text)
|
||||
setCopied(true)
|
||||
setTimeout(() => setCopied(false), 2000)
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={handleCopy}
|
||||
className="p-2 hover:bg-gray-700 rounded transition-colors"
|
||||
title="Kopieren"
|
||||
>
|
||||
{copied ? (
|
||||
<Check className="w-4 h-4 text-green-400" />
|
||||
) : (
|
||||
<Copy className="w-4 h-4 text-gray-400" />
|
||||
)}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
function CodeBlock({ code }: { code: string }) {
|
||||
return (
|
||||
<div className="relative bg-gray-900 rounded-lg overflow-hidden">
|
||||
<div className="absolute top-2 right-2">
|
||||
<CopyButton text={code} />
|
||||
</div>
|
||||
<pre className="p-4 text-sm text-gray-300 font-mono overflow-x-auto">
|
||||
<code>{code}</code>
|
||||
</pre>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function MethodCard({
|
||||
name,
|
||||
signature,
|
||||
description,
|
||||
params,
|
||||
returns,
|
||||
example,
|
||||
}: {
|
||||
name: string
|
||||
signature: string
|
||||
description: string
|
||||
params?: { name: string; type: string; description: string }[]
|
||||
returns?: string
|
||||
example?: string
|
||||
}) {
|
||||
return (
|
||||
<div className="bg-white rounded-xl border border-gray-200 overflow-hidden">
|
||||
<div className="px-6 py-4 border-b border-gray-200 bg-gray-50">
|
||||
<code className="text-violet-600 font-mono font-semibold">{name}</code>
|
||||
</div>
|
||||
<div className="p-6">
|
||||
<div className="bg-gray-100 rounded-lg p-3 mb-4">
|
||||
<code className="text-sm font-mono text-gray-800">{signature}</code>
|
||||
</div>
|
||||
<p className="text-gray-600 mb-4">{description}</p>
|
||||
|
||||
{params && params.length > 0 && (
|
||||
<div className="mb-4">
|
||||
<h4 className="font-medium text-gray-900 mb-2">Parameter</h4>
|
||||
<table className="min-w-full">
|
||||
<tbody className="divide-y divide-gray-200">
|
||||
{params.map((param) => (
|
||||
<tr key={param.name}>
|
||||
<td className="py-2 pr-4">
|
||||
<code className="text-sm text-violet-600">{param.name}</code>
|
||||
</td>
|
||||
<td className="py-2 pr-4">
|
||||
<code className="text-sm text-gray-500">{param.type}</code>
|
||||
</td>
|
||||
<td className="py-2 text-sm text-gray-600">{param.description}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{returns && (
|
||||
<div className="mb-4">
|
||||
<h4 className="font-medium text-gray-900 mb-2">Rueckgabe</h4>
|
||||
<code className="text-sm text-gray-600">{returns}</code>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{example && (
|
||||
<div>
|
||||
<h4 className="font-medium text-gray-900 mb-2">Beispiel</h4>
|
||||
<CodeBlock code={example} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function APIReferencePage() {
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 flex">
|
||||
<SDKDocsSidebar />
|
||||
|
||||
<main className="flex-1 ml-64">
|
||||
<div className="max-w-4xl mx-auto px-8 py-12">
|
||||
<h1 className="text-3xl font-bold text-gray-900 mb-2">API Referenz</h1>
|
||||
<p className="text-lg text-gray-600 mb-8">
|
||||
Vollstaendige Dokumentation aller Methoden und Konfigurationsoptionen des Consent SDK.
|
||||
</p>
|
||||
|
||||
{/* ConsentManager */}
|
||||
<section className="mb-12">
|
||||
<h2 className="text-2xl font-semibold text-gray-900 mb-6">ConsentManager</h2>
|
||||
<p className="text-gray-600 mb-6">
|
||||
Die zentrale Klasse fuer das Consent Management. Verwaltet Einwilligungen, Script-Blocking und Events.
|
||||
</p>
|
||||
|
||||
{/* Constructor */}
|
||||
<div className="space-y-6">
|
||||
<MethodCard
|
||||
name="constructor"
|
||||
signature="new ConsentManager(config: ConsentConfig)"
|
||||
description="Erstellt eine neue Instanz des ConsentManagers mit der angegebenen Konfiguration."
|
||||
params={[
|
||||
{
|
||||
name: 'config',
|
||||
type: 'ConsentConfig',
|
||||
description: 'Konfigurationsobjekt fuer den Manager',
|
||||
},
|
||||
]}
|
||||
example={`const consent = new ConsentManager({
|
||||
apiEndpoint: 'https://api.example.com/consent',
|
||||
siteId: 'my-site',
|
||||
debug: true,
|
||||
});`}
|
||||
/>
|
||||
|
||||
<MethodCard
|
||||
name="init"
|
||||
signature="async init(): Promise<void>"
|
||||
description="Initialisiert das SDK, laedt bestehenden Consent und startet das Script-Blocking. Zeigt den Banner an falls noetig."
|
||||
example={`await consent.init();`}
|
||||
/>
|
||||
|
||||
<MethodCard
|
||||
name="hasConsent"
|
||||
signature="hasConsent(category: ConsentCategory): boolean"
|
||||
description="Prueft ob Einwilligung fuer eine Kategorie vorliegt."
|
||||
params={[
|
||||
{
|
||||
name: 'category',
|
||||
type: 'ConsentCategory',
|
||||
description: 'essential | functional | analytics | marketing | social',
|
||||
},
|
||||
]}
|
||||
returns="boolean - true wenn Einwilligung vorliegt"
|
||||
example={`if (consent.hasConsent('analytics')) {
|
||||
// Analytics laden
|
||||
loadGoogleAnalytics();
|
||||
}`}
|
||||
/>
|
||||
|
||||
<MethodCard
|
||||
name="setConsent"
|
||||
signature="async setConsent(input: ConsentInput): Promise<void>"
|
||||
description="Setzt die Einwilligungen und speichert sie lokal sowie auf dem Server."
|
||||
params={[
|
||||
{
|
||||
name: 'input',
|
||||
type: 'ConsentInput',
|
||||
description: 'Objekt mit Kategorien und optionalen Vendors',
|
||||
},
|
||||
]}
|
||||
example={`await consent.setConsent({
|
||||
essential: true,
|
||||
functional: true,
|
||||
analytics: true,
|
||||
marketing: false,
|
||||
social: false,
|
||||
});`}
|
||||
/>
|
||||
|
||||
<MethodCard
|
||||
name="acceptAll"
|
||||
signature="async acceptAll(): Promise<void>"
|
||||
description="Akzeptiert alle Consent-Kategorien und schliesst den Banner."
|
||||
example={`document.getElementById('accept-all').addEventListener('click', async () => {
|
||||
await consent.acceptAll();
|
||||
});`}
|
||||
/>
|
||||
|
||||
<MethodCard
|
||||
name="rejectAll"
|
||||
signature="async rejectAll(): Promise<void>"
|
||||
description="Lehnt alle nicht-essentiellen Kategorien ab und schliesst den Banner."
|
||||
example={`document.getElementById('reject-all').addEventListener('click', async () => {
|
||||
await consent.rejectAll();
|
||||
});`}
|
||||
/>
|
||||
|
||||
<MethodCard
|
||||
name="revokeAll"
|
||||
signature="async revokeAll(): Promise<void>"
|
||||
description="Widerruft alle Einwilligungen und loescht den gespeicherten Consent."
|
||||
example={`document.getElementById('revoke').addEventListener('click', async () => {
|
||||
await consent.revokeAll();
|
||||
location.reload();
|
||||
});`}
|
||||
/>
|
||||
|
||||
<MethodCard
|
||||
name="on"
|
||||
signature="on<T>(event: ConsentEventType, callback: (data: T) => void): () => void"
|
||||
description="Registriert einen Event-Listener. Gibt eine Unsubscribe-Funktion zurueck."
|
||||
params={[
|
||||
{
|
||||
name: 'event',
|
||||
type: 'ConsentEventType',
|
||||
description: 'init | change | accept_all | reject_all | banner_show | banner_hide | etc.',
|
||||
},
|
||||
{
|
||||
name: 'callback',
|
||||
type: 'function',
|
||||
description: 'Callback-Funktion die bei Event aufgerufen wird',
|
||||
},
|
||||
]}
|
||||
returns="() => void - Funktion zum Entfernen des Listeners"
|
||||
example={`const unsubscribe = consent.on('change', (state) => {
|
||||
console.log('Consent geaendert:', state);
|
||||
});
|
||||
|
||||
// Spaeter: Listener entfernen
|
||||
unsubscribe();`}
|
||||
/>
|
||||
|
||||
<MethodCard
|
||||
name="getConsent"
|
||||
signature="getConsent(): ConsentState | null"
|
||||
description="Gibt den aktuellen Consent-Status zurueck oder null falls kein Consent vorliegt."
|
||||
returns="ConsentState | null"
|
||||
example={`const state = consent.getConsent();
|
||||
if (state) {
|
||||
console.log('Consent ID:', state.consentId);
|
||||
console.log('Kategorien:', state.categories);
|
||||
}`}
|
||||
/>
|
||||
|
||||
<MethodCard
|
||||
name="exportConsent"
|
||||
signature="async exportConsent(): Promise<string>"
|
||||
description="Exportiert alle Consent-Daten als JSON-String (DSGVO Art. 20 Datenportabilitaet)."
|
||||
returns="Promise<string> - JSON-formatierter Export"
|
||||
example={`const exportData = await consent.exportConsent();
|
||||
downloadAsFile(exportData, 'consent-export.json');`}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Configuration */}
|
||||
<section className="mb-12">
|
||||
<h2 className="text-2xl font-semibold text-gray-900 mb-6">Konfiguration</h2>
|
||||
|
||||
<div className="bg-white rounded-xl border border-gray-200 overflow-hidden">
|
||||
<table className="min-w-full divide-y divide-gray-200">
|
||||
<thead className="bg-gray-50">
|
||||
<tr>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">
|
||||
Option
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">
|
||||
Typ
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">
|
||||
Default
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">
|
||||
Beschreibung
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-200">
|
||||
<tr>
|
||||
<td className="px-6 py-4">
|
||||
<code className="text-sm text-violet-600">apiEndpoint</code>
|
||||
</td>
|
||||
<td className="px-6 py-4">
|
||||
<code className="text-sm text-gray-500">string</code>
|
||||
</td>
|
||||
<td className="px-6 py-4 text-sm text-gray-500">erforderlich</td>
|
||||
<td className="px-6 py-4 text-sm text-gray-600">URL des Consent-Backends</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="px-6 py-4">
|
||||
<code className="text-sm text-violet-600">siteId</code>
|
||||
</td>
|
||||
<td className="px-6 py-4">
|
||||
<code className="text-sm text-gray-500">string</code>
|
||||
</td>
|
||||
<td className="px-6 py-4 text-sm text-gray-500">erforderlich</td>
|
||||
<td className="px-6 py-4 text-sm text-gray-600">Eindeutige Site-ID</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="px-6 py-4">
|
||||
<code className="text-sm text-violet-600">debug</code>
|
||||
</td>
|
||||
<td className="px-6 py-4">
|
||||
<code className="text-sm text-gray-500">boolean</code>
|
||||
</td>
|
||||
<td className="px-6 py-4 text-sm text-gray-500">false</td>
|
||||
<td className="px-6 py-4 text-sm text-gray-600">Aktiviert Debug-Logging</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="px-6 py-4">
|
||||
<code className="text-sm text-violet-600">language</code>
|
||||
</td>
|
||||
<td className="px-6 py-4">
|
||||
<code className="text-sm text-gray-500">string</code>
|
||||
</td>
|
||||
<td className="px-6 py-4 text-sm text-gray-500">'de'</td>
|
||||
<td className="px-6 py-4 text-sm text-gray-600">Sprache fuer UI-Texte</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="px-6 py-4">
|
||||
<code className="text-sm text-violet-600">consent.rememberDays</code>
|
||||
</td>
|
||||
<td className="px-6 py-4">
|
||||
<code className="text-sm text-gray-500">number</code>
|
||||
</td>
|
||||
<td className="px-6 py-4 text-sm text-gray-500">365</td>
|
||||
<td className="px-6 py-4 text-sm text-gray-600">Gueltigkeitsdauer in Tagen</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="px-6 py-4">
|
||||
<code className="text-sm text-violet-600">consent.recheckAfterDays</code>
|
||||
</td>
|
||||
<td className="px-6 py-4">
|
||||
<code className="text-sm text-gray-500">number</code>
|
||||
</td>
|
||||
<td className="px-6 py-4 text-sm text-gray-500">180</td>
|
||||
<td className="px-6 py-4 text-sm text-gray-600">Erneute Abfrage nach X Tagen</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Events */}
|
||||
<section className="mb-12">
|
||||
<h2 className="text-2xl font-semibold text-gray-900 mb-6">Events</h2>
|
||||
|
||||
<div className="bg-white rounded-xl border border-gray-200 overflow-hidden">
|
||||
<table className="min-w-full divide-y divide-gray-200">
|
||||
<thead className="bg-gray-50">
|
||||
<tr>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">
|
||||
Event
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">
|
||||
Daten
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">
|
||||
Beschreibung
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-200">
|
||||
<tr>
|
||||
<td className="px-6 py-4">
|
||||
<code className="text-sm text-violet-600">init</code>
|
||||
</td>
|
||||
<td className="px-6 py-4">
|
||||
<code className="text-sm text-gray-500">ConsentState | null</code>
|
||||
</td>
|
||||
<td className="px-6 py-4 text-sm text-gray-600">SDK initialisiert</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="px-6 py-4">
|
||||
<code className="text-sm text-violet-600">change</code>
|
||||
</td>
|
||||
<td className="px-6 py-4">
|
||||
<code className="text-sm text-gray-500">ConsentState</code>
|
||||
</td>
|
||||
<td className="px-6 py-4 text-sm text-gray-600">Consent geaendert</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="px-6 py-4">
|
||||
<code className="text-sm text-violet-600">accept_all</code>
|
||||
</td>
|
||||
<td className="px-6 py-4">
|
||||
<code className="text-sm text-gray-500">ConsentState</code>
|
||||
</td>
|
||||
<td className="px-6 py-4 text-sm text-gray-600">Alle akzeptiert</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="px-6 py-4">
|
||||
<code className="text-sm text-violet-600">reject_all</code>
|
||||
</td>
|
||||
<td className="px-6 py-4">
|
||||
<code className="text-sm text-gray-500">ConsentState</code>
|
||||
</td>
|
||||
<td className="px-6 py-4 text-sm text-gray-600">Alle abgelehnt</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="px-6 py-4">
|
||||
<code className="text-sm text-violet-600">banner_show</code>
|
||||
</td>
|
||||
<td className="px-6 py-4">
|
||||
<code className="text-sm text-gray-500">undefined</code>
|
||||
</td>
|
||||
<td className="px-6 py-4 text-sm text-gray-600">Banner angezeigt</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="px-6 py-4">
|
||||
<code className="text-sm text-violet-600">banner_hide</code>
|
||||
</td>
|
||||
<td className="px-6 py-4">
|
||||
<code className="text-sm text-gray-500">undefined</code>
|
||||
</td>
|
||||
<td className="px-6 py-4 text-sm text-gray-600">Banner versteckt</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="px-6 py-4">
|
||||
<code className="text-sm text-violet-600">error</code>
|
||||
</td>
|
||||
<td className="px-6 py-4">
|
||||
<code className="text-sm text-gray-500">Error</code>
|
||||
</td>
|
||||
<td className="px-6 py-4 text-sm text-gray-600">Fehler aufgetreten</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Types */}
|
||||
<section>
|
||||
<h2 className="text-2xl font-semibold text-gray-900 mb-6">TypeScript Types</h2>
|
||||
<CodeBlock
|
||||
code={`// Consent-Kategorien
|
||||
type ConsentCategory = 'essential' | 'functional' | 'analytics' | 'marketing' | 'social';
|
||||
|
||||
// Consent-Status
|
||||
interface ConsentState {
|
||||
categories: Record<ConsentCategory, boolean>;
|
||||
vendors: Record<string, boolean>;
|
||||
timestamp: string;
|
||||
version: string;
|
||||
consentId?: string;
|
||||
expiresAt?: string;
|
||||
}
|
||||
|
||||
// Konfiguration
|
||||
interface ConsentConfig {
|
||||
apiEndpoint: string;
|
||||
siteId: string;
|
||||
debug?: boolean;
|
||||
language?: string;
|
||||
fallbackLanguage?: string;
|
||||
ui?: ConsentUIConfig;
|
||||
consent?: ConsentBehaviorConfig;
|
||||
onConsentChange?: (state: ConsentState) => void;
|
||||
onBannerShow?: () => void;
|
||||
onBannerHide?: () => void;
|
||||
onError?: (error: Error) => void;
|
||||
}`}
|
||||
/>
|
||||
</section>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,281 @@
|
||||
'use client'
|
||||
|
||||
import React, { useState } from 'react'
|
||||
import { SDKDocsSidebar } from '@/components/developers/SDKDocsSidebar'
|
||||
import { Copy, Check } from 'lucide-react'
|
||||
|
||||
function CopyButton({ text }: { text: string }) {
|
||||
const [copied, setCopied] = useState(false)
|
||||
const handleCopy = async () => {
|
||||
await navigator.clipboard.writeText(text)
|
||||
setCopied(true)
|
||||
setTimeout(() => setCopied(false), 2000)
|
||||
}
|
||||
return (
|
||||
<button onClick={handleCopy} className="p-2 hover:bg-gray-700 rounded transition-colors">
|
||||
{copied ? <Check className="w-4 h-4 text-green-400" /> : <Copy className="w-4 h-4 text-gray-400" />}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
function CodeBlock({ code, filename }: { code: string; filename?: string }) {
|
||||
return (
|
||||
<div className="bg-gray-900 rounded-lg overflow-hidden">
|
||||
{filename && (
|
||||
<div className="px-4 py-2 bg-gray-800 text-gray-400 text-xs border-b border-gray-700">
|
||||
{filename}
|
||||
</div>
|
||||
)}
|
||||
<div className="relative">
|
||||
<div className="absolute top-2 right-2">
|
||||
<CopyButton text={code} />
|
||||
</div>
|
||||
<pre className="p-4 text-sm text-gray-300 font-mono overflow-x-auto">
|
||||
<code>{code}</code>
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function AngularIntegrationPage() {
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 flex">
|
||||
<SDKDocsSidebar />
|
||||
|
||||
<main className="flex-1 ml-64">
|
||||
<div className="max-w-4xl mx-auto px-8 py-12">
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
<div className="w-10 h-10 rounded-lg bg-red-500 flex items-center justify-center">
|
||||
<span className="text-white font-bold">A</span>
|
||||
</div>
|
||||
<h1 className="text-3xl font-bold text-gray-900">Angular Integration</h1>
|
||||
</div>
|
||||
<p className="text-lg text-gray-600 mb-8">
|
||||
Service und Module fuer Angular 14+ Projekte.
|
||||
</p>
|
||||
|
||||
{/* Installation */}
|
||||
<section className="mb-12">
|
||||
<h2 className="text-xl font-semibold text-gray-900 mb-4">Installation</h2>
|
||||
<CodeBlock code="npm install @breakpilot/consent-sdk" />
|
||||
</section>
|
||||
|
||||
{/* Module Setup */}
|
||||
<section className="mb-12">
|
||||
<h2 className="text-xl font-semibold text-gray-900 mb-4">Module Setup</h2>
|
||||
<CodeBlock
|
||||
filename="app.module.ts"
|
||||
code={`import { NgModule } from '@angular/core';
|
||||
import { BrowserModule } from '@angular/platform-browser';
|
||||
import { ConsentModule } from '@breakpilot/consent-sdk/angular';
|
||||
import { environment } from '../environments/environment';
|
||||
|
||||
@NgModule({
|
||||
imports: [
|
||||
BrowserModule,
|
||||
ConsentModule.forRoot({
|
||||
apiEndpoint: environment.consentApi,
|
||||
siteId: 'my-site',
|
||||
debug: !environment.production,
|
||||
}),
|
||||
],
|
||||
})
|
||||
export class AppModule {}`}
|
||||
/>
|
||||
</section>
|
||||
|
||||
{/* Standalone Setup */}
|
||||
<section className="mb-12">
|
||||
<h2 className="text-xl font-semibold text-gray-900 mb-4">Standalone Setup (Angular 15+)</h2>
|
||||
<CodeBlock
|
||||
filename="app.config.ts"
|
||||
code={`import { ApplicationConfig } from '@angular/core';
|
||||
import { provideConsent } from '@breakpilot/consent-sdk/angular';
|
||||
import { environment } from '../environments/environment';
|
||||
|
||||
export const appConfig: ApplicationConfig = {
|
||||
providers: [
|
||||
provideConsent({
|
||||
apiEndpoint: environment.consentApi,
|
||||
siteId: 'my-site',
|
||||
debug: !environment.production,
|
||||
}),
|
||||
],
|
||||
};`}
|
||||
/>
|
||||
</section>
|
||||
|
||||
{/* Service Usage */}
|
||||
<section className="mb-12">
|
||||
<h2 className="text-xl font-semibold text-gray-900 mb-4">Service Usage</h2>
|
||||
<CodeBlock
|
||||
filename="components/analytics.component.ts"
|
||||
code={`import { Component, OnInit } from '@angular/core';
|
||||
import { ConsentService } from '@breakpilot/consent-sdk/angular';
|
||||
|
||||
@Component({
|
||||
selector: 'app-analytics',
|
||||
template: \`
|
||||
<div *ngIf="hasAnalyticsConsent$ | async">
|
||||
<!-- Analytics Code hier -->
|
||||
</div>
|
||||
\`,
|
||||
})
|
||||
export class AnalyticsComponent implements OnInit {
|
||||
hasAnalyticsConsent$ = this.consentService.hasConsent$('analytics');
|
||||
|
||||
constructor(private consentService: ConsentService) {}
|
||||
|
||||
async loadAnalytics() {
|
||||
if (await this.consentService.hasConsent('analytics')) {
|
||||
// Load analytics
|
||||
}
|
||||
}
|
||||
}`}
|
||||
/>
|
||||
</section>
|
||||
|
||||
{/* Cookie Banner */}
|
||||
<section className="mb-12">
|
||||
<h2 className="text-xl font-semibold text-gray-900 mb-4">Cookie Banner Component</h2>
|
||||
<CodeBlock
|
||||
filename="components/cookie-banner.component.ts"
|
||||
code={`import { Component } from '@angular/core';
|
||||
import { ConsentService } from '@breakpilot/consent-sdk/angular';
|
||||
|
||||
@Component({
|
||||
selector: 'app-cookie-banner',
|
||||
template: \`
|
||||
<div
|
||||
*ngIf="isBannerVisible$ | async"
|
||||
class="fixed bottom-0 inset-x-0 bg-white border-t shadow-lg p-4 z-50"
|
||||
>
|
||||
<div class="max-w-4xl mx-auto flex items-center justify-between">
|
||||
<p class="text-sm text-gray-600">
|
||||
Wir verwenden Cookies um Ihr Erlebnis zu verbessern.
|
||||
</p>
|
||||
<div class="flex gap-2">
|
||||
<button (click)="rejectAll()" class="px-4 py-2 border rounded">
|
||||
Ablehnen
|
||||
</button>
|
||||
<button (click)="showSettings()" class="px-4 py-2 border rounded">
|
||||
Einstellungen
|
||||
</button>
|
||||
<button (click)="acceptAll()" class="px-4 py-2 bg-blue-600 text-white rounded">
|
||||
Alle akzeptieren
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
\`,
|
||||
})
|
||||
export class CookieBannerComponent {
|
||||
isBannerVisible$ = this.consentService.isBannerVisible$;
|
||||
|
||||
constructor(private consentService: ConsentService) {}
|
||||
|
||||
async acceptAll() {
|
||||
await this.consentService.acceptAll();
|
||||
}
|
||||
|
||||
async rejectAll() {
|
||||
await this.consentService.rejectAll();
|
||||
}
|
||||
|
||||
showSettings() {
|
||||
this.consentService.showSettings();
|
||||
}
|
||||
}`}
|
||||
/>
|
||||
</section>
|
||||
|
||||
{/* Directive */}
|
||||
<section className="mb-12">
|
||||
<h2 className="text-xl font-semibold text-gray-900 mb-4">ConsentGate Directive</h2>
|
||||
<CodeBlock
|
||||
filename="template.html"
|
||||
code={`<!-- Zeigt Element nur wenn Consent vorhanden -->
|
||||
<iframe
|
||||
*consentGate="'social'"
|
||||
src="https://www.youtube.com/embed/VIDEO_ID"
|
||||
width="560"
|
||||
height="315"
|
||||
></iframe>
|
||||
|
||||
<!-- Mit Custom Fallback -->
|
||||
<div *consentGate="'analytics'; else noConsent">
|
||||
<app-analytics-dashboard></app-analytics-dashboard>
|
||||
</div>
|
||||
<ng-template #noConsent>
|
||||
<div class="bg-gray-100 p-4 rounded-lg text-center">
|
||||
<p>Bitte stimmen Sie Statistik-Cookies zu.</p>
|
||||
<button (click)="showSettings()">Einstellungen</button>
|
||||
</div>
|
||||
</ng-template>`}
|
||||
/>
|
||||
</section>
|
||||
|
||||
{/* Service API */}
|
||||
<section>
|
||||
<h2 className="text-xl font-semibold text-gray-900 mb-4">Service API</h2>
|
||||
<div className="bg-white rounded-xl border border-gray-200 overflow-hidden">
|
||||
<table className="min-w-full divide-y divide-gray-200">
|
||||
<thead className="bg-gray-50">
|
||||
<tr>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">
|
||||
Property/Method
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">
|
||||
Typ
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">
|
||||
Beschreibung
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-200">
|
||||
<tr>
|
||||
<td className="px-6 py-4"><code className="text-violet-600">consent$</code></td>
|
||||
<td className="px-6 py-4"><code className="text-gray-500">Observable<ConsentState></code></td>
|
||||
<td className="px-6 py-4 text-sm text-gray-600">Observable des aktuellen Consent</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="px-6 py-4"><code className="text-violet-600">hasConsent$()</code></td>
|
||||
<td className="px-6 py-4"><code className="text-gray-500">Observable<boolean></code></td>
|
||||
<td className="px-6 py-4 text-sm text-gray-600">Reaktive Consent-Pruefung</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="px-6 py-4"><code className="text-violet-600">hasConsent()</code></td>
|
||||
<td className="px-6 py-4"><code className="text-gray-500">Promise<boolean></code></td>
|
||||
<td className="px-6 py-4 text-sm text-gray-600">Async Consent-Pruefung</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="px-6 py-4"><code className="text-violet-600">isBannerVisible$</code></td>
|
||||
<td className="px-6 py-4"><code className="text-gray-500">Observable<boolean></code></td>
|
||||
<td className="px-6 py-4 text-sm text-gray-600">Banner-Sichtbarkeit</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="px-6 py-4"><code className="text-violet-600">acceptAll()</code></td>
|
||||
<td className="px-6 py-4"><code className="text-gray-500">Promise<void></code></td>
|
||||
<td className="px-6 py-4 text-sm text-gray-600">Akzeptiert alle</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="px-6 py-4"><code className="text-violet-600">rejectAll()</code></td>
|
||||
<td className="px-6 py-4"><code className="text-gray-500">Promise<void></code></td>
|
||||
<td className="px-6 py-4 text-sm text-gray-600">Lehnt alle ab</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="px-6 py-4"><code className="text-violet-600">setConsent()</code></td>
|
||||
<td className="px-6 py-4"><code className="text-gray-500">Promise<void></code></td>
|
||||
<td className="px-6 py-4 text-sm text-gray-600">Setzt spezifische Kategorien</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
'use client'
|
||||
|
||||
import React from 'react'
|
||||
import Link from 'next/link'
|
||||
import { SDKDocsSidebar } from '@/components/developers/SDKDocsSidebar'
|
||||
import { ChevronRight } from 'lucide-react'
|
||||
|
||||
const frameworks = [
|
||||
{
|
||||
name: 'React',
|
||||
href: '/developers/sdk/consent/frameworks/react',
|
||||
logo: '/logos/react.svg',
|
||||
description: 'Hooks und Provider fuer React 17+ und Next.js',
|
||||
features: ['ConsentProvider', 'useConsent Hook', 'ConsentGate Component'],
|
||||
color: 'bg-cyan-500',
|
||||
},
|
||||
{
|
||||
name: 'Vue 3',
|
||||
href: '/developers/sdk/consent/frameworks/vue',
|
||||
logo: '/logos/vue.svg',
|
||||
description: 'Composables und Plugin fuer Vue 3 und Nuxt',
|
||||
features: ['Vue Plugin', 'useConsent Composable', 'ConsentGate Component'],
|
||||
color: 'bg-emerald-500',
|
||||
},
|
||||
{
|
||||
name: 'Angular',
|
||||
href: '/developers/sdk/consent/frameworks/angular',
|
||||
logo: '/logos/angular.svg',
|
||||
description: 'Service und Module fuer Angular 14+',
|
||||
features: ['ConsentService', 'ConsentModule', 'Dependency Injection'],
|
||||
color: 'bg-red-500',
|
||||
},
|
||||
]
|
||||
|
||||
export default function FrameworksPage() {
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 flex">
|
||||
<SDKDocsSidebar />
|
||||
|
||||
<main className="flex-1 ml-64">
|
||||
<div className="max-w-4xl mx-auto px-8 py-12">
|
||||
<h1 className="text-3xl font-bold text-gray-900 mb-2">Framework Integration</h1>
|
||||
<p className="text-lg text-gray-600 mb-8">
|
||||
Das Consent SDK bietet native Integrationen fuer alle gaengigen Frontend-Frameworks.
|
||||
</p>
|
||||
|
||||
<div className="space-y-4">
|
||||
{frameworks.map((framework) => (
|
||||
<Link
|
||||
key={framework.name}
|
||||
href={framework.href}
|
||||
className="block bg-white rounded-xl border border-gray-200 p-6 hover:border-violet-300 hover:shadow-md transition-all group"
|
||||
>
|
||||
<div className="flex items-start gap-4">
|
||||
<div className={`w-12 h-12 rounded-xl ${framework.color} flex items-center justify-center shrink-0`}>
|
||||
<span className="text-white font-bold text-lg">{framework.name[0]}</span>
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<h2 className="text-xl font-semibold text-gray-900 group-hover:text-violet-600 transition-colors">
|
||||
{framework.name}
|
||||
</h2>
|
||||
<ChevronRight className="w-5 h-5 text-gray-400 group-hover:text-violet-600 transition-colors" />
|
||||
</div>
|
||||
<p className="text-gray-600 mt-1">{framework.description}</p>
|
||||
<div className="flex flex-wrap gap-2 mt-3">
|
||||
{framework.features.map((feature) => (
|
||||
<span
|
||||
key={feature}
|
||||
className="px-2 py-1 bg-gray-100 text-gray-600 text-xs rounded-md"
|
||||
>
|
||||
{feature}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Vanilla JS Note */}
|
||||
<div className="mt-8 p-4 bg-blue-50 border border-blue-200 rounded-xl">
|
||||
<h3 className="font-medium text-blue-900">Vanilla JavaScript</h3>
|
||||
<p className="text-sm text-blue-700 mt-1">
|
||||
Sie koennen das SDK auch ohne Framework verwenden. Importieren Sie einfach den ConsentManager direkt
|
||||
aus dem Hauptpaket. Siehe{' '}
|
||||
<Link href="/developers/sdk/consent/installation" className="underline">
|
||||
Installation
|
||||
</Link>{' '}
|
||||
fuer Details.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,277 @@
|
||||
'use client'
|
||||
|
||||
import React, { useState } from 'react'
|
||||
import { SDKDocsSidebar } from '@/components/developers/SDKDocsSidebar'
|
||||
import { Copy, Check } from 'lucide-react'
|
||||
|
||||
function CopyButton({ text }: { text: string }) {
|
||||
const [copied, setCopied] = useState(false)
|
||||
const handleCopy = async () => {
|
||||
await navigator.clipboard.writeText(text)
|
||||
setCopied(true)
|
||||
setTimeout(() => setCopied(false), 2000)
|
||||
}
|
||||
return (
|
||||
<button onClick={handleCopy} className="p-2 hover:bg-gray-700 rounded transition-colors">
|
||||
{copied ? <Check className="w-4 h-4 text-green-400" /> : <Copy className="w-4 h-4 text-gray-400" />}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
function CodeBlock({ code, filename }: { code: string; filename?: string }) {
|
||||
return (
|
||||
<div className="bg-gray-900 rounded-lg overflow-hidden">
|
||||
{filename && (
|
||||
<div className="px-4 py-2 bg-gray-800 text-gray-400 text-xs border-b border-gray-700">
|
||||
{filename}
|
||||
</div>
|
||||
)}
|
||||
<div className="relative">
|
||||
<div className="absolute top-2 right-2">
|
||||
<CopyButton text={code} />
|
||||
</div>
|
||||
<pre className="p-4 text-sm text-gray-300 font-mono overflow-x-auto">
|
||||
<code>{code}</code>
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function ReactIntegrationPage() {
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 flex">
|
||||
<SDKDocsSidebar />
|
||||
|
||||
<main className="flex-1 ml-64">
|
||||
<div className="max-w-4xl mx-auto px-8 py-12">
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
<div className="w-10 h-10 rounded-lg bg-cyan-500 flex items-center justify-center">
|
||||
<span className="text-white font-bold">R</span>
|
||||
</div>
|
||||
<h1 className="text-3xl font-bold text-gray-900">React Integration</h1>
|
||||
</div>
|
||||
<p className="text-lg text-gray-600 mb-8">
|
||||
Hooks und Provider fuer React 17+ und Next.js Projekte.
|
||||
</p>
|
||||
|
||||
{/* Installation */}
|
||||
<section className="mb-12">
|
||||
<h2 className="text-xl font-semibold text-gray-900 mb-4">Installation</h2>
|
||||
<CodeBlock code="npm install @breakpilot/consent-sdk" />
|
||||
</section>
|
||||
|
||||
{/* Provider Setup */}
|
||||
<section className="mb-12">
|
||||
<h2 className="text-xl font-semibold text-gray-900 mb-4">Provider Setup</h2>
|
||||
<p className="text-gray-600 mb-4">
|
||||
Umschliessen Sie Ihre App mit dem ConsentProvider:
|
||||
</p>
|
||||
<CodeBlock
|
||||
filename="app/layout.tsx"
|
||||
code={`import { ConsentProvider } from '@breakpilot/consent-sdk/react';
|
||||
|
||||
export default function RootLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<html lang="de">
|
||||
<body>
|
||||
<ConsentProvider
|
||||
config={{
|
||||
apiEndpoint: process.env.NEXT_PUBLIC_CONSENT_API!,
|
||||
siteId: 'my-site',
|
||||
debug: process.env.NODE_ENV === 'development',
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</ConsentProvider>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}`}
|
||||
/>
|
||||
</section>
|
||||
|
||||
{/* useConsent Hook */}
|
||||
<section className="mb-12">
|
||||
<h2 className="text-xl font-semibold text-gray-900 mb-4">useConsent Hook</h2>
|
||||
<p className="text-gray-600 mb-4">
|
||||
Verwenden Sie den Hook in jeder Komponente:
|
||||
</p>
|
||||
<CodeBlock
|
||||
filename="components/Analytics.tsx"
|
||||
code={`import { useConsent } from '@breakpilot/consent-sdk/react';
|
||||
|
||||
export function Analytics() {
|
||||
const { hasConsent, acceptAll, rejectAll, showSettings } = useConsent();
|
||||
|
||||
if (!hasConsent('analytics')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Script
|
||||
src="https://www.googletagmanager.com/gtag/js?id=GA_ID"
|
||||
strategy="afterInteractive"
|
||||
/>
|
||||
);
|
||||
}`}
|
||||
/>
|
||||
</section>
|
||||
|
||||
{/* ConsentGate */}
|
||||
<section className="mb-12">
|
||||
<h2 className="text-xl font-semibold text-gray-900 mb-4">ConsentGate Component</h2>
|
||||
<p className="text-gray-600 mb-4">
|
||||
Zeigt Inhalte nur wenn Consent vorhanden ist:
|
||||
</p>
|
||||
<CodeBlock
|
||||
filename="components/YouTubeEmbed.tsx"
|
||||
code={`import { ConsentGate } from '@breakpilot/consent-sdk/react';
|
||||
|
||||
export function YouTubeEmbed({ videoId }: { videoId: string }) {
|
||||
return (
|
||||
<ConsentGate
|
||||
category="social"
|
||||
fallback={
|
||||
<div className="bg-gray-100 p-4 rounded-lg text-center">
|
||||
<p>Video erfordert Ihre Zustimmung.</p>
|
||||
<button onClick={() => showSettings()}>
|
||||
Einstellungen oeffnen
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<iframe
|
||||
src={\`https://www.youtube.com/embed/\${videoId}\`}
|
||||
width="560"
|
||||
height="315"
|
||||
allowFullScreen
|
||||
/>
|
||||
</ConsentGate>
|
||||
);
|
||||
}`}
|
||||
/>
|
||||
</section>
|
||||
|
||||
{/* Custom Banner */}
|
||||
<section className="mb-12">
|
||||
<h2 className="text-xl font-semibold text-gray-900 mb-4">Custom Cookie Banner</h2>
|
||||
<CodeBlock
|
||||
filename="components/CookieBanner.tsx"
|
||||
code={`import { useConsent } from '@breakpilot/consent-sdk/react';
|
||||
|
||||
export function CookieBanner() {
|
||||
const {
|
||||
isBannerVisible,
|
||||
acceptAll,
|
||||
rejectAll,
|
||||
showSettings,
|
||||
} = useConsent();
|
||||
|
||||
if (!isBannerVisible) return null;
|
||||
|
||||
return (
|
||||
<div className="fixed bottom-0 inset-x-0 bg-white border-t shadow-lg p-4">
|
||||
<div className="max-w-4xl mx-auto flex items-center justify-between">
|
||||
<p className="text-sm text-gray-600">
|
||||
Wir verwenden Cookies um Ihr Erlebnis zu verbessern.
|
||||
</p>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={rejectAll}
|
||||
className="px-4 py-2 text-sm border rounded"
|
||||
>
|
||||
Ablehnen
|
||||
</button>
|
||||
<button
|
||||
onClick={showSettings}
|
||||
className="px-4 py-2 text-sm border rounded"
|
||||
>
|
||||
Einstellungen
|
||||
</button>
|
||||
<button
|
||||
onClick={acceptAll}
|
||||
className="px-4 py-2 text-sm bg-blue-600 text-white rounded"
|
||||
>
|
||||
Alle akzeptieren
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}`}
|
||||
/>
|
||||
</section>
|
||||
|
||||
{/* Hook API */}
|
||||
<section>
|
||||
<h2 className="text-xl font-semibold text-gray-900 mb-4">Hook API</h2>
|
||||
<div className="bg-white rounded-xl border border-gray-200 overflow-hidden">
|
||||
<table className="min-w-full divide-y divide-gray-200">
|
||||
<thead className="bg-gray-50">
|
||||
<tr>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">
|
||||
Property
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">
|
||||
Typ
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">
|
||||
Beschreibung
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-200">
|
||||
<tr>
|
||||
<td className="px-6 py-4"><code className="text-violet-600">hasConsent</code></td>
|
||||
<td className="px-6 py-4"><code className="text-gray-500">(category) => boolean</code></td>
|
||||
<td className="px-6 py-4 text-sm text-gray-600">Prueft Consent fuer Kategorie</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="px-6 py-4"><code className="text-violet-600">consent</code></td>
|
||||
<td className="px-6 py-4"><code className="text-gray-500">ConsentState | null</code></td>
|
||||
<td className="px-6 py-4 text-sm text-gray-600">Aktueller Consent-Status</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="px-6 py-4"><code className="text-violet-600">acceptAll</code></td>
|
||||
<td className="px-6 py-4"><code className="text-gray-500">() => Promise</code></td>
|
||||
<td className="px-6 py-4 text-sm text-gray-600">Akzeptiert alle Kategorien</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="px-6 py-4"><code className="text-violet-600">rejectAll</code></td>
|
||||
<td className="px-6 py-4"><code className="text-gray-500">() => Promise</code></td>
|
||||
<td className="px-6 py-4 text-sm text-gray-600">Lehnt alle ab (ausser essential)</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="px-6 py-4"><code className="text-violet-600">setConsent</code></td>
|
||||
<td className="px-6 py-4"><code className="text-gray-500">(input) => Promise</code></td>
|
||||
<td className="px-6 py-4 text-sm text-gray-600">Setzt spezifische Kategorien</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="px-6 py-4"><code className="text-violet-600">isBannerVisible</code></td>
|
||||
<td className="px-6 py-4"><code className="text-gray-500">boolean</code></td>
|
||||
<td className="px-6 py-4 text-sm text-gray-600">Banner sichtbar?</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="px-6 py-4"><code className="text-violet-600">showBanner</code></td>
|
||||
<td className="px-6 py-4"><code className="text-gray-500">() => void</code></td>
|
||||
<td className="px-6 py-4 text-sm text-gray-600">Zeigt den Banner</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="px-6 py-4"><code className="text-violet-600">showSettings</code></td>
|
||||
<td className="px-6 py-4"><code className="text-gray-500">() => void</code></td>
|
||||
<td className="px-6 py-4 text-sm text-gray-600">Oeffnet Einstellungen</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,277 @@
|
||||
'use client'
|
||||
|
||||
import React, { useState } from 'react'
|
||||
import { SDKDocsSidebar } from '@/components/developers/SDKDocsSidebar'
|
||||
import { Copy, Check } from 'lucide-react'
|
||||
|
||||
function CopyButton({ text }: { text: string }) {
|
||||
const [copied, setCopied] = useState(false)
|
||||
const handleCopy = async () => {
|
||||
await navigator.clipboard.writeText(text)
|
||||
setCopied(true)
|
||||
setTimeout(() => setCopied(false), 2000)
|
||||
}
|
||||
return (
|
||||
<button onClick={handleCopy} className="p-2 hover:bg-gray-700 rounded transition-colors">
|
||||
{copied ? <Check className="w-4 h-4 text-green-400" /> : <Copy className="w-4 h-4 text-gray-400" />}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
function CodeBlock({ code, filename }: { code: string; filename?: string }) {
|
||||
return (
|
||||
<div className="bg-gray-900 rounded-lg overflow-hidden">
|
||||
{filename && (
|
||||
<div className="px-4 py-2 bg-gray-800 text-gray-400 text-xs border-b border-gray-700">
|
||||
{filename}
|
||||
</div>
|
||||
)}
|
||||
<div className="relative">
|
||||
<div className="absolute top-2 right-2">
|
||||
<CopyButton text={code} />
|
||||
</div>
|
||||
<pre className="p-4 text-sm text-gray-300 font-mono overflow-x-auto">
|
||||
<code>{code}</code>
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function VueIntegrationPage() {
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 flex">
|
||||
<SDKDocsSidebar />
|
||||
|
||||
<main className="flex-1 ml-64">
|
||||
<div className="max-w-4xl mx-auto px-8 py-12">
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
<div className="w-10 h-10 rounded-lg bg-emerald-500 flex items-center justify-center">
|
||||
<span className="text-white font-bold">V</span>
|
||||
</div>
|
||||
<h1 className="text-3xl font-bold text-gray-900">Vue 3 Integration</h1>
|
||||
</div>
|
||||
<p className="text-lg text-gray-600 mb-8">
|
||||
Composables und Plugin fuer Vue 3 und Nuxt Projekte.
|
||||
</p>
|
||||
|
||||
{/* Installation */}
|
||||
<section className="mb-12">
|
||||
<h2 className="text-xl font-semibold text-gray-900 mb-4">Installation</h2>
|
||||
<CodeBlock code="npm install @breakpilot/consent-sdk" />
|
||||
</section>
|
||||
|
||||
{/* Plugin Setup */}
|
||||
<section className="mb-12">
|
||||
<h2 className="text-xl font-semibold text-gray-900 mb-4">Plugin Setup</h2>
|
||||
<CodeBlock
|
||||
filename="main.ts"
|
||||
code={`import { createApp } from 'vue';
|
||||
import App from './App.vue';
|
||||
import { ConsentPlugin } from '@breakpilot/consent-sdk/vue';
|
||||
|
||||
const app = createApp(App);
|
||||
|
||||
app.use(ConsentPlugin, {
|
||||
apiEndpoint: import.meta.env.VITE_CONSENT_API,
|
||||
siteId: 'my-site',
|
||||
debug: import.meta.env.DEV,
|
||||
});
|
||||
|
||||
app.mount('#app');`}
|
||||
/>
|
||||
</section>
|
||||
|
||||
{/* Composable */}
|
||||
<section className="mb-12">
|
||||
<h2 className="text-xl font-semibold text-gray-900 mb-4">useConsent Composable</h2>
|
||||
<CodeBlock
|
||||
filename="components/Analytics.vue"
|
||||
code={`<script setup lang="ts">
|
||||
import { useConsent } from '@breakpilot/consent-sdk/vue';
|
||||
|
||||
const { hasConsent, acceptAll, rejectAll } = useConsent();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="hasConsent('analytics')">
|
||||
<!-- Analytics Code hier -->
|
||||
</div>
|
||||
</template>`}
|
||||
/>
|
||||
</section>
|
||||
|
||||
{/* Cookie Banner */}
|
||||
<section className="mb-12">
|
||||
<h2 className="text-xl font-semibold text-gray-900 mb-4">Cookie Banner Component</h2>
|
||||
<CodeBlock
|
||||
filename="components/CookieBanner.vue"
|
||||
code={`<script setup lang="ts">
|
||||
import { useConsent } from '@breakpilot/consent-sdk/vue';
|
||||
|
||||
const {
|
||||
isBannerVisible,
|
||||
acceptAll,
|
||||
rejectAll,
|
||||
showSettings,
|
||||
} = useConsent();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Transition name="slide">
|
||||
<div
|
||||
v-if="isBannerVisible"
|
||||
class="fixed bottom-0 inset-x-0 bg-white border-t shadow-lg p-4 z-50"
|
||||
>
|
||||
<div class="max-w-4xl mx-auto flex items-center justify-between">
|
||||
<p class="text-sm text-gray-600">
|
||||
Wir verwenden Cookies um Ihr Erlebnis zu verbessern.
|
||||
</p>
|
||||
<div class="flex gap-2">
|
||||
<button
|
||||
@click="rejectAll"
|
||||
class="px-4 py-2 text-sm border rounded hover:bg-gray-50"
|
||||
>
|
||||
Ablehnen
|
||||
</button>
|
||||
<button
|
||||
@click="showSettings"
|
||||
class="px-4 py-2 text-sm border rounded hover:bg-gray-50"
|
||||
>
|
||||
Einstellungen
|
||||
</button>
|
||||
<button
|
||||
@click="acceptAll"
|
||||
class="px-4 py-2 text-sm bg-blue-600 text-white rounded hover:bg-blue-700"
|
||||
>
|
||||
Alle akzeptieren
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.slide-enter-active,
|
||||
.slide-leave-active {
|
||||
transition: transform 0.3s ease;
|
||||
}
|
||||
.slide-enter-from,
|
||||
.slide-leave-to {
|
||||
transform: translateY(100%);
|
||||
}
|
||||
</style>`}
|
||||
/>
|
||||
</section>
|
||||
|
||||
{/* ConsentGate */}
|
||||
<section className="mb-12">
|
||||
<h2 className="text-xl font-semibold text-gray-900 mb-4">ConsentGate Component</h2>
|
||||
<CodeBlock
|
||||
filename="components/YouTubeEmbed.vue"
|
||||
code={`<script setup lang="ts">
|
||||
import { ConsentGate } from '@breakpilot/consent-sdk/vue';
|
||||
|
||||
defineProps<{
|
||||
videoId: string;
|
||||
}>();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ConsentGate category="social">
|
||||
<template #default>
|
||||
<iframe
|
||||
:src="\`https://www.youtube.com/embed/\${videoId}\`"
|
||||
width="560"
|
||||
height="315"
|
||||
allowfullscreen
|
||||
/>
|
||||
</template>
|
||||
<template #fallback>
|
||||
<div class="bg-gray-100 p-4 rounded-lg text-center">
|
||||
<p>Video erfordert Ihre Zustimmung.</p>
|
||||
<button class="mt-2 px-4 py-2 bg-blue-600 text-white rounded">
|
||||
Zustimmen
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
</ConsentGate>
|
||||
</template>`}
|
||||
/>
|
||||
</section>
|
||||
|
||||
{/* Nuxt */}
|
||||
<section className="mb-12">
|
||||
<h2 className="text-xl font-semibold text-gray-900 mb-4">Nuxt 3 Setup</h2>
|
||||
<CodeBlock
|
||||
filename="plugins/consent.client.ts"
|
||||
code={`import { ConsentPlugin } from '@breakpilot/consent-sdk/vue';
|
||||
|
||||
export default defineNuxtPlugin((nuxtApp) => {
|
||||
nuxtApp.vueApp.use(ConsentPlugin, {
|
||||
apiEndpoint: useRuntimeConfig().public.consentApi,
|
||||
siteId: 'my-site',
|
||||
});
|
||||
});`}
|
||||
/>
|
||||
</section>
|
||||
|
||||
{/* Composable API */}
|
||||
<section>
|
||||
<h2 className="text-xl font-semibold text-gray-900 mb-4">Composable API</h2>
|
||||
<div className="bg-white rounded-xl border border-gray-200 overflow-hidden">
|
||||
<table className="min-w-full divide-y divide-gray-200">
|
||||
<thead className="bg-gray-50">
|
||||
<tr>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">
|
||||
Property
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">
|
||||
Typ
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">
|
||||
Beschreibung
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-200">
|
||||
<tr>
|
||||
<td className="px-6 py-4"><code className="text-violet-600">hasConsent</code></td>
|
||||
<td className="px-6 py-4"><code className="text-gray-500">(category) => boolean</code></td>
|
||||
<td className="px-6 py-4 text-sm text-gray-600">Reaktive Consent-Pruefung</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="px-6 py-4"><code className="text-violet-600">consent</code></td>
|
||||
<td className="px-6 py-4"><code className="text-gray-500">Ref<ConsentState></code></td>
|
||||
<td className="px-6 py-4 text-sm text-gray-600">Reaktiver Consent-Status</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="px-6 py-4"><code className="text-violet-600">isBannerVisible</code></td>
|
||||
<td className="px-6 py-4"><code className="text-gray-500">Ref<boolean></code></td>
|
||||
<td className="px-6 py-4 text-sm text-gray-600">Reaktive Banner-Sichtbarkeit</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="px-6 py-4"><code className="text-violet-600">acceptAll</code></td>
|
||||
<td className="px-6 py-4"><code className="text-gray-500">() => Promise</code></td>
|
||||
<td className="px-6 py-4 text-sm text-gray-600">Akzeptiert alle</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="px-6 py-4"><code className="text-violet-600">rejectAll</code></td>
|
||||
<td className="px-6 py-4"><code className="text-gray-500">() => Promise</code></td>
|
||||
<td className="px-6 py-4 text-sm text-gray-600">Lehnt alle ab</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="px-6 py-4"><code className="text-violet-600">setConsent</code></td>
|
||||
<td className="px-6 py-4"><code className="text-gray-500">(input) => Promise</code></td>
|
||||
<td className="px-6 py-4 text-sm text-gray-600">Setzt spezifische Kategorien</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,303 @@
|
||||
'use client'
|
||||
|
||||
import React, { useState } from 'react'
|
||||
import { SDKDocsSidebar } from '@/components/developers/SDKDocsSidebar'
|
||||
import { Copy, Check, Info, AlertTriangle } from 'lucide-react'
|
||||
|
||||
type PackageManager = 'npm' | 'yarn' | 'pnpm'
|
||||
|
||||
const installCommands: Record<PackageManager, string> = {
|
||||
npm: 'npm install @breakpilot/consent-sdk',
|
||||
yarn: 'yarn add @breakpilot/consent-sdk',
|
||||
pnpm: 'pnpm add @breakpilot/consent-sdk',
|
||||
}
|
||||
|
||||
function CopyButton({ text }: { text: string }) {
|
||||
const [copied, setCopied] = useState(false)
|
||||
|
||||
const handleCopy = async () => {
|
||||
await navigator.clipboard.writeText(text)
|
||||
setCopied(true)
|
||||
setTimeout(() => setCopied(false), 2000)
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={handleCopy}
|
||||
className="p-2 hover:bg-gray-700 rounded transition-colors"
|
||||
title="Kopieren"
|
||||
>
|
||||
{copied ? (
|
||||
<Check className="w-4 h-4 text-green-400" />
|
||||
) : (
|
||||
<Copy className="w-4 h-4 text-gray-400" />
|
||||
)}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
function CodeBlock({ code, language = 'typescript' }: { code: string; language?: string }) {
|
||||
return (
|
||||
<div className="relative bg-gray-900 rounded-lg overflow-hidden">
|
||||
<div className="absolute top-2 right-2">
|
||||
<CopyButton text={code} />
|
||||
</div>
|
||||
<pre className="p-4 text-sm text-gray-300 font-mono overflow-x-auto">
|
||||
<code>{code}</code>
|
||||
</pre>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function InfoBox({ type = 'info', children }: { type?: 'info' | 'warning'; children: React.ReactNode }) {
|
||||
const styles = {
|
||||
info: 'bg-blue-50 border-blue-200 text-blue-800',
|
||||
warning: 'bg-yellow-50 border-yellow-200 text-yellow-800',
|
||||
}
|
||||
const Icon = type === 'warning' ? AlertTriangle : Info
|
||||
|
||||
return (
|
||||
<div className={`p-4 border rounded-lg ${styles[type]} flex items-start gap-3`}>
|
||||
<Icon className="w-5 h-5 shrink-0 mt-0.5" />
|
||||
<div className="text-sm">{children}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function InstallationPage() {
|
||||
const [selectedPM, setSelectedPM] = useState<PackageManager>('npm')
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 flex">
|
||||
<SDKDocsSidebar />
|
||||
|
||||
<main className="flex-1 ml-64">
|
||||
<div className="max-w-4xl mx-auto px-8 py-12">
|
||||
<h1 className="text-3xl font-bold text-gray-900 mb-2">Installation</h1>
|
||||
<p className="text-lg text-gray-600 mb-8">
|
||||
Installieren Sie das Consent SDK in Ihrem Projekt.
|
||||
</p>
|
||||
|
||||
{/* Package Installation */}
|
||||
<section className="mb-12">
|
||||
<h2 className="text-xl font-semibold text-gray-900 mb-4">NPM Package</h2>
|
||||
<div className="bg-white rounded-xl border border-gray-200 overflow-hidden">
|
||||
<div className="px-4 py-3 border-b border-gray-200 flex gap-1 bg-gray-50">
|
||||
{(['npm', 'yarn', 'pnpm'] as const).map((pm) => (
|
||||
<button
|
||||
key={pm}
|
||||
onClick={() => setSelectedPM(pm)}
|
||||
className={`px-3 py-1.5 text-sm rounded-md transition-colors ${
|
||||
selectedPM === pm
|
||||
? 'bg-white text-gray-900 shadow-sm border border-gray-200'
|
||||
: 'text-gray-600 hover:text-gray-900'
|
||||
}`}
|
||||
>
|
||||
{pm}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="bg-gray-900 px-4 py-4 flex items-center justify-between">
|
||||
<code className="text-green-400 font-mono text-sm">
|
||||
$ {installCommands[selectedPM]}
|
||||
</code>
|
||||
<CopyButton text={installCommands[selectedPM]} />
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Framework-specific */}
|
||||
<section className="mb-12">
|
||||
<h2 className="text-xl font-semibold text-gray-900 mb-4">Framework-spezifische Imports</h2>
|
||||
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h3 className="font-medium text-gray-900 mb-2">Vanilla JavaScript</h3>
|
||||
<CodeBlock
|
||||
code={`import { ConsentManager } from '@breakpilot/consent-sdk';
|
||||
|
||||
const consent = new ConsentManager({
|
||||
apiEndpoint: 'https://api.example.com/consent',
|
||||
siteId: 'your-site-id',
|
||||
});
|
||||
|
||||
await consent.init();`}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3 className="font-medium text-gray-900 mb-2">React</h3>
|
||||
<CodeBlock
|
||||
code={`import { ConsentProvider, useConsent } from '@breakpilot/consent-sdk/react';
|
||||
|
||||
function App() {
|
||||
return (
|
||||
<ConsentProvider
|
||||
config={{
|
||||
apiEndpoint: 'https://api.example.com/consent',
|
||||
siteId: 'your-site-id',
|
||||
}}
|
||||
>
|
||||
<YourApp />
|
||||
</ConsentProvider>
|
||||
);
|
||||
}`}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3 className="font-medium text-gray-900 mb-2">Vue 3</h3>
|
||||
<CodeBlock
|
||||
code={`import { createApp } from 'vue';
|
||||
import { ConsentPlugin } from '@breakpilot/consent-sdk/vue';
|
||||
|
||||
const app = createApp(App);
|
||||
|
||||
app.use(ConsentPlugin, {
|
||||
apiEndpoint: 'https://api.example.com/consent',
|
||||
siteId: 'your-site-id',
|
||||
});`}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Script Blocking Setup */}
|
||||
<section className="mb-12">
|
||||
<h2 className="text-xl font-semibold text-gray-900 mb-4">Script Blocking einrichten</h2>
|
||||
<p className="text-gray-600 mb-4">
|
||||
Um Third-Party Scripts automatisch zu blockieren, verwenden Sie das{' '}
|
||||
<code className="px-1.5 py-0.5 bg-gray-100 rounded text-sm">data-consent</code> Attribut:
|
||||
</p>
|
||||
|
||||
<CodeBlock
|
||||
language="html"
|
||||
code={`<!-- Analytics Script (blockiert bis Consent) -->
|
||||
<script
|
||||
data-consent="analytics"
|
||||
data-src="https://www.googletagmanager.com/gtag/js?id=GA_MEASUREMENT_ID"
|
||||
type="text/plain"
|
||||
></script>
|
||||
|
||||
<!-- Marketing Script (blockiert bis Consent) -->
|
||||
<script data-consent="marketing" type="text/plain">
|
||||
fbq('init', 'YOUR_PIXEL_ID');
|
||||
</script>
|
||||
|
||||
<!-- Embedded iFrame (blockiert bis Consent) -->
|
||||
<iframe
|
||||
data-consent="social"
|
||||
data-src="https://www.youtube.com/embed/VIDEO_ID"
|
||||
width="560"
|
||||
height="315"
|
||||
></iframe>`}
|
||||
/>
|
||||
</section>
|
||||
|
||||
{/* Requirements */}
|
||||
<section className="mb-12">
|
||||
<h2 className="text-xl font-semibold text-gray-900 mb-4">Systemvoraussetzungen</h2>
|
||||
<div className="bg-white rounded-xl border border-gray-200 overflow-hidden">
|
||||
<table className="min-w-full divide-y divide-gray-200">
|
||||
<thead className="bg-gray-50">
|
||||
<tr>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">
|
||||
Anforderung
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">
|
||||
Minimum
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-200">
|
||||
<tr>
|
||||
<td className="px-6 py-4 text-sm text-gray-900">Node.js</td>
|
||||
<td className="px-6 py-4 text-sm text-gray-600">>= 18.0.0</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="px-6 py-4 text-sm text-gray-900">React (optional)</td>
|
||||
<td className="px-6 py-4 text-sm text-gray-600">>= 17.0.0</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="px-6 py-4 text-sm text-gray-900">Vue (optional)</td>
|
||||
<td className="px-6 py-4 text-sm text-gray-600">>= 3.0.0</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="px-6 py-4 text-sm text-gray-900">TypeScript (optional)</td>
|
||||
<td className="px-6 py-4 text-sm text-gray-600">>= 4.7.0</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Browser Support */}
|
||||
<section className="mb-12">
|
||||
<h2 className="text-xl font-semibold text-gray-900 mb-4">Browser-Unterstuetzung</h2>
|
||||
<InfoBox type="info">
|
||||
Das SDK unterstuetzt alle modernen Browser mit ES2017+ Unterstuetzung.
|
||||
Fuer aeltere Browser wird ein automatischer Fallback fuer Crypto-Funktionen bereitgestellt.
|
||||
</InfoBox>
|
||||
<div className="mt-4 bg-white rounded-xl border border-gray-200 overflow-hidden">
|
||||
<table className="min-w-full divide-y divide-gray-200">
|
||||
<thead className="bg-gray-50">
|
||||
<tr>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">
|
||||
Browser
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">
|
||||
Minimum Version
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-200">
|
||||
<tr>
|
||||
<td className="px-6 py-4 text-sm text-gray-900">Chrome</td>
|
||||
<td className="px-6 py-4 text-sm text-gray-600">>= 60</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="px-6 py-4 text-sm text-gray-900">Firefox</td>
|
||||
<td className="px-6 py-4 text-sm text-gray-600">>= 55</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="px-6 py-4 text-sm text-gray-900">Safari</td>
|
||||
<td className="px-6 py-4 text-sm text-gray-600">>= 11</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="px-6 py-4 text-sm text-gray-900">Edge</td>
|
||||
<td className="px-6 py-4 text-sm text-gray-600">>= 79 (Chromium)</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Next Steps */}
|
||||
<section>
|
||||
<h2 className="text-xl font-semibold text-gray-900 mb-4">Naechste Schritte</h2>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<a
|
||||
href="/developers/sdk/consent/api-reference"
|
||||
className="p-4 bg-white rounded-xl border border-gray-200 hover:border-violet-300 hover:shadow-md transition-all"
|
||||
>
|
||||
<h3 className="font-medium text-gray-900">API Referenz</h3>
|
||||
<p className="text-sm text-gray-500 mt-1">
|
||||
Vollstaendige Dokumentation aller Methoden und Konfigurationsoptionen.
|
||||
</p>
|
||||
</a>
|
||||
<a
|
||||
href="/developers/sdk/consent/frameworks"
|
||||
className="p-4 bg-white rounded-xl border border-gray-200 hover:border-violet-300 hover:shadow-md transition-all"
|
||||
>
|
||||
<h3 className="font-medium text-gray-900">Framework Integration</h3>
|
||||
<p className="text-sm text-gray-500 mt-1">
|
||||
Detaillierte Anleitungen fuer React, Vue und Angular.
|
||||
</p>
|
||||
</a>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,269 @@
|
||||
'use client'
|
||||
|
||||
import React, { useState } from 'react'
|
||||
import { SDKDocsSidebar } from '@/components/developers/SDKDocsSidebar'
|
||||
import { Copy, Check, Smartphone } from 'lucide-react'
|
||||
|
||||
function CopyButton({ text }: { text: string }) {
|
||||
const [copied, setCopied] = useState(false)
|
||||
const handleCopy = async () => {
|
||||
await navigator.clipboard.writeText(text)
|
||||
setCopied(true)
|
||||
setTimeout(() => setCopied(false), 2000)
|
||||
}
|
||||
return (
|
||||
<button onClick={handleCopy} className="p-2 hover:bg-gray-700 rounded transition-colors">
|
||||
{copied ? <Check className="w-4 h-4 text-green-400" /> : <Copy className="w-4 h-4 text-gray-400" />}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
function CodeBlock({ code, filename }: { code: string; filename?: string }) {
|
||||
return (
|
||||
<div className="bg-gray-900 rounded-lg overflow-hidden">
|
||||
{filename && (
|
||||
<div className="px-4 py-2 bg-gray-800 text-gray-400 text-xs border-b border-gray-700">
|
||||
{filename}
|
||||
</div>
|
||||
)}
|
||||
<div className="relative">
|
||||
<div className="absolute top-2 right-2">
|
||||
<CopyButton text={code} />
|
||||
</div>
|
||||
<pre className="p-4 text-sm text-gray-300 font-mono overflow-x-auto">
|
||||
<code>{code}</code>
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function AndroidSDKPage() {
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 flex">
|
||||
<SDKDocsSidebar />
|
||||
|
||||
<main className="flex-1 ml-64">
|
||||
<div className="max-w-4xl mx-auto px-8 py-12">
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
<div className="w-10 h-10 rounded-lg bg-green-600 flex items-center justify-center">
|
||||
<Smartphone className="w-6 h-6 text-white" />
|
||||
</div>
|
||||
<h1 className="text-3xl font-bold text-gray-900">Android SDK (Kotlin)</h1>
|
||||
</div>
|
||||
<p className="text-lg text-gray-600 mb-8">
|
||||
Native Kotlin SDK fuer Android API 26+ mit Jetpack Compose Unterstuetzung.
|
||||
</p>
|
||||
|
||||
{/* Requirements */}
|
||||
<section className="mb-12">
|
||||
<h2 className="text-xl font-semibold text-gray-900 mb-4">Systemvoraussetzungen</h2>
|
||||
<div className="bg-white rounded-xl border border-gray-200 overflow-hidden">
|
||||
<table className="min-w-full divide-y divide-gray-200">
|
||||
<tbody className="divide-y divide-gray-200">
|
||||
<tr>
|
||||
<td className="px-6 py-4 text-sm font-medium text-gray-900">Kotlin Version</td>
|
||||
<td className="px-6 py-4 text-sm text-gray-600">1.9+</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="px-6 py-4 text-sm font-medium text-gray-900">Min SDK</td>
|
||||
<td className="px-6 py-4 text-sm text-gray-600">API 26 (Android 8.0)</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="px-6 py-4 text-sm font-medium text-gray-900">Compile SDK</td>
|
||||
<td className="px-6 py-4 text-sm text-gray-600">34+</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Installation */}
|
||||
<section className="mb-12">
|
||||
<h2 className="text-xl font-semibold text-gray-900 mb-4">Installation</h2>
|
||||
<CodeBlock
|
||||
filename="build.gradle.kts (Module)"
|
||||
code={`dependencies {
|
||||
implementation("com.breakpilot:consent-sdk:1.0.0")
|
||||
|
||||
// Fuer Jetpack Compose
|
||||
implementation("com.breakpilot:consent-sdk-compose:1.0.0")
|
||||
}`}
|
||||
/>
|
||||
</section>
|
||||
|
||||
{/* Basic Setup */}
|
||||
<section className="mb-12">
|
||||
<h2 className="text-xl font-semibold text-gray-900 mb-4">Grundlegende Einrichtung</h2>
|
||||
<CodeBlock
|
||||
filename="MyApplication.kt"
|
||||
code={`import android.app.Application
|
||||
import com.breakpilot.consent.ConsentManager
|
||||
|
||||
class MyApplication : Application() {
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
|
||||
// Consent Manager konfigurieren
|
||||
ConsentManager.configure(
|
||||
context = this,
|
||||
config = ConsentConfig(
|
||||
apiEndpoint = "https://api.example.com/consent",
|
||||
siteId = "my-android-app"
|
||||
)
|
||||
)
|
||||
|
||||
// Initialisieren
|
||||
lifecycleScope.launch {
|
||||
ConsentManager.initialize()
|
||||
}
|
||||
}
|
||||
}`}
|
||||
/>
|
||||
</section>
|
||||
|
||||
{/* Jetpack Compose */}
|
||||
<section className="mb-12">
|
||||
<h2 className="text-xl font-semibold text-gray-900 mb-4">Jetpack Compose Integration</h2>
|
||||
<CodeBlock
|
||||
filename="MainActivity.kt"
|
||||
code={`import androidx.compose.runtime.*
|
||||
import com.breakpilot.consent.compose.*
|
||||
|
||||
@Composable
|
||||
fun MainScreen() {
|
||||
val consent = rememberConsentState()
|
||||
|
||||
Column {
|
||||
// Consent-abhaengige UI
|
||||
if (consent.hasConsent(ConsentCategory.ANALYTICS)) {
|
||||
AnalyticsView()
|
||||
}
|
||||
|
||||
// Buttons
|
||||
Button(onClick = { consent.acceptAll() }) {
|
||||
Text("Alle akzeptieren")
|
||||
}
|
||||
|
||||
Button(onClick = { consent.rejectAll() }) {
|
||||
Text("Alle ablehnen")
|
||||
}
|
||||
}
|
||||
|
||||
// Consent Banner (automatisch angezeigt wenn noetig)
|
||||
ConsentBanner()
|
||||
}
|
||||
|
||||
// ConsentGate Composable
|
||||
@Composable
|
||||
fun ProtectedContent() {
|
||||
ConsentGate(
|
||||
category = ConsentCategory.MARKETING,
|
||||
fallback = {
|
||||
Text("Marketing-Zustimmung erforderlich")
|
||||
}
|
||||
) {
|
||||
MarketingContent()
|
||||
}
|
||||
}`}
|
||||
/>
|
||||
</section>
|
||||
|
||||
{/* Traditional Android */}
|
||||
<section className="mb-12">
|
||||
<h2 className="text-xl font-semibold text-gray-900 mb-4">View-basierte Integration</h2>
|
||||
<CodeBlock
|
||||
filename="MainActivity.kt"
|
||||
code={`import androidx.appcompat.app.AppCompatActivity
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import com.breakpilot.consent.ConsentManager
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.flow.collect
|
||||
|
||||
class MainActivity : AppCompatActivity() {
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
setContentView(R.layout.activity_main)
|
||||
|
||||
// Auf Consent-Aenderungen reagieren
|
||||
lifecycleScope.launch {
|
||||
ConsentManager.consentFlow.collect { state ->
|
||||
updateUI(state)
|
||||
}
|
||||
}
|
||||
|
||||
// Banner anzeigen wenn noetig
|
||||
if (ConsentManager.needsConsent()) {
|
||||
ConsentManager.showBanner(this)
|
||||
}
|
||||
}
|
||||
|
||||
private fun updateUI(state: ConsentState?) {
|
||||
if (state?.hasConsent(ConsentCategory.ANALYTICS) == true) {
|
||||
loadAnalytics()
|
||||
}
|
||||
}
|
||||
|
||||
fun onAcceptAllClick(view: View) {
|
||||
lifecycleScope.launch {
|
||||
ConsentManager.acceptAll()
|
||||
}
|
||||
}
|
||||
}`}
|
||||
/>
|
||||
</section>
|
||||
|
||||
{/* API Reference */}
|
||||
<section>
|
||||
<h2 className="text-xl font-semibold text-gray-900 mb-4">API Referenz</h2>
|
||||
<div className="bg-white rounded-xl border border-gray-200 overflow-hidden">
|
||||
<table className="min-w-full divide-y divide-gray-200">
|
||||
<thead className="bg-gray-50">
|
||||
<tr>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Methode</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Beschreibung</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-200">
|
||||
<tr>
|
||||
<td className="px-6 py-4"><code className="text-violet-600">configure()</code></td>
|
||||
<td className="px-6 py-4 text-sm text-gray-600">SDK konfigurieren</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="px-6 py-4"><code className="text-violet-600">initialize()</code></td>
|
||||
<td className="px-6 py-4 text-sm text-gray-600">SDK initialisieren (suspend)</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="px-6 py-4"><code className="text-violet-600">hasConsent()</code></td>
|
||||
<td className="px-6 py-4 text-sm text-gray-600">Consent fuer Kategorie pruefen</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="px-6 py-4"><code className="text-violet-600">consentFlow</code></td>
|
||||
<td className="px-6 py-4 text-sm text-gray-600">Flow fuer reaktive Updates</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="px-6 py-4"><code className="text-violet-600">acceptAll()</code></td>
|
||||
<td className="px-6 py-4 text-sm text-gray-600">Alle akzeptieren (suspend)</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="px-6 py-4"><code className="text-violet-600">rejectAll()</code></td>
|
||||
<td className="px-6 py-4 text-sm text-gray-600">Alle ablehnen (suspend)</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="px-6 py-4"><code className="text-violet-600">setConsent()</code></td>
|
||||
<td className="px-6 py-4 text-sm text-gray-600">Kategorien setzen (suspend)</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="px-6 py-4"><code className="text-violet-600">showBanner()</code></td>
|
||||
<td className="px-6 py-4 text-sm text-gray-600">Banner als DialogFragment</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,313 @@
|
||||
'use client'
|
||||
|
||||
import React, { useState } from 'react'
|
||||
import { SDKDocsSidebar } from '@/components/developers/SDKDocsSidebar'
|
||||
import { Copy, Check, Smartphone } from 'lucide-react'
|
||||
|
||||
function CopyButton({ text }: { text: string }) {
|
||||
const [copied, setCopied] = useState(false)
|
||||
const handleCopy = async () => {
|
||||
await navigator.clipboard.writeText(text)
|
||||
setCopied(true)
|
||||
setTimeout(() => setCopied(false), 2000)
|
||||
}
|
||||
return (
|
||||
<button onClick={handleCopy} className="p-2 hover:bg-gray-700 rounded transition-colors">
|
||||
{copied ? <Check className="w-4 h-4 text-green-400" /> : <Copy className="w-4 h-4 text-gray-400" />}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
function CodeBlock({ code, filename }: { code: string; filename?: string }) {
|
||||
return (
|
||||
<div className="bg-gray-900 rounded-lg overflow-hidden">
|
||||
{filename && (
|
||||
<div className="px-4 py-2 bg-gray-800 text-gray-400 text-xs border-b border-gray-700">
|
||||
{filename}
|
||||
</div>
|
||||
)}
|
||||
<div className="relative">
|
||||
<div className="absolute top-2 right-2">
|
||||
<CopyButton text={code} />
|
||||
</div>
|
||||
<pre className="p-4 text-sm text-gray-300 font-mono overflow-x-auto">
|
||||
<code>{code}</code>
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function FlutterSDKPage() {
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 flex">
|
||||
<SDKDocsSidebar />
|
||||
|
||||
<main className="flex-1 ml-64">
|
||||
<div className="max-w-4xl mx-auto px-8 py-12">
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
<div className="w-10 h-10 rounded-lg bg-blue-500 flex items-center justify-center">
|
||||
<Smartphone className="w-6 h-6 text-white" />
|
||||
</div>
|
||||
<h1 className="text-3xl font-bold text-gray-900">Flutter SDK</h1>
|
||||
</div>
|
||||
<p className="text-lg text-gray-600 mb-8">
|
||||
Cross-Platform SDK fuer Flutter 3.16+ mit iOS, Android und Web Support.
|
||||
</p>
|
||||
|
||||
{/* Requirements */}
|
||||
<section className="mb-12">
|
||||
<h2 className="text-xl font-semibold text-gray-900 mb-4">Systemvoraussetzungen</h2>
|
||||
<div className="bg-white rounded-xl border border-gray-200 overflow-hidden">
|
||||
<table className="min-w-full divide-y divide-gray-200">
|
||||
<tbody className="divide-y divide-gray-200">
|
||||
<tr>
|
||||
<td className="px-6 py-4 text-sm font-medium text-gray-900">Dart Version</td>
|
||||
<td className="px-6 py-4 text-sm text-gray-600">3.0+</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="px-6 py-4 text-sm font-medium text-gray-900">Flutter Version</td>
|
||||
<td className="px-6 py-4 text-sm text-gray-600">3.16+</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="px-6 py-4 text-sm font-medium text-gray-900">Plattformen</td>
|
||||
<td className="px-6 py-4 text-sm text-gray-600">iOS, Android, Web</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Installation */}
|
||||
<section className="mb-12">
|
||||
<h2 className="text-xl font-semibold text-gray-900 mb-4">Installation</h2>
|
||||
<CodeBlock
|
||||
filename="pubspec.yaml"
|
||||
code={`dependencies:
|
||||
consent_sdk: ^1.0.0`}
|
||||
/>
|
||||
<div className="mt-4">
|
||||
<CodeBlock code="flutter pub get" />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Basic Setup */}
|
||||
<section className="mb-12">
|
||||
<h2 className="text-xl font-semibold text-gray-900 mb-4">Grundlegende Einrichtung</h2>
|
||||
<CodeBlock
|
||||
filename="main.dart"
|
||||
code={`import 'package:flutter/material.dart';
|
||||
import 'package:consent_sdk/consent_sdk.dart';
|
||||
|
||||
void main() async {
|
||||
WidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
// Consent SDK initialisieren
|
||||
await ConsentManager.instance.initialize(
|
||||
config: ConsentConfig(
|
||||
apiEndpoint: 'https://api.example.com/consent',
|
||||
siteId: 'my-flutter-app',
|
||||
),
|
||||
);
|
||||
|
||||
runApp(const MyApp());
|
||||
}
|
||||
|
||||
class MyApp extends StatelessWidget {
|
||||
const MyApp({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return MaterialApp(
|
||||
home: const ConsentWrapper(
|
||||
child: HomeScreen(),
|
||||
),
|
||||
);
|
||||
}
|
||||
}`}
|
||||
/>
|
||||
</section>
|
||||
|
||||
{/* Widget Usage */}
|
||||
<section className="mb-12">
|
||||
<h2 className="text-xl font-semibold text-gray-900 mb-4">Widget Integration</h2>
|
||||
<CodeBlock
|
||||
filename="home_screen.dart"
|
||||
code={`import 'package:flutter/material.dart';
|
||||
import 'package:consent_sdk/consent_sdk.dart';
|
||||
|
||||
class HomeScreen extends StatelessWidget {
|
||||
const HomeScreen({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
body: Column(
|
||||
children: [
|
||||
// StreamBuilder fuer reaktive Updates
|
||||
StreamBuilder<ConsentState?>(
|
||||
stream: ConsentManager.instance.consentStream,
|
||||
builder: (context, snapshot) {
|
||||
final consent = snapshot.data;
|
||||
|
||||
if (consent?.hasConsent(ConsentCategory.analytics) ?? false) {
|
||||
return const AnalyticsWidget();
|
||||
}
|
||||
|
||||
return const SizedBox.shrink();
|
||||
},
|
||||
),
|
||||
|
||||
// ConsentGate Widget
|
||||
ConsentGate(
|
||||
category: ConsentCategory.marketing,
|
||||
fallback: const Center(
|
||||
child: Text('Marketing-Zustimmung erforderlich'),
|
||||
),
|
||||
child: const MarketingWidget(),
|
||||
),
|
||||
|
||||
// Buttons
|
||||
ElevatedButton(
|
||||
onPressed: () => ConsentManager.instance.acceptAll(),
|
||||
child: const Text('Alle akzeptieren'),
|
||||
),
|
||||
|
||||
ElevatedButton(
|
||||
onPressed: () => ConsentManager.instance.rejectAll(),
|
||||
child: const Text('Alle ablehnen'),
|
||||
),
|
||||
|
||||
TextButton(
|
||||
onPressed: () => ConsentManager.instance.showSettings(context),
|
||||
child: const Text('Einstellungen'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}`}
|
||||
/>
|
||||
</section>
|
||||
|
||||
{/* Custom Banner */}
|
||||
<section className="mb-12">
|
||||
<h2 className="text-xl font-semibold text-gray-900 mb-4">Custom Cookie Banner</h2>
|
||||
<CodeBlock
|
||||
filename="cookie_banner.dart"
|
||||
code={`import 'package:flutter/material.dart';
|
||||
import 'package:consent_sdk/consent_sdk.dart';
|
||||
|
||||
class CustomCookieBanner extends StatelessWidget {
|
||||
const CustomCookieBanner({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return StreamBuilder<bool>(
|
||||
stream: ConsentManager.instance.isBannerVisibleStream,
|
||||
builder: (context, snapshot) {
|
||||
if (!(snapshot.data ?? false)) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withOpacity(0.1),
|
||||
blurRadius: 10,
|
||||
),
|
||||
],
|
||||
),
|
||||
child: SafeArea(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const Text(
|
||||
'Wir verwenden Cookies um Ihr Erlebnis zu verbessern.',
|
||||
style: TextStyle(fontSize: 14),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.end,
|
||||
children: [
|
||||
TextButton(
|
||||
onPressed: () => ConsentManager.instance.rejectAll(),
|
||||
child: const Text('Ablehnen'),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () => ConsentManager.instance.showSettings(context),
|
||||
child: const Text('Einstellungen'),
|
||||
),
|
||||
ElevatedButton(
|
||||
onPressed: () => ConsentManager.instance.acceptAll(),
|
||||
child: const Text('Alle akzeptieren'),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}`}
|
||||
/>
|
||||
</section>
|
||||
|
||||
{/* API Reference */}
|
||||
<section>
|
||||
<h2 className="text-xl font-semibold text-gray-900 mb-4">API Referenz</h2>
|
||||
<div className="bg-white rounded-xl border border-gray-200 overflow-hidden">
|
||||
<table className="min-w-full divide-y divide-gray-200">
|
||||
<thead className="bg-gray-50">
|
||||
<tr>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Methode/Property</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Beschreibung</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-200">
|
||||
<tr>
|
||||
<td className="px-6 py-4"><code className="text-violet-600">initialize()</code></td>
|
||||
<td className="px-6 py-4 text-sm text-gray-600">SDK initialisieren (Future)</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="px-6 py-4"><code className="text-violet-600">hasConsent()</code></td>
|
||||
<td className="px-6 py-4 text-sm text-gray-600">Consent pruefen</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="px-6 py-4"><code className="text-violet-600">consentStream</code></td>
|
||||
<td className="px-6 py-4 text-sm text-gray-600">Stream fuer Consent-Updates</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="px-6 py-4"><code className="text-violet-600">isBannerVisibleStream</code></td>
|
||||
<td className="px-6 py-4 text-sm text-gray-600">Stream fuer Banner-Sichtbarkeit</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="px-6 py-4"><code className="text-violet-600">acceptAll()</code></td>
|
||||
<td className="px-6 py-4 text-sm text-gray-600">Alle akzeptieren (Future)</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="px-6 py-4"><code className="text-violet-600">rejectAll()</code></td>
|
||||
<td className="px-6 py-4 text-sm text-gray-600">Alle ablehnen (Future)</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="px-6 py-4"><code className="text-violet-600">setConsent()</code></td>
|
||||
<td className="px-6 py-4 text-sm text-gray-600">Kategorien setzen (Future)</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="px-6 py-4"><code className="text-violet-600">showSettings()</code></td>
|
||||
<td className="px-6 py-4 text-sm text-gray-600">Einstellungs-Dialog oeffnen</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,283 @@
|
||||
'use client'
|
||||
|
||||
import React, { useState } from 'react'
|
||||
import { SDKDocsSidebar } from '@/components/developers/SDKDocsSidebar'
|
||||
import { Copy, Check, Apple } from 'lucide-react'
|
||||
|
||||
function CopyButton({ text }: { text: string }) {
|
||||
const [copied, setCopied] = useState(false)
|
||||
const handleCopy = async () => {
|
||||
await navigator.clipboard.writeText(text)
|
||||
setCopied(true)
|
||||
setTimeout(() => setCopied(false), 2000)
|
||||
}
|
||||
return (
|
||||
<button onClick={handleCopy} className="p-2 hover:bg-gray-700 rounded transition-colors">
|
||||
{copied ? <Check className="w-4 h-4 text-green-400" /> : <Copy className="w-4 h-4 text-gray-400" />}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
function CodeBlock({ code, filename }: { code: string; filename?: string }) {
|
||||
return (
|
||||
<div className="bg-gray-900 rounded-lg overflow-hidden">
|
||||
{filename && (
|
||||
<div className="px-4 py-2 bg-gray-800 text-gray-400 text-xs border-b border-gray-700">
|
||||
{filename}
|
||||
</div>
|
||||
)}
|
||||
<div className="relative">
|
||||
<div className="absolute top-2 right-2">
|
||||
<CopyButton text={code} />
|
||||
</div>
|
||||
<pre className="p-4 text-sm text-gray-300 font-mono overflow-x-auto">
|
||||
<code>{code}</code>
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function iOSSDKPage() {
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 flex">
|
||||
<SDKDocsSidebar />
|
||||
|
||||
<main className="flex-1 ml-64">
|
||||
<div className="max-w-4xl mx-auto px-8 py-12">
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
<div className="w-10 h-10 rounded-lg bg-gray-900 flex items-center justify-center">
|
||||
<Apple className="w-6 h-6 text-white" />
|
||||
</div>
|
||||
<h1 className="text-3xl font-bold text-gray-900">iOS SDK (Swift)</h1>
|
||||
</div>
|
||||
<p className="text-lg text-gray-600 mb-8">
|
||||
Native Swift SDK fuer iOS 15+ und iPadOS mit SwiftUI-Unterstuetzung.
|
||||
</p>
|
||||
|
||||
{/* Requirements */}
|
||||
<section className="mb-12">
|
||||
<h2 className="text-xl font-semibold text-gray-900 mb-4">Systemvoraussetzungen</h2>
|
||||
<div className="bg-white rounded-xl border border-gray-200 overflow-hidden">
|
||||
<table className="min-w-full divide-y divide-gray-200">
|
||||
<tbody className="divide-y divide-gray-200">
|
||||
<tr>
|
||||
<td className="px-6 py-4 text-sm font-medium text-gray-900">Swift Version</td>
|
||||
<td className="px-6 py-4 text-sm text-gray-600">5.9+</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="px-6 py-4 text-sm font-medium text-gray-900">iOS Deployment Target</td>
|
||||
<td className="px-6 py-4 text-sm text-gray-600">iOS 15.0+</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="px-6 py-4 text-sm font-medium text-gray-900">Xcode Version</td>
|
||||
<td className="px-6 py-4 text-sm text-gray-600">15.0+</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Installation */}
|
||||
<section className="mb-12">
|
||||
<h2 className="text-xl font-semibold text-gray-900 mb-4">Installation</h2>
|
||||
<h3 className="font-medium text-gray-900 mb-2">Swift Package Manager</h3>
|
||||
<CodeBlock
|
||||
filename="Package.swift"
|
||||
code={`dependencies: [
|
||||
.package(url: "https://github.com/breakpilot/consent-sdk-ios.git", from: "1.0.0")
|
||||
]`}
|
||||
/>
|
||||
<p className="text-sm text-gray-600 mt-4">
|
||||
Oder in Xcode: File → Add Package Dependencies → URL eingeben
|
||||
</p>
|
||||
</section>
|
||||
|
||||
{/* Basic Usage */}
|
||||
<section className="mb-12">
|
||||
<h2 className="text-xl font-semibold text-gray-900 mb-4">Grundlegende Verwendung</h2>
|
||||
<CodeBlock
|
||||
filename="AppDelegate.swift"
|
||||
code={`import ConsentSDK
|
||||
|
||||
@main
|
||||
class AppDelegate: UIResponder, UIApplicationDelegate {
|
||||
func application(
|
||||
_ application: UIApplication,
|
||||
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
|
||||
) -> Bool {
|
||||
|
||||
// Consent Manager konfigurieren
|
||||
ConsentManager.shared.configure(
|
||||
apiEndpoint: "https://api.example.com/consent",
|
||||
siteId: "my-ios-app"
|
||||
)
|
||||
|
||||
// Initialisieren
|
||||
Task {
|
||||
await ConsentManager.shared.initialize()
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
}`}
|
||||
/>
|
||||
</section>
|
||||
|
||||
{/* SwiftUI Integration */}
|
||||
<section className="mb-12">
|
||||
<h2 className="text-xl font-semibold text-gray-900 mb-4">SwiftUI Integration</h2>
|
||||
<CodeBlock
|
||||
filename="ContentView.swift"
|
||||
code={`import SwiftUI
|
||||
import ConsentSDK
|
||||
|
||||
struct ContentView: View {
|
||||
@StateObject private var consent = ConsentManager.shared
|
||||
|
||||
var body: some View {
|
||||
VStack {
|
||||
if consent.hasConsent(.analytics) {
|
||||
AnalyticsView()
|
||||
}
|
||||
|
||||
Button("Alle akzeptieren") {
|
||||
Task {
|
||||
await consent.acceptAll()
|
||||
}
|
||||
}
|
||||
}
|
||||
.consentBanner() // Zeigt Banner automatisch
|
||||
}
|
||||
}
|
||||
|
||||
// Consent Gate Modifier
|
||||
struct ProtectedView: View {
|
||||
var body: some View {
|
||||
Text("Geschuetzter Inhalt")
|
||||
.requiresConsent(.marketing) {
|
||||
// Fallback wenn kein Consent
|
||||
Text("Marketing-Zustimmung erforderlich")
|
||||
}
|
||||
}
|
||||
}`}
|
||||
/>
|
||||
</section>
|
||||
|
||||
{/* UIKit Integration */}
|
||||
<section className="mb-12">
|
||||
<h2 className="text-xl font-semibold text-gray-900 mb-4">UIKit Integration</h2>
|
||||
<CodeBlock
|
||||
filename="ViewController.swift"
|
||||
code={`import UIKit
|
||||
import ConsentSDK
|
||||
import Combine
|
||||
|
||||
class ViewController: UIViewController {
|
||||
private var cancellables = Set<AnyCancellable>()
|
||||
|
||||
override func viewDidLoad() {
|
||||
super.viewDidLoad()
|
||||
|
||||
// Reaktiv auf Consent-Aenderungen reagieren
|
||||
ConsentManager.shared.$consent
|
||||
.receive(on: DispatchQueue.main)
|
||||
.sink { [weak self] state in
|
||||
self?.updateUI(consent: state)
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
}
|
||||
|
||||
private func updateUI(consent: ConsentState?) {
|
||||
if consent?.hasConsent(.analytics) == true {
|
||||
loadAnalytics()
|
||||
}
|
||||
}
|
||||
|
||||
@IBAction func acceptAllTapped(_ sender: UIButton) {
|
||||
Task {
|
||||
await ConsentManager.shared.acceptAll()
|
||||
}
|
||||
}
|
||||
}`}
|
||||
/>
|
||||
</section>
|
||||
|
||||
{/* Consent Categories */}
|
||||
<section className="mb-12">
|
||||
<h2 className="text-xl font-semibold text-gray-900 mb-4">Consent-Kategorien</h2>
|
||||
<CodeBlock
|
||||
code={`// Verfuegbare Kategorien
|
||||
enum ConsentCategory {
|
||||
case essential // Immer aktiv
|
||||
case functional // Funktionale Features
|
||||
case analytics // Statistik & Analyse
|
||||
case marketing // Werbung & Tracking
|
||||
case social // Social Media Integration
|
||||
}
|
||||
|
||||
// Consent pruefen
|
||||
if ConsentManager.shared.hasConsent(.analytics) {
|
||||
// Analytics laden
|
||||
}
|
||||
|
||||
// Mehrere Kategorien pruefen
|
||||
if ConsentManager.shared.hasConsent([.analytics, .marketing]) {
|
||||
// Beide Kategorien haben Consent
|
||||
}`}
|
||||
/>
|
||||
</section>
|
||||
|
||||
{/* API Reference */}
|
||||
<section>
|
||||
<h2 className="text-xl font-semibold text-gray-900 mb-4">API Referenz</h2>
|
||||
<div className="bg-white rounded-xl border border-gray-200 overflow-hidden">
|
||||
<table className="min-w-full divide-y divide-gray-200">
|
||||
<thead className="bg-gray-50">
|
||||
<tr>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Methode</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Beschreibung</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-200">
|
||||
<tr>
|
||||
<td className="px-6 py-4"><code className="text-violet-600">configure()</code></td>
|
||||
<td className="px-6 py-4 text-sm text-gray-600">SDK konfigurieren</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="px-6 py-4"><code className="text-violet-600">initialize()</code></td>
|
||||
<td className="px-6 py-4 text-sm text-gray-600">SDK initialisieren (async)</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="px-6 py-4"><code className="text-violet-600">hasConsent(_:)</code></td>
|
||||
<td className="px-6 py-4 text-sm text-gray-600">Consent fuer Kategorie pruefen</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="px-6 py-4"><code className="text-violet-600">acceptAll()</code></td>
|
||||
<td className="px-6 py-4 text-sm text-gray-600">Alle Kategorien akzeptieren (async)</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="px-6 py-4"><code className="text-violet-600">rejectAll()</code></td>
|
||||
<td className="px-6 py-4 text-sm text-gray-600">Alle ablehnen (async)</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="px-6 py-4"><code className="text-violet-600">setConsent(_:)</code></td>
|
||||
<td className="px-6 py-4 text-sm text-gray-600">Spezifische Kategorien setzen (async)</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="px-6 py-4"><code className="text-violet-600">showBanner()</code></td>
|
||||
<td className="px-6 py-4 text-sm text-gray-600">Banner anzeigen</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="px-6 py-4"><code className="text-violet-600">exportConsent()</code></td>
|
||||
<td className="px-6 py-4 text-sm text-gray-600">Consent-Daten exportieren (DSGVO)</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
'use client'
|
||||
|
||||
import React from 'react'
|
||||
import Link from 'next/link'
|
||||
import { SDKDocsSidebar } from '@/components/developers/SDKDocsSidebar'
|
||||
import { ChevronRight, Apple, Smartphone } from 'lucide-react'
|
||||
|
||||
const platforms = [
|
||||
{
|
||||
name: 'iOS (Swift)',
|
||||
href: '/developers/sdk/consent/mobile/ios',
|
||||
description: 'Native Swift SDK fuer iOS 15+ und iPadOS',
|
||||
features: ['Swift 5.9+', 'iOS 15.0+', 'SwiftUI Support', 'Combine Integration'],
|
||||
color: 'bg-gray-900',
|
||||
icon: Apple,
|
||||
},
|
||||
{
|
||||
name: 'Android (Kotlin)',
|
||||
href: '/developers/sdk/consent/mobile/android',
|
||||
description: 'Native Kotlin SDK fuer Android API 26+',
|
||||
features: ['Kotlin 1.9+', 'API 26+', 'Jetpack Compose', 'Coroutines'],
|
||||
color: 'bg-green-600',
|
||||
icon: Smartphone,
|
||||
},
|
||||
{
|
||||
name: 'Flutter',
|
||||
href: '/developers/sdk/consent/mobile/flutter',
|
||||
description: 'Cross-Platform SDK fuer Flutter 3.16+',
|
||||
features: ['Dart 3.0+', 'Flutter 3.16+', 'iOS & Android', 'Web Support'],
|
||||
color: 'bg-blue-500',
|
||||
icon: Smartphone,
|
||||
},
|
||||
]
|
||||
|
||||
export default function MobileSDKsPage() {
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 flex">
|
||||
<SDKDocsSidebar />
|
||||
|
||||
<main className="flex-1 ml-64">
|
||||
<div className="max-w-4xl mx-auto px-8 py-12">
|
||||
<h1 className="text-3xl font-bold text-gray-900 mb-2">Mobile SDKs</h1>
|
||||
<p className="text-lg text-gray-600 mb-8">
|
||||
Native SDKs fuer iOS, Android und Flutter mit vollstaendiger DSGVO-Konformitaet.
|
||||
</p>
|
||||
|
||||
<div className="space-y-4">
|
||||
{platforms.map((platform) => (
|
||||
<Link
|
||||
key={platform.name}
|
||||
href={platform.href}
|
||||
className="block bg-white rounded-xl border border-gray-200 p-6 hover:border-violet-300 hover:shadow-md transition-all group"
|
||||
>
|
||||
<div className="flex items-start gap-4">
|
||||
<div className={`w-12 h-12 rounded-xl ${platform.color} flex items-center justify-center shrink-0`}>
|
||||
<platform.icon className="w-6 h-6 text-white" />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<h2 className="text-xl font-semibold text-gray-900 group-hover:text-violet-600 transition-colors">
|
||||
{platform.name}
|
||||
</h2>
|
||||
<ChevronRight className="w-5 h-5 text-gray-400 group-hover:text-violet-600 transition-colors" />
|
||||
</div>
|
||||
<p className="text-gray-600 mt-1">{platform.description}</p>
|
||||
<div className="flex flex-wrap gap-2 mt-3">
|
||||
{platform.features.map((feature) => (
|
||||
<span
|
||||
key={feature}
|
||||
className="px-2 py-1 bg-gray-100 text-gray-600 text-xs rounded-md"
|
||||
>
|
||||
{feature}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Cross-Platform Note */}
|
||||
<div className="mt-8 p-4 bg-blue-50 border border-blue-200 rounded-xl">
|
||||
<h3 className="font-medium text-blue-900">Cross-Platform Konsistenz</h3>
|
||||
<p className="text-sm text-blue-700 mt-1">
|
||||
Alle Mobile SDKs bieten dieselbe API-Oberflaeche wie das Web SDK.
|
||||
Consent-Daten werden ueber die API synchronisiert, sodass Benutzer auf allen Geraeten
|
||||
denselben Consent-Status haben.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,262 @@
|
||||
'use client'
|
||||
|
||||
import React, { useState } from 'react'
|
||||
import Link from 'next/link'
|
||||
import {
|
||||
Shield, Code, Download, Smartphone, FileCode, Lock,
|
||||
ChevronRight, Copy, Check, Zap, Globe, Layers,
|
||||
BookOpen, Terminal
|
||||
} from 'lucide-react'
|
||||
import { SDKDocsSidebar } from '@/components/developers/SDKDocsSidebar'
|
||||
|
||||
type Framework = 'npm' | 'yarn' | 'pnpm'
|
||||
|
||||
const installCommands: Record<Framework, string> = {
|
||||
npm: 'npm install @breakpilot/consent-sdk',
|
||||
yarn: 'yarn add @breakpilot/consent-sdk',
|
||||
pnpm: 'pnpm add @breakpilot/consent-sdk',
|
||||
}
|
||||
|
||||
function CopyButton({ text }: { text: string }) {
|
||||
const [copied, setCopied] = useState(false)
|
||||
|
||||
const handleCopy = async () => {
|
||||
await navigator.clipboard.writeText(text)
|
||||
setCopied(true)
|
||||
setTimeout(() => setCopied(false), 2000)
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={handleCopy}
|
||||
className="p-2 hover:bg-gray-700 rounded transition-colors"
|
||||
title="Kopieren"
|
||||
>
|
||||
{copied ? (
|
||||
<Check className="w-4 h-4 text-green-400" />
|
||||
) : (
|
||||
<Copy className="w-4 h-4 text-gray-400" />
|
||||
)}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
export default function ConsentSDKHubPage() {
|
||||
const [selectedPM, setSelectedPM] = useState<Framework>('npm')
|
||||
|
||||
const quickLinks = [
|
||||
{
|
||||
title: 'Installation',
|
||||
description: 'SDK in wenigen Minuten einrichten',
|
||||
href: '/developers/sdk/consent/installation',
|
||||
icon: Download,
|
||||
color: 'bg-blue-500',
|
||||
},
|
||||
{
|
||||
title: 'API Referenz',
|
||||
description: 'Vollstaendige API-Dokumentation',
|
||||
href: '/developers/sdk/consent/api-reference',
|
||||
icon: FileCode,
|
||||
color: 'bg-purple-500',
|
||||
},
|
||||
{
|
||||
title: 'Frameworks',
|
||||
description: 'React, Vue, Angular Integration',
|
||||
href: '/developers/sdk/consent/frameworks',
|
||||
icon: Layers,
|
||||
color: 'bg-green-500',
|
||||
},
|
||||
{
|
||||
title: 'Mobile SDKs',
|
||||
description: 'iOS, Android, Flutter',
|
||||
href: '/developers/sdk/consent/mobile',
|
||||
icon: Smartphone,
|
||||
color: 'bg-orange-500',
|
||||
},
|
||||
{
|
||||
title: 'Sicherheit',
|
||||
description: 'Best Practices & Compliance',
|
||||
href: '/developers/sdk/consent/security',
|
||||
icon: Lock,
|
||||
color: 'bg-red-500',
|
||||
},
|
||||
]
|
||||
|
||||
const features = [
|
||||
{
|
||||
title: 'DSGVO & TTDSG Konform',
|
||||
description: 'Vollstaendige Unterstuetzung fuer EU-Datenschutzverordnungen mit revisionssicherer Consent-Speicherung.',
|
||||
icon: Shield,
|
||||
},
|
||||
{
|
||||
title: 'Google Consent Mode v2',
|
||||
description: 'Native Integration mit automatischer Synchronisation zu Google Analytics und Ads.',
|
||||
icon: Globe,
|
||||
},
|
||||
{
|
||||
title: 'Script Blocking',
|
||||
description: 'Automatisches Blockieren von Third-Party Scripts bis zur Einwilligung.',
|
||||
icon: Code,
|
||||
},
|
||||
{
|
||||
title: 'Multi-Platform',
|
||||
description: 'Unterstuetzung fuer Web, PWA, iOS, Android und Flutter aus einer Codebasis.',
|
||||
icon: Smartphone,
|
||||
},
|
||||
]
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 flex">
|
||||
<SDKDocsSidebar />
|
||||
|
||||
<main className="flex-1 ml-64">
|
||||
<div className="max-w-5xl mx-auto px-8 py-12">
|
||||
{/* Header */}
|
||||
<div className="mb-12">
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<div className="w-12 h-12 rounded-xl bg-gradient-to-br from-violet-600 to-purple-600 flex items-center justify-center">
|
||||
<Shield className="w-7 h-7 text-white" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-gray-900">Consent SDK</h1>
|
||||
<div className="flex items-center gap-2 mt-1">
|
||||
<span className="px-2 py-0.5 bg-green-100 text-green-800 text-xs font-medium rounded-full">
|
||||
v1.0.0
|
||||
</span>
|
||||
<span className="text-sm text-gray-500">DSGVO/TTDSG Compliant</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-lg text-gray-600 max-w-3xl">
|
||||
Das Consent SDK ermoeglicht DSGVO-konforme Einwilligungsverwaltung fuer Web, PWA und Mobile Apps.
|
||||
Mit nativer Unterstuetzung fuer React, Vue, Angular und Mobile Platforms.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Quick Install */}
|
||||
<div className="mb-12 bg-white rounded-xl border border-gray-200 overflow-hidden">
|
||||
<div className="px-6 py-4 border-b border-gray-200 flex items-center justify-between">
|
||||
<h2 className="font-semibold text-gray-900">Schnellinstallation</h2>
|
||||
<div className="flex gap-1 bg-gray-100 rounded-lg p-1">
|
||||
{(['npm', 'yarn', 'pnpm'] as const).map((pm) => (
|
||||
<button
|
||||
key={pm}
|
||||
onClick={() => setSelectedPM(pm)}
|
||||
className={`px-3 py-1 text-sm rounded-md transition-colors ${
|
||||
selectedPM === pm
|
||||
? 'bg-white text-gray-900 shadow-sm'
|
||||
: 'text-gray-600 hover:text-gray-900'
|
||||
}`}
|
||||
>
|
||||
{pm}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-gray-900 px-6 py-4 flex items-center justify-between">
|
||||
<code className="text-green-400 font-mono text-sm">
|
||||
$ {installCommands[selectedPM]}
|
||||
</code>
|
||||
<CopyButton text={installCommands[selectedPM]} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Quick Links */}
|
||||
<div className="mb-12">
|
||||
<h2 className="text-xl font-semibold text-gray-900 mb-4">Dokumentation</h2>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{quickLinks.map((link) => (
|
||||
<Link
|
||||
key={link.href}
|
||||
href={link.href}
|
||||
className="group p-4 bg-white rounded-xl border border-gray-200 hover:border-violet-300 hover:shadow-md transition-all"
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
<div className={`w-10 h-10 rounded-lg ${link.color} flex items-center justify-center shrink-0`}>
|
||||
<link.icon className="w-5 h-5 text-white" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<h3 className="font-medium text-gray-900 group-hover:text-violet-600 transition-colors flex items-center gap-1">
|
||||
{link.title}
|
||||
<ChevronRight className="w-4 h-4 opacity-0 group-hover:opacity-100 transition-opacity" />
|
||||
</h3>
|
||||
<p className="text-sm text-gray-500 mt-1">{link.description}</p>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Quick Start Code */}
|
||||
<div className="mb-12 bg-white rounded-xl border border-gray-200 overflow-hidden">
|
||||
<div className="px-6 py-4 border-b border-gray-200">
|
||||
<h2 className="font-semibold text-gray-900">Schnellstart</h2>
|
||||
</div>
|
||||
<div className="bg-gray-900 p-6">
|
||||
<pre className="text-sm text-gray-300 font-mono overflow-x-auto">
|
||||
{`import { ConsentManager } from '@breakpilot/consent-sdk';
|
||||
|
||||
// Manager initialisieren
|
||||
const consent = new ConsentManager({
|
||||
apiEndpoint: 'https://api.example.com/consent',
|
||||
siteId: 'your-site-id',
|
||||
});
|
||||
|
||||
// SDK starten
|
||||
await consent.init();
|
||||
|
||||
// Consent pruefen
|
||||
if (consent.hasConsent('analytics')) {
|
||||
// Analytics laden
|
||||
}
|
||||
|
||||
// Events abonnieren
|
||||
consent.on('change', (state) => {
|
||||
console.log('Consent geaendert:', state);
|
||||
});`}
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Features */}
|
||||
<div className="mb-12">
|
||||
<h2 className="text-xl font-semibold text-gray-900 mb-4">Features</h2>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{features.map((feature) => (
|
||||
<div
|
||||
key={feature.title}
|
||||
className="p-4 bg-white rounded-xl border border-gray-200"
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="w-10 h-10 rounded-lg bg-violet-100 flex items-center justify-center shrink-0">
|
||||
<feature.icon className="w-5 h-5 text-violet-600" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-medium text-gray-900">{feature.title}</h3>
|
||||
<p className="text-sm text-gray-500 mt-1">{feature.description}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Compliance Notice */}
|
||||
<div className="p-4 bg-blue-50 border border-blue-200 rounded-xl">
|
||||
<div className="flex items-start gap-3">
|
||||
<Shield className="w-5 h-5 text-blue-600 mt-0.5" />
|
||||
<div>
|
||||
<h3 className="font-medium text-blue-900">DSGVO & TTDSG Compliance</h3>
|
||||
<p className="text-sm text-blue-700 mt-1">
|
||||
Das Consent SDK erfuellt alle Anforderungen der DSGVO (Art. 6, 7, 13, 14, 17, 20) und des TTDSG (§ 25).
|
||||
Alle Einwilligungen werden revisionssicher gespeichert und koennen jederzeit exportiert werden.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,290 @@
|
||||
'use client'
|
||||
|
||||
import React from 'react'
|
||||
import { SDKDocsSidebar } from '@/components/developers/SDKDocsSidebar'
|
||||
import { Shield, Lock, Eye, Database, Key, AlertTriangle, CheckCircle } from 'lucide-react'
|
||||
|
||||
function SecurityCard({
|
||||
title,
|
||||
description,
|
||||
icon: Icon,
|
||||
items,
|
||||
}: {
|
||||
title: string
|
||||
description: string
|
||||
icon: React.ComponentType<{ className?: string }>
|
||||
items: string[]
|
||||
}) {
|
||||
return (
|
||||
<div className="bg-white rounded-xl border border-gray-200 p-6">
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="w-10 h-10 rounded-lg bg-violet-100 flex items-center justify-center shrink-0">
|
||||
<Icon className="w-5 h-5 text-violet-600" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-semibold text-gray-900">{title}</h3>
|
||||
<p className="text-sm text-gray-600 mt-1">{description}</p>
|
||||
<ul className="mt-3 space-y-1">
|
||||
{items.map((item, i) => (
|
||||
<li key={i} className="flex items-center gap-2 text-sm text-gray-600">
|
||||
<CheckCircle className="w-4 h-4 text-green-500 shrink-0" />
|
||||
{item}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function SecurityPage() {
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 flex">
|
||||
<SDKDocsSidebar />
|
||||
|
||||
<main className="flex-1 ml-64">
|
||||
<div className="max-w-4xl mx-auto px-8 py-12">
|
||||
<h1 className="text-3xl font-bold text-gray-900 mb-2">Sicherheit & Compliance</h1>
|
||||
<p className="text-lg text-gray-600 mb-8">
|
||||
Best Practices fuer sichere Implementierung und DSGVO-konforme Nutzung des Consent SDK.
|
||||
</p>
|
||||
|
||||
{/* Security Features */}
|
||||
<section className="mb-12">
|
||||
<h2 className="text-xl font-semibold text-gray-900 mb-6">Sicherheits-Features</h2>
|
||||
<div className="grid gap-4">
|
||||
<SecurityCard
|
||||
title="Datenverschluesselung"
|
||||
description="Alle Daten werden verschluesselt uebertragen und gespeichert."
|
||||
icon={Lock}
|
||||
items={[
|
||||
'TLS 1.3 fuer alle API-Kommunikation',
|
||||
'HMAC-Signatur fuer lokale Storage-Integritaet',
|
||||
'Keine Klartextspeicherung sensibler Daten',
|
||||
]}
|
||||
/>
|
||||
|
||||
<SecurityCard
|
||||
title="Datenschutzkonformes Fingerprinting"
|
||||
description="Anonymisiertes Fingerprinting ohne invasive Techniken."
|
||||
icon={Eye}
|
||||
items={[
|
||||
'Kein Canvas/WebGL/Audio Fingerprinting',
|
||||
'Nur anonymisierte Browser-Eigenschaften',
|
||||
'SHA-256 Hash der Komponenten',
|
||||
'Nicht eindeutig identifizierend',
|
||||
]}
|
||||
/>
|
||||
|
||||
<SecurityCard
|
||||
title="Sichere Speicherung"
|
||||
description="Lokale Speicherung mit Manipulationsschutz."
|
||||
icon={Database}
|
||||
items={[
|
||||
'Signierte localStorage-Eintraege',
|
||||
'Automatische Signaturverifikation',
|
||||
'HttpOnly Cookies fuer SSR',
|
||||
'SameSite=Lax gegen CSRF',
|
||||
]}
|
||||
/>
|
||||
|
||||
<SecurityCard
|
||||
title="API-Sicherheit"
|
||||
description="Sichere Backend-Kommunikation."
|
||||
icon={Key}
|
||||
items={[
|
||||
'Request-Signierung mit Timestamp',
|
||||
'Credentials-Include fuer Session-Cookies',
|
||||
'CORS-Konfiguration erforderlich',
|
||||
'Rate-Limiting auf Server-Seite',
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* DSGVO Compliance */}
|
||||
<section className="mb-12">
|
||||
<h2 className="text-xl font-semibold text-gray-900 mb-6">DSGVO Compliance</h2>
|
||||
|
||||
<div className="bg-white rounded-xl border border-gray-200 overflow-hidden">
|
||||
<table className="min-w-full divide-y divide-gray-200">
|
||||
<thead className="bg-gray-50">
|
||||
<tr>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">
|
||||
DSGVO Artikel
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">
|
||||
Anforderung
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">
|
||||
SDK-Unterstuetzung
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-200">
|
||||
<tr>
|
||||
<td className="px-6 py-4 text-sm font-medium text-gray-900">Art. 6</td>
|
||||
<td className="px-6 py-4 text-sm text-gray-600">Rechtmaessigkeit der Verarbeitung</td>
|
||||
<td className="px-6 py-4">
|
||||
<span className="px-2 py-1 bg-green-100 text-green-800 text-xs rounded-full">
|
||||
Vollstaendig
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="px-6 py-4 text-sm font-medium text-gray-900">Art. 7</td>
|
||||
<td className="px-6 py-4 text-sm text-gray-600">Bedingungen fuer Einwilligung</td>
|
||||
<td className="px-6 py-4">
|
||||
<span className="px-2 py-1 bg-green-100 text-green-800 text-xs rounded-full">
|
||||
Vollstaendig
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="px-6 py-4 text-sm font-medium text-gray-900">Art. 13/14</td>
|
||||
<td className="px-6 py-4 text-sm text-gray-600">Informationspflichten</td>
|
||||
<td className="px-6 py-4">
|
||||
<span className="px-2 py-1 bg-green-100 text-green-800 text-xs rounded-full">
|
||||
Vollstaendig
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="px-6 py-4 text-sm font-medium text-gray-900">Art. 17</td>
|
||||
<td className="px-6 py-4 text-sm text-gray-600">Recht auf Loeschung</td>
|
||||
<td className="px-6 py-4">
|
||||
<span className="px-2 py-1 bg-green-100 text-green-800 text-xs rounded-full">
|
||||
Vollstaendig
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="px-6 py-4 text-sm font-medium text-gray-900">Art. 20</td>
|
||||
<td className="px-6 py-4 text-sm text-gray-600">Datenportabilitaet</td>
|
||||
<td className="px-6 py-4">
|
||||
<span className="px-2 py-1 bg-green-100 text-green-800 text-xs rounded-full">
|
||||
Vollstaendig
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* TTDSG Compliance */}
|
||||
<section className="mb-12">
|
||||
<h2 className="text-xl font-semibold text-gray-900 mb-6">TTDSG Compliance</h2>
|
||||
<div className="bg-white rounded-xl border border-gray-200 p-6">
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="w-10 h-10 rounded-lg bg-blue-100 flex items-center justify-center shrink-0">
|
||||
<Shield className="w-5 h-5 text-blue-600" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-semibold text-gray-900">§ 25 TTDSG - Schutz der Privatsphaere</h3>
|
||||
<p className="text-sm text-gray-600 mt-1">
|
||||
Das SDK erfuellt alle Anforderungen des § 25 TTDSG (Telemediengesetz):
|
||||
</p>
|
||||
<ul className="mt-3 space-y-2">
|
||||
<li className="flex items-start gap-2 text-sm text-gray-600">
|
||||
<CheckCircle className="w-4 h-4 text-green-500 shrink-0 mt-0.5" />
|
||||
<span>
|
||||
<strong>Einwilligung vor Speicherung:</strong> Cookies und localStorage werden erst nach
|
||||
Einwilligung gesetzt (ausser technisch notwendige).
|
||||
</span>
|
||||
</li>
|
||||
<li className="flex items-start gap-2 text-sm text-gray-600">
|
||||
<CheckCircle className="w-4 h-4 text-green-500 shrink-0 mt-0.5" />
|
||||
<span>
|
||||
<strong>Informierte Einwilligung:</strong> Klare Kategorisierung und Beschreibung
|
||||
aller Cookies und Tracker.
|
||||
</span>
|
||||
</li>
|
||||
<li className="flex items-start gap-2 text-sm text-gray-600">
|
||||
<CheckCircle className="w-4 h-4 text-green-500 shrink-0 mt-0.5" />
|
||||
<span>
|
||||
<strong>Widerrufsrecht:</strong> Jederzeit widerrufbare Einwilligung mit einem Klick.
|
||||
</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Best Practices */}
|
||||
<section className="mb-12">
|
||||
<h2 className="text-xl font-semibold text-gray-900 mb-6">Best Practices</h2>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="bg-green-50 border border-green-200 rounded-xl p-4">
|
||||
<h3 className="font-medium text-green-900 flex items-center gap-2">
|
||||
<CheckCircle className="w-5 h-5" />
|
||||
Empfohlen
|
||||
</h3>
|
||||
<ul className="mt-2 space-y-1 text-sm text-green-800">
|
||||
<li>• HTTPS fuer alle API-Aufrufe verwenden</li>
|
||||
<li>• Consent-Banner vor dem Laden von Third-Party Scripts anzeigen</li>
|
||||
<li>• Alle Kategorien klar und verstaendlich beschreiben</li>
|
||||
<li>• Ablehnen-Button gleichwertig zum Akzeptieren-Button darstellen</li>
|
||||
<li>• Consent-Aenderungen serverseitig protokollieren</li>
|
||||
<li>• Regelmaessige Ueberpruefung der Consent-Gultigkeit (recheckAfterDays)</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div className="bg-red-50 border border-red-200 rounded-xl p-4">
|
||||
<h3 className="font-medium text-red-900 flex items-center gap-2">
|
||||
<AlertTriangle className="w-5 h-5" />
|
||||
Vermeiden
|
||||
</h3>
|
||||
<ul className="mt-2 space-y-1 text-sm text-red-800">
|
||||
<li>• Dark Patterns (versteckte Ablehnen-Buttons)</li>
|
||||
<li>• Pre-checked Consent-Optionen</li>
|
||||
<li>• Tracking vor Einwilligung</li>
|
||||
<li>• Cookie-Walls ohne echte Alternative</li>
|
||||
<li>• Unklare oder irrefuehrende Kategoriebezeichnungen</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Audit Trail */}
|
||||
<section>
|
||||
<h2 className="text-xl font-semibold text-gray-900 mb-6">Audit Trail</h2>
|
||||
<div className="bg-white rounded-xl border border-gray-200 p-6">
|
||||
<p className="text-gray-600 mb-4">
|
||||
Das SDK speichert fuer jeden Consent-Vorgang revisionssichere Daten:
|
||||
</p>
|
||||
<div className="bg-gray-50 rounded-lg p-4 font-mono text-sm">
|
||||
<pre className="text-gray-700">
|
||||
{`{
|
||||
"consentId": "consent_abc123...",
|
||||
"timestamp": "2024-01-15T10:30:00.000Z",
|
||||
"categories": {
|
||||
"essential": true,
|
||||
"analytics": true,
|
||||
"marketing": false
|
||||
},
|
||||
"metadata": {
|
||||
"userAgent": "Mozilla/5.0...",
|
||||
"language": "de-DE",
|
||||
"platform": "web",
|
||||
"screenResolution": "1920x1080"
|
||||
},
|
||||
"signature": "sha256=...",
|
||||
"version": "1.0.0"
|
||||
}`}
|
||||
</pre>
|
||||
</div>
|
||||
<p className="text-sm text-gray-500 mt-4">
|
||||
Diese Daten werden sowohl lokal als auch auf dem Server gespeichert und koennen
|
||||
jederzeit fuer Audits exportiert werden.
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,186 @@
|
||||
import { DevPortalLayout, CodeBlock, InfoBox, ParameterTable } from '@/components/developers/DevPortalLayout'
|
||||
|
||||
export default function SDKInstallationPage() {
|
||||
return (
|
||||
<DevPortalLayout
|
||||
title="SDK Installation"
|
||||
description="Installationsanleitung fuer das AI Compliance SDK"
|
||||
>
|
||||
<h2>Voraussetzungen</h2>
|
||||
<ul>
|
||||
<li>Node.js 18 oder hoeher</li>
|
||||
<li>React 18+ / Next.js 14+</li>
|
||||
<li>TypeScript 5.0+ (empfohlen)</li>
|
||||
</ul>
|
||||
|
||||
<h2>Installation</h2>
|
||||
<p>
|
||||
Installieren Sie das SDK ueber Ihren bevorzugten Paketmanager:
|
||||
</p>
|
||||
<CodeBlock language="bash" filename="npm">
|
||||
{`npm install @breakpilot/compliance-sdk`}
|
||||
</CodeBlock>
|
||||
<CodeBlock language="bash" filename="yarn">
|
||||
{`yarn add @breakpilot/compliance-sdk`}
|
||||
</CodeBlock>
|
||||
<CodeBlock language="bash" filename="pnpm">
|
||||
{`pnpm add @breakpilot/compliance-sdk`}
|
||||
</CodeBlock>
|
||||
|
||||
<h2>Peer Dependencies</h2>
|
||||
<p>
|
||||
Das SDK hat folgende Peer Dependencies, die automatisch installiert werden sollten:
|
||||
</p>
|
||||
<CodeBlock language="json" filename="package.json">
|
||||
{`{
|
||||
"peerDependencies": {
|
||||
"react": ">=18.0.0",
|
||||
"react-dom": ">=18.0.0"
|
||||
}
|
||||
}`}
|
||||
</CodeBlock>
|
||||
|
||||
<h2>Zusaetzliche Pakete (optional)</h2>
|
||||
<p>
|
||||
Fuer erweiterte Funktionen koennen Sie folgende Pakete installieren:
|
||||
</p>
|
||||
<ParameterTable
|
||||
parameters={[
|
||||
{
|
||||
name: 'jspdf',
|
||||
type: 'npm package',
|
||||
required: false,
|
||||
description: 'Fuer PDF-Export (wird automatisch geladen wenn verfuegbar)',
|
||||
},
|
||||
{
|
||||
name: 'jszip',
|
||||
type: 'npm package',
|
||||
required: false,
|
||||
description: 'Fuer ZIP-Export aller Dokumente',
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
<h2>TypeScript Konfiguration</h2>
|
||||
<p>
|
||||
Das SDK ist vollstaendig in TypeScript geschrieben. Stellen Sie sicher,
|
||||
dass Ihre tsconfig.json folgende Optionen enthaelt:
|
||||
</p>
|
||||
<CodeBlock language="json" filename="tsconfig.json">
|
||||
{`{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"lib": ["dom", "dom.iterable", "esnext"],
|
||||
"module": "esnext",
|
||||
"moduleResolution": "bundler",
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"forceConsistentCasingInFileNames": true
|
||||
}
|
||||
}`}
|
||||
</CodeBlock>
|
||||
|
||||
<h2>Next.js Integration</h2>
|
||||
<p>
|
||||
Fuer Next.js 14+ mit App Router, fuegen Sie den Provider in Ihr Root-Layout ein:
|
||||
</p>
|
||||
<CodeBlock language="typescript" filename="app/layout.tsx">
|
||||
{`import { SDKProvider } from '@breakpilot/compliance-sdk'
|
||||
|
||||
export default function RootLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
}) {
|
||||
return (
|
||||
<html lang="de">
|
||||
<body>
|
||||
<SDKProvider
|
||||
tenantId={process.env.NEXT_PUBLIC_TENANT_ID!}
|
||||
apiKey={process.env.BREAKPILOT_API_KEY}
|
||||
enableBackendSync={true}
|
||||
>
|
||||
{children}
|
||||
</SDKProvider>
|
||||
</body>
|
||||
</html>
|
||||
)
|
||||
}`}
|
||||
</CodeBlock>
|
||||
|
||||
<InfoBox type="warning" title="Wichtig fuer Server Components">
|
||||
Der SDKProvider ist ein Client-Component. Wenn Sie Server Components
|
||||
verwenden, wrappen Sie nur die Teile der App, die das SDK benoetigen.
|
||||
</InfoBox>
|
||||
|
||||
<h2>Umgebungsvariablen</h2>
|
||||
<p>
|
||||
Erstellen Sie eine .env.local Datei mit folgenden Variablen:
|
||||
</p>
|
||||
<CodeBlock language="bash" filename=".env.local">
|
||||
{`# Pflicht
|
||||
NEXT_PUBLIC_TENANT_ID=your-tenant-id
|
||||
|
||||
# Optional (fuer Backend-Sync)
|
||||
BREAKPILOT_API_KEY=sk_live_...
|
||||
|
||||
# Optional (fuer Self-Hosted)
|
||||
NEXT_PUBLIC_SDK_API_URL=https://your-server.com/sdk/v1`}
|
||||
</CodeBlock>
|
||||
|
||||
<InfoBox type="info" title="API Key Sicherheit">
|
||||
Der API Key sollte niemals im Frontend-Code oder in NEXT_PUBLIC_ Variablen
|
||||
erscheinen. Verwenden Sie Server-Side API Routes fuer authentifizierte Anfragen.
|
||||
</InfoBox>
|
||||
|
||||
<h2>Verifizierung</h2>
|
||||
<p>
|
||||
Testen Sie die Installation mit einer einfachen Komponente:
|
||||
</p>
|
||||
<CodeBlock language="typescript" filename="app/test/page.tsx">
|
||||
{`'use client'
|
||||
|
||||
import { useSDK } from '@breakpilot/compliance-sdk'
|
||||
|
||||
export default function TestPage() {
|
||||
const { state, completionPercentage } = useSDK()
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h1>SDK Test</h1>
|
||||
<p>Fortschritt: {completionPercentage}%</p>
|
||||
<p>Aktuelle Phase: {state.currentPhase}</p>
|
||||
<p>Use Cases: {state.useCases.length}</p>
|
||||
</div>
|
||||
)
|
||||
}`}
|
||||
</CodeBlock>
|
||||
|
||||
<h2>Fehlerbehebung</h2>
|
||||
|
||||
<h3>Error: useSDK must be used within SDKProvider</h3>
|
||||
<p>
|
||||
Stellen Sie sicher, dass der SDKProvider das gesamte Layout umschliesst
|
||||
und dass Sie {'\'use client\''} in Client-Komponenten verwenden.
|
||||
</p>
|
||||
|
||||
<h3>Error: Module not found</h3>
|
||||
<p>
|
||||
Loeschen Sie node_modules und package-lock.json, dann reinstallieren:
|
||||
</p>
|
||||
<CodeBlock language="bash" filename="Terminal">
|
||||
{`rm -rf node_modules package-lock.json
|
||||
npm install`}
|
||||
</CodeBlock>
|
||||
|
||||
<h3>TypeScript Errors</h3>
|
||||
<p>
|
||||
Stellen Sie sicher, dass TypeScript 5.0+ installiert ist:
|
||||
</p>
|
||||
<CodeBlock language="bash" filename="Terminal">
|
||||
{`npm install typescript@latest`}
|
||||
</CodeBlock>
|
||||
</DevPortalLayout>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,281 @@
|
||||
import Link from 'next/link'
|
||||
import { DevPortalLayout, CodeBlock, InfoBox } from '@/components/developers/DevPortalLayout'
|
||||
|
||||
export default function SDKOverviewPage() {
|
||||
return (
|
||||
<DevPortalLayout
|
||||
title="SDK Documentation"
|
||||
description="TypeScript SDK für React und Next.js Integration"
|
||||
>
|
||||
<h2>Übersicht</h2>
|
||||
<p>
|
||||
Das AI Compliance SDK ist ein TypeScript-Paket für die Integration des
|
||||
Compliance-Workflows in React und Next.js Anwendungen. Es bietet:
|
||||
</p>
|
||||
<ul>
|
||||
<li>React Context Provider für State Management</li>
|
||||
<li>Hooks für einfachen Zugriff auf Compliance-Daten</li>
|
||||
<li>Automatische Synchronisation mit dem Backend</li>
|
||||
<li>Offline-Support mit localStorage Fallback</li>
|
||||
<li>Export-Funktionen (PDF, JSON, ZIP)</li>
|
||||
</ul>
|
||||
|
||||
<h2>Kernkomponenten</h2>
|
||||
|
||||
<h3>SDKProvider</h3>
|
||||
<p>
|
||||
Der Provider wrappet Ihre App und stellt den SDK-Kontext bereit:
|
||||
</p>
|
||||
<CodeBlock language="typescript" filename="app/layout.tsx">
|
||||
{`import { SDKProvider } from '@breakpilot/compliance-sdk'
|
||||
|
||||
export default function Layout({ children }) {
|
||||
return (
|
||||
<SDKProvider
|
||||
tenantId="your-tenant"
|
||||
enableBackendSync={true}
|
||||
>
|
||||
{children}
|
||||
</SDKProvider>
|
||||
)
|
||||
}`}
|
||||
</CodeBlock>
|
||||
|
||||
<h3>useSDK Hook</h3>
|
||||
<p>
|
||||
Der Haupt-Hook für den Zugriff auf alle SDK-Funktionen:
|
||||
</p>
|
||||
<CodeBlock language="typescript" filename="component.tsx">
|
||||
{`import { useSDK } from '@breakpilot/compliance-sdk'
|
||||
|
||||
function MyComponent() {
|
||||
const {
|
||||
// State
|
||||
state,
|
||||
dispatch,
|
||||
|
||||
// Navigation
|
||||
currentStep,
|
||||
goToStep,
|
||||
goToNextStep,
|
||||
goToPreviousStep,
|
||||
canGoNext,
|
||||
canGoPrevious,
|
||||
|
||||
// Progress
|
||||
completionPercentage,
|
||||
phase1Completion,
|
||||
phase2Completion,
|
||||
|
||||
// Checkpoints
|
||||
validateCheckpoint,
|
||||
overrideCheckpoint,
|
||||
getCheckpointStatus,
|
||||
|
||||
// Data Updates
|
||||
updateUseCase,
|
||||
addRisk,
|
||||
updateControl,
|
||||
|
||||
// Persistence
|
||||
saveState,
|
||||
loadState,
|
||||
|
||||
// Demo Data
|
||||
seedDemoData,
|
||||
clearDemoData,
|
||||
isDemoDataLoaded,
|
||||
|
||||
// Sync
|
||||
syncState,
|
||||
forceSyncToServer,
|
||||
isOnline,
|
||||
|
||||
// Export
|
||||
exportState,
|
||||
|
||||
// Command Bar
|
||||
isCommandBarOpen,
|
||||
setCommandBarOpen,
|
||||
} = useSDK()
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h1>Progress: {completionPercentage}%</h1>
|
||||
<button onClick={() => goToStep('risks')}>
|
||||
Zur Risikoanalyse
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}`}
|
||||
</CodeBlock>
|
||||
|
||||
<h2>Types</h2>
|
||||
<p>
|
||||
Das SDK exportiert alle TypeScript-Types für volle Typsicherheit:
|
||||
</p>
|
||||
<CodeBlock language="typescript" filename="types.ts">
|
||||
{`import type {
|
||||
// Core Types
|
||||
SDKState,
|
||||
SDKAction,
|
||||
SDKStep,
|
||||
SDKPhase,
|
||||
|
||||
// Use Cases
|
||||
UseCaseAssessment,
|
||||
AssessmentResult,
|
||||
|
||||
// Risk Management
|
||||
Risk,
|
||||
RiskSeverity,
|
||||
RiskMitigation,
|
||||
|
||||
// Controls & Evidence
|
||||
Control,
|
||||
Evidence,
|
||||
Requirement,
|
||||
|
||||
// Checkpoints
|
||||
Checkpoint,
|
||||
CheckpointStatus,
|
||||
ValidationError,
|
||||
|
||||
// DSFA
|
||||
DSFA,
|
||||
DSFASection,
|
||||
DSFAApproval,
|
||||
|
||||
// TOMs & VVT
|
||||
TOM,
|
||||
ProcessingActivity,
|
||||
RetentionPolicy,
|
||||
|
||||
// AI Act
|
||||
AIActResult,
|
||||
AIActRiskCategory,
|
||||
} from '@breakpilot/compliance-sdk'`}
|
||||
</CodeBlock>
|
||||
|
||||
<h2>Utility Functions</h2>
|
||||
<p>
|
||||
Hilfreiche Funktionen für die Arbeit mit dem SDK:
|
||||
</p>
|
||||
<CodeBlock language="typescript" filename="utils.ts">
|
||||
{`import {
|
||||
// Step Navigation
|
||||
getStepById,
|
||||
getStepByUrl,
|
||||
getNextStep,
|
||||
getPreviousStep,
|
||||
getStepsForPhase,
|
||||
|
||||
// Risk Calculation
|
||||
calculateRiskScore,
|
||||
getRiskSeverityFromScore,
|
||||
calculateResidualRisk,
|
||||
|
||||
// Progress
|
||||
getCompletionPercentage,
|
||||
getPhaseCompletionPercentage,
|
||||
} from '@breakpilot/compliance-sdk'
|
||||
|
||||
// Beispiel: Risk Score berechnen
|
||||
const inherentRisk = calculateRiskScore(4, 5) // likelihood * impact = 20
|
||||
const severity = getRiskSeverityFromScore(20) // 'CRITICAL'`}
|
||||
</CodeBlock>
|
||||
|
||||
<h2>API Client</h2>
|
||||
<p>
|
||||
Für direkten API-Zugriff ohne React Context:
|
||||
</p>
|
||||
<CodeBlock language="typescript" filename="api.ts">
|
||||
{`import {
|
||||
getSDKApiClient,
|
||||
SDKApiClient,
|
||||
} from '@breakpilot/compliance-sdk'
|
||||
|
||||
const client = getSDKApiClient('your-tenant-id')
|
||||
|
||||
// State laden
|
||||
const state = await client.getState()
|
||||
|
||||
// State speichern
|
||||
await client.saveState(updatedState)
|
||||
|
||||
// Checkpoint validieren
|
||||
const result = await client.validateCheckpoint('CP-UC', state)
|
||||
|
||||
// Export
|
||||
const blob = await client.exportState('pdf')`}
|
||||
</CodeBlock>
|
||||
|
||||
<h2>RAG & LLM Client</h2>
|
||||
<p>
|
||||
Zugriff auf die RAG-Suche und Dokumentengenerierung:
|
||||
</p>
|
||||
<CodeBlock language="typescript" filename="rag.ts">
|
||||
{`import {
|
||||
getSDKBackendClient,
|
||||
isLegalQuery,
|
||||
} from '@breakpilot/compliance-sdk'
|
||||
|
||||
const client = getSDKBackendClient()
|
||||
|
||||
// RAG-Suche
|
||||
const results = await client.search('DSGVO Art. 5', 5)
|
||||
console.log(results) // SearchResult[]
|
||||
|
||||
// Dokumentengenerierung
|
||||
const dsfa = await client.generateDSFA(context)
|
||||
const toms = await client.generateTOM(context)
|
||||
const vvt = await client.generateVVT(context)
|
||||
|
||||
// Prüfen ob eine Query rechtliche Inhalte betrifft
|
||||
if (isLegalQuery('Einwilligung DSGVO')) {
|
||||
// RAG-Suche durchführen
|
||||
}`}
|
||||
</CodeBlock>
|
||||
|
||||
<h2>Export</h2>
|
||||
<p>
|
||||
Exportieren Sie Compliance-Daten in verschiedenen Formaten:
|
||||
</p>
|
||||
<CodeBlock language="typescript" filename="export.ts">
|
||||
{`import { exportToPDF, exportToZIP, downloadExport } from '@breakpilot/compliance-sdk'
|
||||
|
||||
// PDF Export
|
||||
const pdfBlob = await exportToPDF(state)
|
||||
downloadExport(pdfBlob, 'compliance-report.pdf')
|
||||
|
||||
// ZIP Export (alle Dokumente)
|
||||
const zipBlob = await exportToZIP(state)
|
||||
downloadExport(zipBlob, 'compliance-export.zip')
|
||||
|
||||
// Über den Hook
|
||||
const { exportState } = useSDK()
|
||||
const blob = await exportState('pdf') // 'json' | 'pdf' | 'zip'`}
|
||||
</CodeBlock>
|
||||
|
||||
<InfoBox type="success" title="Weitere Dokumentation">
|
||||
<ul className="list-disc list-inside space-y-1">
|
||||
<li>
|
||||
<Link href="/developers/sdk/installation" className="text-blue-600 hover:underline">
|
||||
Installation Guide
|
||||
</Link>
|
||||
</li>
|
||||
<li>
|
||||
<Link href="/developers/sdk/configuration" className="text-blue-600 hover:underline">
|
||||
Konfigurationsoptionen
|
||||
</Link>
|
||||
</li>
|
||||
<li>
|
||||
<Link href="/developers/guides/phase1" className="text-blue-600 hover:underline">
|
||||
Phase 1 Workflow Guide
|
||||
</Link>
|
||||
</li>
|
||||
</ul>
|
||||
</InfoBox>
|
||||
</DevPortalLayout>
|
||||
)
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,39 @@
|
||||
'use client'
|
||||
|
||||
import { PagePurpose } from '@/components/common/PagePurpose'
|
||||
import { getModuleByHref } from '@/lib/navigation'
|
||||
import { GraduationCap, Construction } from 'lucide-react'
|
||||
|
||||
export default function CompanionPage() {
|
||||
const moduleInfo = getModuleByHref('/development/companion')
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{moduleInfo && (
|
||||
<PagePurpose
|
||||
title={moduleInfo.module.name}
|
||||
purpose={moduleInfo.module.purpose}
|
||||
audience={moduleInfo.module.audience}
|
||||
collapsible={true}
|
||||
defaultCollapsed={true}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="bg-white border border-slate-200 rounded-xl p-8 text-center">
|
||||
<div className="flex justify-center mb-4">
|
||||
<div className="p-4 bg-slate-100 rounded-full">
|
||||
<GraduationCap className="w-12 h-12 text-slate-400" />
|
||||
</div>
|
||||
</div>
|
||||
<h2 className="text-xl font-semibold text-slate-800 mb-2">Companion Dev</h2>
|
||||
<p className="text-slate-600 mb-4">
|
||||
Lesson-Modus Entwicklung fuer strukturiertes Lernen.
|
||||
</p>
|
||||
<div className="inline-flex items-center gap-2 px-4 py-2 bg-amber-50 border border-amber-200 rounded-lg text-amber-700">
|
||||
<Construction className="w-4 h-4" />
|
||||
<span className="text-sm font-medium">In Entwicklung</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,216 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect } from 'react'
|
||||
import { PagePurpose } from '@/components/common/PagePurpose'
|
||||
import { getModuleByHref } from '@/lib/navigation'
|
||||
import { ExternalLink, Maximize2, Minimize2, RefreshCw, Search, BookOpen, ArrowRight } from 'lucide-react'
|
||||
|
||||
// Quick links to important documentation sections
|
||||
const quickLinks = [
|
||||
{ name: 'Architektur', path: '#architektur', icon: '🏗️' },
|
||||
{ name: 'Klausur-Service', path: 'services/klausur-service/', icon: '📝' },
|
||||
{ name: 'AI-Compliance-SDK', path: 'services/ai-compliance-sdk/', icon: '🔒' },
|
||||
{ name: 'Voice-Service', path: 'services/voice-service/', icon: '🎤' },
|
||||
{ name: 'Agent-Core', path: 'services/agent-core/', icon: '🤖' },
|
||||
{ name: 'CI/CD Pipeline', path: 'development/ci-cd-pipeline/', icon: '🚀' },
|
||||
]
|
||||
|
||||
export default function DocsPage() {
|
||||
const [isFullscreen, setIsFullscreen] = useState(false)
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
const [currentPath, setCurrentPath] = useState('')
|
||||
const moduleInfo = getModuleByHref('/development/docs')
|
||||
|
||||
// Determine docs URL based on environment
|
||||
// Use same-origin proxy at /docs/ to avoid mixed content issues (HTTPS -> HTTP)
|
||||
const getDocsUrl = () => {
|
||||
if (typeof window !== 'undefined') {
|
||||
// Use same-origin proxy path to avoid mixed content issues
|
||||
const protocol = window.location.protocol
|
||||
const hostname = window.location.hostname
|
||||
const port = window.location.port
|
||||
return `${protocol}//${hostname}${port ? ':' + port : ''}/docs`
|
||||
}
|
||||
return '/docs'
|
||||
}
|
||||
|
||||
const docsUrl = getDocsUrl()
|
||||
|
||||
const handleIframeLoad = () => {
|
||||
setIsLoading(false)
|
||||
}
|
||||
|
||||
const navigateTo = (path: string) => {
|
||||
setCurrentPath(path)
|
||||
setIsLoading(true)
|
||||
}
|
||||
|
||||
const toggleFullscreen = () => {
|
||||
setIsFullscreen(!isFullscreen)
|
||||
}
|
||||
|
||||
const openInNewTab = () => {
|
||||
window.open(`${docsUrl}/${currentPath}`, '_blank')
|
||||
}
|
||||
|
||||
const refreshDocs = () => {
|
||||
setIsLoading(true)
|
||||
// Force iframe reload by toggling key
|
||||
setCurrentPath(currentPath + '?refresh=' + Date.now())
|
||||
setTimeout(() => setCurrentPath(currentPath), 100)
|
||||
}
|
||||
|
||||
if (isFullscreen) {
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 bg-white">
|
||||
{/* Fullscreen Toolbar */}
|
||||
<div className="absolute top-0 left-0 right-0 h-12 bg-slate-900 flex items-center justify-between px-4 z-10">
|
||||
<div className="flex items-center gap-2 text-white">
|
||||
<BookOpen className="w-5 h-5" />
|
||||
<span className="font-semibold">Breakpilot Dokumentation</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={openInNewTab}
|
||||
className="p-2 text-slate-300 hover:text-white hover:bg-slate-700 rounded transition-colors"
|
||||
title="In neuem Tab oeffnen"
|
||||
>
|
||||
<ExternalLink className="w-4 h-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={toggleFullscreen}
|
||||
className="p-2 text-slate-300 hover:text-white hover:bg-slate-700 rounded transition-colors"
|
||||
title="Vollbild beenden"
|
||||
>
|
||||
<Minimize2 className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<iframe
|
||||
src={`${docsUrl}/${currentPath}`}
|
||||
className="w-full h-full pt-12"
|
||||
title="Breakpilot Documentation"
|
||||
onLoad={handleIframeLoad}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Page Purpose */}
|
||||
{moduleInfo && (
|
||||
<PagePurpose
|
||||
title={moduleInfo.module.name}
|
||||
purpose={moduleInfo.module.purpose}
|
||||
audience={moduleInfo.module.audience}
|
||||
architecture={{
|
||||
services: ['MkDocs (Static Site)', 'Nginx (Port 8009)'],
|
||||
databases: [],
|
||||
}}
|
||||
relatedPages={[
|
||||
{ name: 'CI/CD', href: '/infrastructure/ci-cd', description: 'Deployment Pipeline' },
|
||||
{ name: 'Architektur', href: '/architecture', description: 'System-Uebersicht' },
|
||||
{ name: 'SBOM', href: '/infrastructure/sbom', description: 'Abhaengigkeiten' },
|
||||
]}
|
||||
collapsible={true}
|
||||
defaultCollapsed={true}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Quick Links */}
|
||||
<div className="bg-white border border-slate-200 rounded-xl p-4">
|
||||
<h3 className="text-sm font-semibold text-slate-700 mb-3 flex items-center gap-2">
|
||||
<Search className="w-4 h-4" />
|
||||
Schnellzugriff
|
||||
</h3>
|
||||
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-6 gap-2">
|
||||
{quickLinks.map((link) => (
|
||||
<button
|
||||
key={link.path}
|
||||
onClick={() => navigateTo(link.path)}
|
||||
className="flex items-center gap-2 px-3 py-2 text-sm bg-slate-50 hover:bg-slate-100 border border-slate-200 rounded-lg transition-colors text-left"
|
||||
>
|
||||
<span>{link.icon}</span>
|
||||
<span className="truncate">{link.name}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Toolbar */}
|
||||
<div className="flex items-center justify-between bg-white border border-slate-200 rounded-xl p-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<BookOpen className="w-5 h-5 text-slate-500" />
|
||||
<span className="text-sm font-medium text-slate-700">
|
||||
Breakpilot Dokumentation
|
||||
</span>
|
||||
<span className="text-xs text-slate-400">
|
||||
(MkDocs Material)
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={refreshDocs}
|
||||
className="p-2 text-slate-500 hover:text-slate-700 hover:bg-slate-100 rounded-lg transition-colors"
|
||||
title="Aktualisieren"
|
||||
>
|
||||
<RefreshCw className={`w-4 h-4 ${isLoading ? 'animate-spin' : ''}`} />
|
||||
</button>
|
||||
<button
|
||||
onClick={openInNewTab}
|
||||
className="p-2 text-slate-500 hover:text-slate-700 hover:bg-slate-100 rounded-lg transition-colors"
|
||||
title="In neuem Tab oeffnen"
|
||||
>
|
||||
<ExternalLink className="w-4 h-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={toggleFullscreen}
|
||||
className="p-2 text-slate-500 hover:text-slate-700 hover:bg-slate-100 rounded-lg transition-colors"
|
||||
title="Vollbild"
|
||||
>
|
||||
<Maximize2 className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Documentation Iframe */}
|
||||
<div className="relative bg-white border border-slate-200 rounded-xl overflow-hidden" style={{ height: 'calc(100vh - 400px)', minHeight: '500px' }}>
|
||||
{isLoading && (
|
||||
<div className="absolute inset-0 bg-white flex items-center justify-center z-10">
|
||||
<div className="flex flex-col items-center gap-3">
|
||||
<div className="w-8 h-8 border-2 border-slate-300 border-t-slate-600 rounded-full animate-spin" />
|
||||
<span className="text-sm text-slate-500">Dokumentation wird geladen...</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<iframe
|
||||
key={currentPath}
|
||||
src={`${docsUrl}/${currentPath}`}
|
||||
className="w-full h-full"
|
||||
title="Breakpilot Documentation"
|
||||
onLoad={handleIframeLoad}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Info Box */}
|
||||
<div className="bg-slate-50 border border-slate-200 rounded-xl p-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="p-2 bg-slate-200 rounded-lg">
|
||||
<ArrowRight className="w-4 h-4 text-slate-600" />
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="font-medium text-slate-800">Dokumentation bearbeiten</h4>
|
||||
<p className="text-sm text-slate-600 mt-1">
|
||||
Die Dokumentation befindet sich im Repository unter <code className="text-xs bg-slate-200 px-1.5 py-0.5 rounded">docs-src/</code>.
|
||||
Nach Aenderungen muss der Docs-Container neu gebaut werden.
|
||||
</p>
|
||||
<div className="mt-2 text-xs text-slate-500 font-mono bg-slate-100 p-2 rounded">
|
||||
rsync docs-src/ macmini:... && ssh macmini "docker compose build docs && docker compose up -d docs"
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
'use client'
|
||||
|
||||
import { PagePurpose } from '@/components/common/PagePurpose'
|
||||
import { getModuleByHref } from '@/lib/navigation'
|
||||
import { Gamepad2, Construction } from 'lucide-react'
|
||||
|
||||
export default function GamePage() {
|
||||
const moduleInfo = getModuleByHref('/development/game')
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{moduleInfo && (
|
||||
<PagePurpose
|
||||
title={moduleInfo.module.name}
|
||||
purpose={moduleInfo.module.purpose}
|
||||
audience={moduleInfo.module.audience}
|
||||
collapsible={true}
|
||||
defaultCollapsed={true}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="bg-white border border-slate-200 rounded-xl p-8 text-center">
|
||||
<div className="flex justify-center mb-4">
|
||||
<div className="p-4 bg-slate-100 rounded-full">
|
||||
<Gamepad2 className="w-12 h-12 text-slate-400" />
|
||||
</div>
|
||||
</div>
|
||||
<h2 className="text-xl font-semibold text-slate-800 mb-2">Breakpilot Drive</h2>
|
||||
<p className="text-slate-600 mb-4">
|
||||
Lernspiel-Management fuer Level, Inhalte und Lernziele.
|
||||
</p>
|
||||
<div className="inline-flex items-center gap-2 px-4 py-2 bg-amber-50 border border-amber-200 rounded-lg text-amber-700">
|
||||
<Construction className="w-4 h-4" />
|
||||
<span className="text-sm font-medium">In Entwicklung</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
'use client'
|
||||
|
||||
import { getCategoryById } from '@/lib/navigation'
|
||||
import { ModuleCard } from '@/components/common/ModuleCard'
|
||||
import { PagePurpose } from '@/components/common/PagePurpose'
|
||||
|
||||
export default function DevelopmentPage() {
|
||||
const category = getCategoryById('development')
|
||||
|
||||
if (!category) {
|
||||
return <div>Kategorie nicht gefunden</div>
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* Page Purpose */}
|
||||
<PagePurpose
|
||||
title={category.name}
|
||||
purpose="Diese Kategorie umfasst alle Entwicklungs- und Produkt-Module. Hier konfigurieren Sie den Voice-Service, verwalten Spielinhalte, erstellen Dokumentation und pflegen das Brandbook."
|
||||
audience={['Entwickler', 'Designer', 'Content Manager']}
|
||||
architecture={{
|
||||
services: ['voice-service (Python)', 'breakpilot-drive (Unity)', 'backend (Python)'],
|
||||
databases: ['PostgreSQL', 'MinIO'],
|
||||
}}
|
||||
relatedPages={[
|
||||
{ name: 'GPU Infrastruktur', href: '/infrastructure/gpu', description: 'GPU fuer Voice/Game' },
|
||||
{ name: 'LLM Vergleich', href: '/ai/llm-compare', description: 'LLM fuer Voice/Game' },
|
||||
]}
|
||||
collapsible={true}
|
||||
defaultCollapsed={false}
|
||||
/>
|
||||
|
||||
{/* Modules Grid */}
|
||||
<h2 className="text-lg font-semibold text-slate-900 mb-4">Module</h2>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{category.modules.map((module) => (
|
||||
<ModuleCard key={module.id} module={module} category={category} />
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Info Section */}
|
||||
<div className="mt-8 bg-slate-100 border border-slate-300 rounded-xl p-6">
|
||||
<h3 className="font-semibold text-slate-800 flex items-center gap-2">
|
||||
<span>💻</span>
|
||||
Entwickler-Ressourcen
|
||||
</h3>
|
||||
<p className="text-sm text-slate-700 mt-2">
|
||||
Die Developer Docs enthalten alle API-Dokumentationen und Architektur-Diagramme.
|
||||
Das Brandbook definiert Corporate-Design-Richtlinien fuer konsistente UI/UX.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,797 @@
|
||||
'use client'
|
||||
|
||||
/**
|
||||
* Screen Flow Visualization
|
||||
*
|
||||
* Visualisiert alle Screens aus:
|
||||
* - Studio (Port 8000): Lehrer-Oberflaeche
|
||||
* - Admin v2 (Port 3002): Admin Panel
|
||||
*/
|
||||
|
||||
import { useCallback, useState, useMemo, useEffect } from 'react'
|
||||
import ReactFlow, {
|
||||
Node,
|
||||
Edge,
|
||||
Controls,
|
||||
Background,
|
||||
MiniMap,
|
||||
useNodesState,
|
||||
useEdgesState,
|
||||
BackgroundVariant,
|
||||
MarkerType,
|
||||
Panel,
|
||||
} from 'reactflow'
|
||||
import 'reactflow/dist/style.css'
|
||||
|
||||
// ============================================
|
||||
// TYPES
|
||||
// ============================================
|
||||
|
||||
interface ScreenDefinition {
|
||||
id: string
|
||||
name: string
|
||||
description: string
|
||||
category: string
|
||||
icon: string
|
||||
url?: string
|
||||
}
|
||||
|
||||
interface ConnectionDef {
|
||||
source: string
|
||||
target: string
|
||||
label?: string
|
||||
}
|
||||
|
||||
type FlowType = 'studio' | 'admin'
|
||||
|
||||
// ============================================
|
||||
// STUDIO SCREENS (Port 8000)
|
||||
// ============================================
|
||||
|
||||
const STUDIO_SCREENS: ScreenDefinition[] = [
|
||||
{ id: 'lehrer-dashboard', name: 'Mein Dashboard', description: 'Hauptuebersicht mit Widgets', category: 'navigation', icon: '🏠', url: '/app#lehrer-dashboard' },
|
||||
{ id: 'lehrer-onboarding', name: 'Erste Schritte', description: 'Onboarding & Schnellstart', category: 'navigation', icon: '🚀', url: '/app#lehrer-onboarding' },
|
||||
{ id: 'hilfe', name: 'Dokumentation', description: 'Hilfe & Anleitungen', category: 'navigation', icon: '📚', url: '/app#hilfe' },
|
||||
{ id: 'worksheets', name: 'Arbeitsblaetter Studio', description: 'Lernmaterialien erstellen', category: 'content', icon: '📝', url: '/app#worksheets' },
|
||||
{ id: 'content-creator', name: 'Content Creator', description: 'Inhalte erstellen', category: 'content', icon: '✨', url: '/app#content-creator' },
|
||||
{ id: 'content-feed', name: 'Content Feed', description: 'Inhalte durchsuchen', category: 'content', icon: '📰', url: '/app#content-feed' },
|
||||
{ id: 'unit-creator', name: 'Unit Creator', description: 'Lerneinheiten erstellen', category: 'content', icon: '📦', url: '/app#unit-creator' },
|
||||
{ id: 'letters', name: 'Briefe & Vorlagen', description: 'Brief-Generator', category: 'content', icon: '✉️', url: '/app#letters' },
|
||||
{ id: 'correction', name: 'Korrektur', description: 'Arbeiten korrigieren', category: 'content', icon: '✏️', url: '/app#correction' },
|
||||
{ id: 'klausur-korrektur', name: 'Abiturklausuren', description: 'KI-gestuetzte Klausurkorrektur', category: 'content', icon: '📋', url: '/app#klausur-korrektur' },
|
||||
{ id: 'jitsi', name: 'Videokonferenz', description: 'Jitsi Meet Integration', category: 'communication', icon: '🎥', url: '/app#jitsi' },
|
||||
{ id: 'messenger', name: 'Messenger', description: 'Matrix E2EE Chat', category: 'communication', icon: '💬', url: '/app#messenger' },
|
||||
{ id: 'mail', name: 'Unified Inbox', description: 'E-Mail Verwaltung', category: 'communication', icon: '📧', url: '/app#mail' },
|
||||
{ id: 'school-classes', name: 'Klassen', description: 'Klassenverwaltung', category: 'school', icon: '👥', url: '/app#school-classes' },
|
||||
{ id: 'school-exams', name: 'Pruefungen', description: 'Pruefungsverwaltung', category: 'school', icon: '📝', url: '/app#school-exams' },
|
||||
{ id: 'school-grades', name: 'Noten', description: 'Notenverwaltung', category: 'school', icon: '📊', url: '/app#school-grades' },
|
||||
{ id: 'school-gradebook', name: 'Notenbuch', description: 'Digitales Notenbuch', category: 'school', icon: '📖', url: '/app#school-gradebook' },
|
||||
{ id: 'school-certificates', name: 'Zeugnisse', description: 'Zeugniserstellung', category: 'school', icon: '🎓', url: '/app#school-certificates' },
|
||||
{ id: 'companion', name: 'Begleiter & Stunde', description: 'KI-Unterrichtsassistent', category: 'ai', icon: '🤖', url: '/app#companion' },
|
||||
{ id: 'alerts', name: 'Alerts', description: 'News & Benachrichtigungen', category: 'ai', icon: '🔔', url: '/app#alerts' },
|
||||
{ id: 'admin', name: 'Einstellungen', description: 'Systemeinstellungen', category: 'admin', icon: '⚙️', url: '/app#admin' },
|
||||
{ id: 'rbac-admin', name: 'Rollen & Rechte', description: 'Berechtigungsverwaltung', category: 'admin', icon: '🔐', url: '/app#rbac-admin' },
|
||||
{ id: 'abitur-docs-admin', name: 'Abitur Dokumente', description: 'Erwartungshorizonte', category: 'admin', icon: '📄', url: '/app#abitur-docs-admin' },
|
||||
{ id: 'system-info', name: 'System Info', description: 'Systeminformationen', category: 'admin', icon: '💻', url: '/app#system-info' },
|
||||
{ id: 'workflow', name: 'Workflow', description: 'Automatisierungen', category: 'admin', icon: '⚡', url: '/app#workflow' },
|
||||
]
|
||||
|
||||
const STUDIO_CONNECTIONS: ConnectionDef[] = [
|
||||
{ source: 'lehrer-onboarding', target: 'worksheets', label: 'Arbeitsblaetter' },
|
||||
{ source: 'lehrer-onboarding', target: 'klausur-korrektur', label: 'Abiturklausuren' },
|
||||
{ source: 'lehrer-onboarding', target: 'correction', label: 'Korrektur' },
|
||||
{ source: 'lehrer-onboarding', target: 'letters', label: 'Briefe' },
|
||||
{ source: 'lehrer-onboarding', target: 'school-classes', label: 'Klassen' },
|
||||
{ source: 'lehrer-onboarding', target: 'jitsi', label: 'Meet' },
|
||||
{ source: 'lehrer-onboarding', target: 'hilfe', label: 'Doku' },
|
||||
{ source: 'lehrer-onboarding', target: 'admin', label: 'Settings' },
|
||||
{ source: 'lehrer-dashboard', target: 'worksheets' },
|
||||
{ source: 'lehrer-dashboard', target: 'correction' },
|
||||
{ source: 'lehrer-dashboard', target: 'jitsi' },
|
||||
{ source: 'lehrer-dashboard', target: 'letters' },
|
||||
{ source: 'lehrer-dashboard', target: 'messenger' },
|
||||
{ source: 'lehrer-dashboard', target: 'klausur-korrektur' },
|
||||
{ source: 'lehrer-dashboard', target: 'companion' },
|
||||
{ source: 'lehrer-dashboard', target: 'alerts' },
|
||||
{ source: 'lehrer-dashboard', target: 'mail' },
|
||||
{ source: 'lehrer-dashboard', target: 'school-classes' },
|
||||
{ source: 'lehrer-dashboard', target: 'lehrer-onboarding', label: 'Sidebar' },
|
||||
{ source: 'school-classes', target: 'school-exams' },
|
||||
{ source: 'school-classes', target: 'school-grades' },
|
||||
{ source: 'school-grades', target: 'school-gradebook' },
|
||||
{ source: 'school-gradebook', target: 'school-certificates' },
|
||||
{ source: 'worksheets', target: 'content-creator' },
|
||||
{ source: 'worksheets', target: 'unit-creator' },
|
||||
{ source: 'content-creator', target: 'content-feed' },
|
||||
{ source: 'klausur-korrektur', target: 'abitur-docs-admin' },
|
||||
{ source: 'admin', target: 'rbac-admin' },
|
||||
{ source: 'admin', target: 'system-info' },
|
||||
{ source: 'admin', target: 'workflow' },
|
||||
]
|
||||
|
||||
// ============================================
|
||||
// ADMIN v2 SCREENS (Port 3002)
|
||||
// Based on navigation.ts - Last updated: 2026-02-03
|
||||
// ============================================
|
||||
|
||||
const ADMIN_SCREENS: ScreenDefinition[] = [
|
||||
// === META / OVERVIEW ===
|
||||
{ id: 'admin-dashboard', name: 'Dashboard', description: 'Uebersicht & Statistiken', category: 'overview', icon: '🏠', url: '/dashboard' },
|
||||
{ id: 'admin-onboarding', name: 'Onboarding', description: 'Lern-Wizards fuer alle Module', category: 'overview', icon: '📖', url: '/onboarding' },
|
||||
{ id: 'admin-architecture', name: 'Architektur', description: 'Backend-Module & Datenfluss', category: 'overview', icon: '🏗️', url: '/architecture' },
|
||||
{ id: 'admin-backlog', name: 'Production Backlog', description: 'Go-Live Checkliste', category: 'overview', icon: '📝', url: '/backlog' },
|
||||
{ id: 'admin-rbac', name: 'RBAC', description: 'Rollen & Berechtigungen', category: 'overview', icon: '👥', url: '/rbac' },
|
||||
|
||||
// === COMPLIANCE SDK (Violet #8b5cf6) ===
|
||||
// DSGVO - Datenschutz & Betroffenenrechte
|
||||
{ id: 'admin-consent', name: 'Consent Verwaltung', description: 'Rechtliche Dokumente & Versionen', category: 'sdk', icon: '📄', url: '/sdk/consent-management' },
|
||||
{ id: 'admin-dsr', name: 'Datenschutzanfragen', description: 'DSGVO Art. 15-21', category: 'sdk', icon: '🔒', url: '/sdk/dsr' },
|
||||
{ id: 'admin-einwilligungen', name: 'Einwilligungen', description: 'Nutzer-Consent Uebersicht', category: 'sdk', icon: '✅', url: '/sdk/einwilligungen' },
|
||||
{ id: 'admin-vvt', name: 'VVT', description: 'Verarbeitungsverzeichnis Art. 30', category: 'sdk', icon: '📋', url: '/sdk/vvt' },
|
||||
{ id: 'admin-dsfa', name: 'DSFA', description: 'Datenschutz-Folgenabschaetzung', category: 'sdk', icon: '⚖️', url: '/sdk/dsfa' },
|
||||
{ id: 'admin-tom', name: 'TOMs', description: 'Technische & Org. Massnahmen', category: 'sdk', icon: '🛡️', url: '/sdk/tom' },
|
||||
{ id: 'admin-loeschfristen', name: 'Loeschfristen', description: 'Aufbewahrung & Deadlines', category: 'sdk', icon: '🗑️', url: '/sdk/loeschfristen' },
|
||||
{ id: 'admin-advisory-board', name: 'Advisory Board', description: 'KI-Use-Case Pruefung', category: 'sdk', icon: '🧑⚖️', url: '/sdk/advisory-board' },
|
||||
{ id: 'admin-escalations', name: 'Eskalations-Queue', description: 'DSB Review & Freigabe', category: 'sdk', icon: '🚨', url: '/sdk/escalations' },
|
||||
// Compliance - Audit, GRC & Regulatorik
|
||||
{ id: 'admin-compliance-hub', name: 'Compliance Hub', description: 'Zentrales GRC Dashboard', category: 'sdk', icon: '✅', url: '/sdk/compliance-hub' },
|
||||
{ id: 'admin-audit-checklist', name: 'Audit Checkliste', description: '476 Anforderungen pruefen', category: 'sdk', icon: '📋', url: '/sdk/audit-checklist' },
|
||||
{ id: 'admin-requirements', name: 'Requirements', description: '558+ aus 19 Verordnungen', category: 'sdk', icon: '📜', url: '/sdk/requirements' },
|
||||
{ id: 'admin-controls', name: 'Controls', description: '474 Control-Mappings', category: 'sdk', icon: '🎛️', url: '/sdk/controls' },
|
||||
{ id: 'admin-evidence', name: 'Evidence', description: 'Nachweise & Dokumentation', category: 'sdk', icon: '📎', url: '/sdk/evidence' },
|
||||
{ id: 'admin-risks', name: 'Risiken', description: 'Risk Matrix & Register', category: 'sdk', icon: '⚠️', url: '/sdk/risks' },
|
||||
{ id: 'admin-audit-report', name: 'Audit Report', description: 'PDF Audit-Berichte', category: 'sdk', icon: '📊', url: '/sdk/audit-report' },
|
||||
{ id: 'admin-modules', name: 'Service Registry', description: '30+ Service-Module', category: 'sdk', icon: '🔧', url: '/sdk/modules' },
|
||||
{ id: 'admin-dsms', name: 'DSMS', description: 'Datenschutz-Management-System', category: 'sdk', icon: '🏛️', url: '/sdk/dsms' },
|
||||
{ id: 'admin-compliance-workflow', name: 'Workflow', description: 'Freigabe-Workflows', category: 'sdk', icon: '🔄', url: '/sdk/workflow' },
|
||||
{ id: 'admin-source-policy', name: 'Quellen-Policy', description: 'Datenquellen & Compliance', category: 'sdk', icon: '📚', url: '/sdk/source-policy' },
|
||||
{ id: 'admin-ai-act', name: 'EU-AI-Act', description: 'KI-Risikoklassifizierung', category: 'sdk', icon: '🤖', url: '/sdk/ai-act' },
|
||||
{ id: 'admin-obligations', name: 'Pflichten', description: 'NIS2, DSGVO, AI Act', category: 'sdk', icon: '⚡', url: '/sdk/obligations' },
|
||||
|
||||
// === KI & AUTOMATISIERUNG (Teal #14b8a6) ===
|
||||
{ id: 'admin-llm-compare', name: 'LLM Vergleich', description: 'KI-Provider Vergleich', category: 'ai', icon: '🤖', url: '/ai/llm-compare' },
|
||||
{ id: 'admin-rag', name: 'Daten & RAG', description: 'Training Data & RAG', category: 'ai', icon: '🗄️', url: '/ai/rag' },
|
||||
{ id: 'admin-ocr-labeling', name: 'OCR-Labeling', description: 'Handschrift-Training', category: 'ai', icon: '✍️', url: '/ai/ocr-labeling' },
|
||||
{ id: 'admin-magic-help', name: 'Magic Help', description: 'TrOCR Handschrift-OCR', category: 'ai', icon: '🪄', url: '/ai/magic-help' },
|
||||
{ id: 'admin-klausur-korrektur', name: 'Klausur-Korrektur', description: 'Abitur-Korrektur mit KI', category: 'ai', icon: '📝', url: '/ai/klausur-korrektur' },
|
||||
{ id: 'admin-quality', name: 'Qualitaet & Audit', description: 'Compliance-Audit & Traceability', category: 'ai', icon: '✨', url: '/ai/quality' },
|
||||
{ id: 'admin-test-quality', name: 'Test Quality (BQAS)', description: 'Golden Suite & Synthetic Tests', category: 'ai', icon: '🧪', url: '/ai/test-quality' },
|
||||
{ id: 'admin-agents', name: 'Agent Management', description: 'Multi-Agent & SOUL-Editor', category: 'ai', icon: '🧠', url: '/ai/agents' },
|
||||
|
||||
// === INFRASTRUKTUR (Orange #f97316) ===
|
||||
{ id: 'admin-gpu', name: 'GPU Infrastruktur', description: 'vast.ai GPU Management', category: 'infrastructure', icon: '🖥️', url: '/infrastructure/gpu' },
|
||||
{ id: 'admin-middleware', name: 'Middleware', description: 'Stack & API Gateway', category: 'infrastructure', icon: '🔧', url: '/infrastructure/middleware' },
|
||||
{ id: 'admin-security', name: 'Security', description: 'DevSecOps & Scans', category: 'infrastructure', icon: '🔐', url: '/infrastructure/security' },
|
||||
{ id: 'admin-sbom', name: 'SBOM', description: 'Software Bill of Materials', category: 'infrastructure', icon: '📦', url: '/infrastructure/sbom' },
|
||||
{ id: 'admin-cicd', name: 'CI/CD', description: 'Pipelines & Deployments', category: 'infrastructure', icon: '🔄', url: '/infrastructure/ci-cd' },
|
||||
{ id: 'admin-tests', name: 'Test Dashboard', description: '195+ Tests & Coverage', category: 'infrastructure', icon: '🧪', url: '/infrastructure/tests' },
|
||||
|
||||
// === BILDUNG (Blue #3b82f6) ===
|
||||
{ id: 'admin-edu-search', name: 'Education Search', description: 'Bildungsquellen & Crawler', category: 'education', icon: '🔍', url: '/education/edu-search' },
|
||||
{ id: 'admin-zeugnisse', name: 'Zeugnisse-Crawler', description: 'Zeugnis-Daten', category: 'education', icon: '📜', url: '/education/zeugnisse-crawler' },
|
||||
{ id: 'admin-rag-pipeline', name: 'RAG Pipeline', description: 'Bildungsdokumente indexieren', category: 'ai', icon: '🔗', url: '/ai/rag-pipeline' },
|
||||
{ id: 'admin-foerderantrag', name: 'Foerderantrag-Wizard', description: 'DigitalPakt & Landesfoerderung', category: 'education', icon: '💰', url: '/education/foerderantrag' },
|
||||
|
||||
// === KOMMUNIKATION (Green #22c55e) ===
|
||||
{ id: 'admin-video', name: 'Video & Chat', description: 'Matrix & Jitsi Monitoring', category: 'communication', icon: '🎥', url: '/communication/video-chat' },
|
||||
{ id: 'admin-matrix', name: 'Voice Service', description: 'Voice-First Interface', category: 'communication', icon: '🎙️', url: '/communication/matrix' },
|
||||
{ id: 'admin-mail', name: 'Unified Inbox', description: 'E-Mail & KI-Analyse', category: 'communication', icon: '📧', url: '/communication/mail' },
|
||||
{ id: 'admin-alerts', name: 'Alerts Monitoring', description: 'Google Alerts & Feeds', category: 'communication', icon: '🔔', url: '/communication/alerts' },
|
||||
|
||||
// === ENTWICKLUNG (Slate #64748b) ===
|
||||
{ id: 'admin-workflow', name: 'Dev Workflow', description: 'Git, CI/CD & Team-Regeln', category: 'development', icon: '⚡', url: '/development/workflow' },
|
||||
{ id: 'admin-game', name: 'Breakpilot Drive', description: 'Lernspiel Management', category: 'development', icon: '🎮', url: '/development/game' },
|
||||
{ id: 'admin-unity', name: 'Unity Bridge', description: 'Unity Editor Steuerung', category: 'development', icon: '🎯', url: '/development/unity-bridge' },
|
||||
{ id: 'admin-companion', name: 'Companion Dev', description: 'Lesson-Modus Entwicklung', category: 'development', icon: '📚', url: '/development/companion' },
|
||||
{ id: 'admin-docs', name: 'Developer Docs', description: 'API & Architektur', category: 'development', icon: '📖', url: '/development/docs' },
|
||||
{ id: 'admin-brandbook', name: 'Brandbook', description: 'Corporate Design', category: 'development', icon: '🎨', url: '/development/brandbook' },
|
||||
{ id: 'admin-screen-flow', name: 'Screen Flow', description: 'UI Screen-Verbindungen', category: 'development', icon: '🔀', url: '/development/screen-flow' },
|
||||
{ id: 'admin-content', name: 'Uebersetzungen', description: 'Website Content & Sprachen', category: 'development', icon: '🌐', url: '/development/content' },
|
||||
]
|
||||
|
||||
const ADMIN_CONNECTIONS: ConnectionDef[] = [
|
||||
// === OVERVIEW/META FLOWS ===
|
||||
{ source: 'admin-dashboard', target: 'admin-onboarding', label: 'Erste Schritte' },
|
||||
{ source: 'admin-dashboard', target: 'admin-architecture', label: 'System' },
|
||||
{ source: 'admin-dashboard', target: 'admin-backlog', label: 'Go-Live' },
|
||||
{ source: 'admin-dashboard', target: 'admin-compliance-hub', label: 'Compliance' },
|
||||
{ source: 'admin-onboarding', target: 'admin-consent' },
|
||||
{ source: 'admin-onboarding', target: 'admin-llm-compare' },
|
||||
{ source: 'admin-rbac', target: 'admin-consent' },
|
||||
|
||||
// === DSGVO FLOW ===
|
||||
{ source: 'admin-consent', target: 'admin-einwilligungen', label: 'Nutzer' },
|
||||
{ source: 'admin-consent', target: 'admin-dsr' },
|
||||
{ source: 'admin-dsr', target: 'admin-loeschfristen' },
|
||||
{ source: 'admin-vvt', target: 'admin-tom' },
|
||||
{ source: 'admin-vvt', target: 'admin-dsfa' },
|
||||
{ source: 'admin-dsfa', target: 'admin-tom' },
|
||||
{ source: 'admin-advisory-board', target: 'admin-escalations', label: 'Eskalation' },
|
||||
{ source: 'admin-advisory-board', target: 'admin-dsfa', label: 'Risiko' },
|
||||
|
||||
// === COMPLIANCE FLOW ===
|
||||
{ source: 'admin-compliance-hub', target: 'admin-audit-checklist', label: 'Audit' },
|
||||
{ source: 'admin-compliance-hub', target: 'admin-requirements', label: 'Anforderungen' },
|
||||
{ source: 'admin-compliance-hub', target: 'admin-risks', label: 'Risiken' },
|
||||
{ source: 'admin-compliance-hub', target: 'admin-ai-act', label: 'AI Act' },
|
||||
{ source: 'admin-requirements', target: 'admin-controls' },
|
||||
{ source: 'admin-controls', target: 'admin-evidence' },
|
||||
{ source: 'admin-audit-checklist', target: 'admin-audit-report', label: 'Report' },
|
||||
{ source: 'admin-risks', target: 'admin-controls' },
|
||||
{ source: 'admin-modules', target: 'admin-controls' },
|
||||
{ source: 'admin-source-policy', target: 'admin-rag' },
|
||||
{ source: 'admin-obligations', target: 'admin-requirements' },
|
||||
{ source: 'admin-dsms', target: 'admin-compliance-workflow' },
|
||||
|
||||
// === KI & AUTOMATISIERUNG FLOW ===
|
||||
{ source: 'admin-llm-compare', target: 'admin-rag', label: 'Daten' },
|
||||
{ source: 'admin-rag', target: 'admin-quality' },
|
||||
{ source: 'admin-rag', target: 'admin-agents' },
|
||||
{ source: 'admin-ocr-labeling', target: 'admin-magic-help', label: 'Training' },
|
||||
{ source: 'admin-magic-help', target: 'admin-klausur-korrektur', label: 'Korrektur' },
|
||||
{ source: 'admin-quality', target: 'admin-test-quality' },
|
||||
{ source: 'admin-agents', target: 'admin-test-quality', label: 'BQAS' },
|
||||
{ source: 'admin-klausur-korrektur', target: 'admin-quality', label: 'Audit' },
|
||||
|
||||
// === INFRASTRUKTUR FLOW ===
|
||||
{ source: 'admin-security', target: 'admin-sbom', label: 'Dependencies' },
|
||||
{ source: 'admin-sbom', target: 'admin-tests' },
|
||||
{ source: 'admin-tests', target: 'admin-cicd', label: 'Pipeline' },
|
||||
{ source: 'admin-cicd', target: 'admin-middleware' },
|
||||
{ source: 'admin-middleware', target: 'admin-gpu', label: 'GPU' },
|
||||
{ source: 'admin-security', target: 'admin-compliance-hub', label: 'Compliance' },
|
||||
|
||||
// === BILDUNG FLOW ===
|
||||
{ source: 'admin-edu-search', target: 'admin-rag', label: 'Quellen' },
|
||||
{ source: 'admin-edu-search', target: 'admin-zeugnisse' },
|
||||
{ source: 'admin-training', target: 'admin-onboarding' },
|
||||
{ source: 'admin-foerderantrag', target: 'admin-docs', label: 'Docs' },
|
||||
|
||||
// === KOMMUNIKATION FLOW ===
|
||||
{ source: 'admin-video', target: 'admin-matrix', label: 'Voice' },
|
||||
{ source: 'admin-mail', target: 'admin-alerts' },
|
||||
{ source: 'admin-alerts', target: 'admin-mail', label: 'Inbox' },
|
||||
|
||||
// === ENTWICKLUNG FLOW ===
|
||||
{ source: 'admin-workflow', target: 'admin-cicd', label: 'Pipeline' },
|
||||
{ source: 'admin-workflow', target: 'admin-docs' },
|
||||
{ source: 'admin-game', target: 'admin-unity', label: 'Editor' },
|
||||
{ source: 'admin-companion', target: 'admin-agents', label: 'Agents' },
|
||||
{ source: 'admin-brandbook', target: 'admin-screen-flow' },
|
||||
{ source: 'admin-docs', target: 'admin-architecture' },
|
||||
{ source: 'admin-content', target: 'admin-brandbook' },
|
||||
]
|
||||
|
||||
// ============================================
|
||||
// CATEGORY COLORS
|
||||
// ============================================
|
||||
|
||||
const STUDIO_COLORS: Record<string, { bg: string; border: string; text: string }> = {
|
||||
navigation: { bg: '#dbeafe', border: '#3b82f6', text: '#1e40af' },
|
||||
content: { bg: '#dcfce7', border: '#22c55e', text: '#166534' },
|
||||
communication: { bg: '#fef3c7', border: '#f59e0b', text: '#92400e' },
|
||||
school: { bg: '#fce7f3', border: '#ec4899', text: '#9d174d' },
|
||||
admin: { bg: '#f3e8ff', border: '#a855f7', text: '#6b21a8' },
|
||||
ai: { bg: '#cffafe', border: '#06b6d4', text: '#0e7490' },
|
||||
}
|
||||
|
||||
// Colors from navigation.ts
|
||||
const ADMIN_COLORS: Record<string, { bg: string; border: string; text: string }> = {
|
||||
overview: { bg: '#e0f2fe', border: '#0ea5e9', text: '#0369a1' }, // Sky (Meta)
|
||||
dsgvo: { bg: '#ede9fe', border: '#7c3aed', text: '#5b21b6' }, // Violet
|
||||
compliance: { bg: '#f3e8ff', border: '#9333ea', text: '#6b21a8' }, // Purple
|
||||
ai: { bg: '#ccfbf1', border: '#14b8a6', text: '#0f766e' }, // Teal
|
||||
infrastructure: { bg: '#ffedd5', border: '#f97316', text: '#c2410c' },// Orange
|
||||
education: { bg: '#dbeafe', border: '#3b82f6', text: '#1e40af' }, // Blue
|
||||
communication: { bg: '#dcfce7', border: '#22c55e', text: '#166534' }, // Green
|
||||
development: { bg: '#f1f5f9', border: '#64748b', text: '#334155' }, // Slate
|
||||
}
|
||||
|
||||
const STUDIO_LABELS: Record<string, string> = {
|
||||
navigation: 'Navigation',
|
||||
content: 'Content & Tools',
|
||||
communication: 'Kommunikation',
|
||||
school: 'Schulverwaltung',
|
||||
admin: 'Administration',
|
||||
ai: 'KI & Assistent',
|
||||
}
|
||||
|
||||
// Labels from navigation.ts
|
||||
const ADMIN_LABELS: Record<string, string> = {
|
||||
overview: 'Uebersicht & Meta',
|
||||
dsgvo: 'DSGVO',
|
||||
compliance: 'Compliance & GRC',
|
||||
ai: 'KI & Automatisierung',
|
||||
infrastructure: 'Infrastruktur & DevOps',
|
||||
education: 'Bildung & Schule',
|
||||
communication: 'Kommunikation & Alerts',
|
||||
development: 'Entwicklung & Produkte',
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// HELPER: Find all connected nodes (recursive)
|
||||
// ============================================
|
||||
|
||||
function findConnectedNodes(
|
||||
startNodeId: string,
|
||||
connections: ConnectionDef[],
|
||||
direction: 'children' | 'parents' | 'both' = 'children'
|
||||
): Set<string> {
|
||||
const connected = new Set<string>()
|
||||
connected.add(startNodeId)
|
||||
|
||||
const queue = [startNodeId]
|
||||
while (queue.length > 0) {
|
||||
const current = queue.shift()!
|
||||
|
||||
connections.forEach(conn => {
|
||||
if ((direction === 'children' || direction === 'both') && conn.source === current) {
|
||||
if (!connected.has(conn.target)) {
|
||||
connected.add(conn.target)
|
||||
queue.push(conn.target)
|
||||
}
|
||||
}
|
||||
if ((direction === 'parents' || direction === 'both') && conn.target === current) {
|
||||
if (!connected.has(conn.source)) {
|
||||
connected.add(conn.source)
|
||||
queue.push(conn.source)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
return connected
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// LAYOUT HELPERS
|
||||
// ============================================
|
||||
|
||||
const getNodePosition = (
|
||||
id: string,
|
||||
category: string,
|
||||
screens: ScreenDefinition[],
|
||||
flowType: FlowType
|
||||
) => {
|
||||
const studioPositions: Record<string, { x: number; y: number }> = {
|
||||
navigation: { x: 400, y: 50 },
|
||||
content: { x: 50, y: 250 },
|
||||
communication: { x: 750, y: 250 },
|
||||
school: { x: 50, y: 500 },
|
||||
admin: { x: 750, y: 500 },
|
||||
ai: { x: 400, y: 380 },
|
||||
}
|
||||
|
||||
const adminPositions: Record<string, { x: number; y: number }> = {
|
||||
overview: { x: 400, y: 30 },
|
||||
dsgvo: { x: 50, y: 150 },
|
||||
compliance: { x: 700, y: 150 },
|
||||
ai: { x: 50, y: 350 },
|
||||
communication: { x: 400, y: 350 },
|
||||
infrastructure: { x: 700, y: 350 },
|
||||
education: { x: 50, y: 550 },
|
||||
development: { x: 400, y: 550 },
|
||||
}
|
||||
|
||||
const positions = flowType === 'studio' ? studioPositions : adminPositions
|
||||
const base = positions[category] || { x: 400, y: 300 }
|
||||
const categoryScreens = screens.filter(s => s.category === category)
|
||||
const categoryIndex = categoryScreens.findIndex(s => s.id === id)
|
||||
|
||||
const cols = Math.ceil(Math.sqrt(categoryScreens.length + 1))
|
||||
const row = Math.floor(categoryIndex / cols)
|
||||
const col = categoryIndex % cols
|
||||
|
||||
return {
|
||||
x: base.x + col * 160,
|
||||
y: base.y + row * 90,
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// MAIN COMPONENT
|
||||
// ============================================
|
||||
|
||||
export default function ScreenFlowPage() {
|
||||
const [flowType, setFlowType] = useState<FlowType>('admin')
|
||||
const [selectedCategory, setSelectedCategory] = useState<string | null>(null)
|
||||
const [selectedNode, setSelectedNode] = useState<string | null>(null)
|
||||
const [previewScreen, setPreviewScreen] = useState<ScreenDefinition | null>(null)
|
||||
|
||||
// Get data based on flow type
|
||||
const screens = flowType === 'studio' ? STUDIO_SCREENS : ADMIN_SCREENS
|
||||
const connections = flowType === 'studio' ? STUDIO_CONNECTIONS : ADMIN_CONNECTIONS
|
||||
const colors = flowType === 'studio' ? STUDIO_COLORS : ADMIN_COLORS
|
||||
const labels = flowType === 'studio' ? STUDIO_LABELS : ADMIN_LABELS
|
||||
const baseUrl = flowType === 'studio' ? 'http://macmini:8000' : 'http://macmini:3002'
|
||||
|
||||
// Calculate connected nodes
|
||||
const connectedNodes = useMemo(() => {
|
||||
if (!selectedNode) return new Set<string>()
|
||||
return findConnectedNodes(selectedNode, connections, 'children')
|
||||
}, [selectedNode, connections])
|
||||
|
||||
// Create nodes with useMemo
|
||||
const initialNodes = useMemo((): Node[] => {
|
||||
return screens.map((screen) => {
|
||||
const catColors = colors[screen.category] || { bg: '#f1f5f9', border: '#94a3b8', text: '#475569' }
|
||||
const position = getNodePosition(screen.id, screen.category, screens, flowType)
|
||||
|
||||
// Determine opacity
|
||||
let opacity = 1
|
||||
if (selectedNode) {
|
||||
opacity = connectedNodes.has(screen.id) ? 1 : 0.2
|
||||
} else if (selectedCategory) {
|
||||
opacity = screen.category === selectedCategory ? 1 : 0.2
|
||||
}
|
||||
|
||||
const isSelected = selectedNode === screen.id
|
||||
|
||||
return {
|
||||
id: screen.id,
|
||||
type: 'default',
|
||||
position,
|
||||
data: {
|
||||
label: (
|
||||
<div className="text-center p-1">
|
||||
<div className="text-lg mb-1">{screen.icon}</div>
|
||||
<div className="font-medium text-xs leading-tight">{screen.name}</div>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
style: {
|
||||
background: isSelected ? catColors.border : catColors.bg,
|
||||
color: isSelected ? 'white' : catColors.text,
|
||||
border: `2px solid ${catColors.border}`,
|
||||
borderRadius: '12px',
|
||||
padding: '6px',
|
||||
minWidth: '110px',
|
||||
opacity,
|
||||
cursor: 'pointer',
|
||||
boxShadow: isSelected ? `0 0 20px ${catColors.border}` : 'none',
|
||||
},
|
||||
}
|
||||
})
|
||||
}, [screens, colors, flowType, selectedCategory, selectedNode, connectedNodes])
|
||||
|
||||
// Create edges with useMemo
|
||||
const initialEdges = useMemo((): Edge[] => {
|
||||
return connections.map((conn, index) => {
|
||||
const isHighlighted = selectedNode && (conn.source === selectedNode || conn.target === selectedNode)
|
||||
const isInSubtree = selectedNode && connectedNodes.has(conn.source) && connectedNodes.has(conn.target)
|
||||
|
||||
return {
|
||||
id: `e-${conn.source}-${conn.target}-${index}`,
|
||||
source: conn.source,
|
||||
target: conn.target,
|
||||
label: conn.label,
|
||||
type: 'smoothstep',
|
||||
animated: isHighlighted || false,
|
||||
style: {
|
||||
stroke: isHighlighted ? '#3b82f6' : (isInSubtree ? '#94a3b8' : '#e2e8f0'),
|
||||
strokeWidth: isHighlighted ? 3 : 1.5,
|
||||
opacity: selectedNode ? (isInSubtree ? 1 : 0.15) : 1,
|
||||
},
|
||||
labelStyle: { fontSize: 9, fill: '#64748b' },
|
||||
labelBgStyle: { fill: '#f8fafc' },
|
||||
markerEnd: { type: MarkerType.ArrowClosed, color: isHighlighted ? '#3b82f6' : '#94a3b8', width: 15, height: 15 },
|
||||
}
|
||||
})
|
||||
}, [connections, selectedNode, connectedNodes])
|
||||
|
||||
const [nodes, setNodes, onNodesChange] = useNodesState([])
|
||||
const [edges, setEdges, onEdgesChange] = useEdgesState([])
|
||||
|
||||
// Update nodes/edges when dependencies change
|
||||
useEffect(() => {
|
||||
setNodes(initialNodes)
|
||||
setEdges(initialEdges)
|
||||
}, [initialNodes, initialEdges, setNodes, setEdges])
|
||||
|
||||
// Reset when flow type changes
|
||||
const handleFlowTypeChange = useCallback((newType: FlowType) => {
|
||||
setFlowType(newType)
|
||||
setSelectedNode(null)
|
||||
setSelectedCategory(null)
|
||||
setPreviewScreen(null)
|
||||
}, [])
|
||||
|
||||
// Handle node click
|
||||
const onNodeClick = useCallback((_event: React.MouseEvent, node: Node) => {
|
||||
const screen = screens.find(s => s.id === node.id)
|
||||
|
||||
if (selectedNode === node.id) {
|
||||
// Double-click: open in new tab
|
||||
if (screen?.url) {
|
||||
window.open(`${baseUrl}${screen.url}`, '_blank')
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
setSelectedNode(node.id)
|
||||
setSelectedCategory(null)
|
||||
|
||||
if (screen) {
|
||||
setPreviewScreen(screen)
|
||||
}
|
||||
}, [screens, baseUrl, selectedNode])
|
||||
|
||||
// Handle background click - deselect
|
||||
const onPaneClick = useCallback(() => {
|
||||
setSelectedNode(null)
|
||||
setPreviewScreen(null)
|
||||
}, [])
|
||||
|
||||
// Stats
|
||||
const stats = {
|
||||
totalScreens: screens.length,
|
||||
totalConnections: connections.length,
|
||||
connectedCount: connectedNodes.size,
|
||||
}
|
||||
|
||||
const categories = Object.keys(labels)
|
||||
|
||||
// Connected screens list
|
||||
const connectedScreens = selectedNode
|
||||
? screens.filter(s => connectedNodes.has(s.id))
|
||||
: []
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Flow Type Selector */}
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<button
|
||||
onClick={() => handleFlowTypeChange('studio')}
|
||||
className={`p-6 rounded-xl border-2 transition-all ${
|
||||
flowType === 'studio'
|
||||
? 'border-green-500 bg-green-50 shadow-lg'
|
||||
: 'border-slate-200 bg-white hover:border-slate-300'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-4">
|
||||
<div className={`w-14 h-14 rounded-xl flex items-center justify-center text-2xl ${
|
||||
flowType === 'studio' ? 'bg-green-500 text-white' : 'bg-slate-100'
|
||||
}`}>
|
||||
🎓
|
||||
</div>
|
||||
<div className="text-left">
|
||||
<div className="font-bold text-lg">Studio (Port 8000)</div>
|
||||
<div className="text-sm text-slate-500">Lehrer-Oberflaeche</div>
|
||||
<div className="text-xs text-slate-400 mt-1">{STUDIO_SCREENS.length} Screens</div>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => handleFlowTypeChange('admin')}
|
||||
className={`p-6 rounded-xl border-2 transition-all ${
|
||||
flowType === 'admin'
|
||||
? 'border-primary-500 bg-primary-50 shadow-lg'
|
||||
: 'border-slate-200 bg-white hover:border-slate-300'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-4">
|
||||
<div className={`w-14 h-14 rounded-xl flex items-center justify-center text-2xl ${
|
||||
flowType === 'admin' ? 'bg-primary-500 text-white' : 'bg-slate-100'
|
||||
}`}>
|
||||
⚙️
|
||||
</div>
|
||||
<div className="text-left">
|
||||
<div className="font-bold text-lg">Admin v2 (Port 3002)</div>
|
||||
<div className="text-sm text-slate-500">Admin Panel</div>
|
||||
<div className="text-xs text-slate-400 mt-1">{ADMIN_SCREENS.length} Screens</div>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Stats & Selection Info */}
|
||||
<div className="grid grid-cols-4 gap-4">
|
||||
<div className="bg-white rounded-xl border border-slate-200 p-4 shadow-sm">
|
||||
<div className="text-3xl font-bold text-slate-800">{stats.totalScreens}</div>
|
||||
<div className="text-sm text-slate-500">Screens</div>
|
||||
</div>
|
||||
<div className="bg-white rounded-xl border border-slate-200 p-4 shadow-sm">
|
||||
<div className="text-3xl font-bold text-primary-600">{stats.totalConnections}</div>
|
||||
<div className="text-sm text-slate-500">Verbindungen</div>
|
||||
</div>
|
||||
<div className="bg-white rounded-xl border border-slate-200 p-4 shadow-sm col-span-2">
|
||||
{selectedNode ? (
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="text-3xl">{previewScreen?.icon}</div>
|
||||
<div>
|
||||
<div className="font-bold text-slate-800">{previewScreen?.name}</div>
|
||||
<div className="text-sm text-slate-500">
|
||||
{stats.connectedCount} verbundene Screen{stats.connectedCount !== 1 ? 's' : ''}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => {
|
||||
setSelectedNode(null)
|
||||
setPreviewScreen(null)
|
||||
}}
|
||||
className="ml-auto px-3 py-1 text-sm bg-slate-100 hover:bg-slate-200 rounded-lg"
|
||||
>
|
||||
Zuruecksetzen
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-slate-500 text-sm">
|
||||
Klicke auf einen Screen um den Subtree zu sehen
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Category Filter */}
|
||||
<div className="bg-white rounded-xl border border-slate-200 p-4 shadow-sm">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<button
|
||||
onClick={() => {
|
||||
setSelectedCategory(null)
|
||||
setSelectedNode(null)
|
||||
setPreviewScreen(null)
|
||||
}}
|
||||
className={`px-4 py-2 rounded-lg text-sm font-medium transition-colors ${
|
||||
selectedCategory === null && !selectedNode
|
||||
? 'bg-slate-800 text-white'
|
||||
: 'bg-slate-100 text-slate-700 hover:bg-slate-200'
|
||||
}`}
|
||||
>
|
||||
Alle ({screens.length})
|
||||
</button>
|
||||
{categories.map((key) => {
|
||||
const count = screens.filter(s => s.category === key).length
|
||||
const catColors = colors[key] || { bg: '#f1f5f9', border: '#94a3b8', text: '#475569' }
|
||||
return (
|
||||
<button
|
||||
key={key}
|
||||
onClick={() => {
|
||||
setSelectedCategory(selectedCategory === key ? null : key)
|
||||
setSelectedNode(null)
|
||||
setPreviewScreen(null)
|
||||
}}
|
||||
className="px-4 py-2 rounded-lg text-sm font-medium transition-all flex items-center gap-2"
|
||||
style={{
|
||||
background: selectedCategory === key ? catColors.border : catColors.bg,
|
||||
color: selectedCategory === key ? 'white' : catColors.text,
|
||||
}}
|
||||
>
|
||||
<span className="w-3 h-3 rounded-full" style={{ background: catColors.border }} />
|
||||
{labels[key]} ({count})
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Connected Screens List */}
|
||||
{selectedNode && connectedScreens.length > 1 && (
|
||||
<div className="bg-white rounded-xl border border-slate-200 p-4 shadow-sm">
|
||||
<div className="text-sm font-medium text-slate-700 mb-3">Verbundene Screens:</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{connectedScreens.map((screen) => {
|
||||
const catColors = colors[screen.category] || { bg: '#f1f5f9', border: '#94a3b8', text: '#475569' }
|
||||
const isCurrentNode = screen.id === selectedNode
|
||||
return (
|
||||
<button
|
||||
key={screen.id}
|
||||
onClick={() => {
|
||||
if (screen.url) {
|
||||
window.open(`${baseUrl}${screen.url}`, '_blank')
|
||||
}
|
||||
}}
|
||||
className={`px-3 py-2 rounded-lg text-sm font-medium transition-all flex items-center gap-2 ${
|
||||
isCurrentNode ? 'ring-2 ring-primary-500' : ''
|
||||
}`}
|
||||
style={{
|
||||
background: isCurrentNode ? catColors.border : catColors.bg,
|
||||
color: isCurrentNode ? 'white' : catColors.text,
|
||||
}}
|
||||
>
|
||||
<span>{screen.icon}</span>
|
||||
{screen.name}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Flow Diagram */}
|
||||
<div className="bg-white rounded-xl border border-slate-200 shadow-sm overflow-hidden" style={{ height: '500px' }}>
|
||||
<ReactFlow
|
||||
nodes={nodes}
|
||||
edges={edges}
|
||||
onNodesChange={onNodesChange}
|
||||
onEdgesChange={onEdgesChange}
|
||||
onNodeClick={onNodeClick}
|
||||
onPaneClick={onPaneClick}
|
||||
fitView
|
||||
fitViewOptions={{ padding: 0.2 }}
|
||||
attributionPosition="bottom-left"
|
||||
>
|
||||
<Controls />
|
||||
<MiniMap
|
||||
nodeColor={(node) => {
|
||||
const screen = screens.find(s => s.id === node.id)
|
||||
const catColors = screen ? colors[screen.category] : null
|
||||
return catColors?.border || '#94a3b8'
|
||||
}}
|
||||
maskColor="rgba(0, 0, 0, 0.1)"
|
||||
/>
|
||||
<Background variant={BackgroundVariant.Dots} gap={12} size={1} />
|
||||
|
||||
<Panel position="top-left" className="bg-white/95 p-3 rounded-lg shadow-lg text-xs">
|
||||
<div className="font-medium text-slate-700 mb-2">
|
||||
{flowType === 'studio' ? '🎓 Studio' : '⚙️ Admin v2'}
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
{categories.slice(0, 4).map((key) => {
|
||||
const catColors = colors[key] || { bg: '#f1f5f9', border: '#94a3b8' }
|
||||
return (
|
||||
<div key={key} className="flex items-center gap-2">
|
||||
<span
|
||||
className="w-3 h-3 rounded"
|
||||
style={{ background: catColors.bg, border: `1px solid ${catColors.border}` }}
|
||||
/>
|
||||
<span className="text-slate-600">{labels[key]}</span>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
<div className="mt-2 pt-2 border-t text-slate-400">
|
||||
Klick = Subtree<br/>
|
||||
Doppelklick = Oeffnen
|
||||
</div>
|
||||
</Panel>
|
||||
</ReactFlow>
|
||||
</div>
|
||||
|
||||
{/* Screen List */}
|
||||
<div className="bg-white rounded-xl border border-slate-200 shadow-sm overflow-hidden">
|
||||
<div className="px-4 py-3 bg-slate-50 border-b flex items-center justify-between">
|
||||
<h3 className="font-medium text-slate-700">
|
||||
Alle Screens ({screens.length})
|
||||
</h3>
|
||||
<span className="text-xs text-slate-400">{baseUrl}</span>
|
||||
</div>
|
||||
<div className="divide-y max-h-80 overflow-y-auto">
|
||||
{screens
|
||||
.filter(s => !selectedCategory || s.category === selectedCategory)
|
||||
.map((screen) => {
|
||||
const catColors = colors[screen.category] || { bg: '#f1f5f9', border: '#94a3b8', text: '#475569' }
|
||||
return (
|
||||
<button
|
||||
key={screen.id}
|
||||
onClick={() => {
|
||||
setSelectedNode(screen.id)
|
||||
setSelectedCategory(null)
|
||||
setPreviewScreen(screen)
|
||||
}}
|
||||
className="w-full flex items-center gap-4 p-3 hover:bg-slate-50 transition-colors text-left"
|
||||
>
|
||||
<span
|
||||
className="w-9 h-9 rounded-lg flex items-center justify-center text-lg"
|
||||
style={{ background: catColors.bg }}
|
||||
>
|
||||
{screen.icon}
|
||||
</span>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="font-medium text-slate-800 text-sm">{screen.name}</div>
|
||||
<div className="text-xs text-slate-500 truncate">{screen.description}</div>
|
||||
</div>
|
||||
<span
|
||||
className="px-2 py-1 rounded text-xs font-medium shrink-0"
|
||||
style={{ background: catColors.bg, color: catColors.text }}
|
||||
>
|
||||
{labels[screen.category]}
|
||||
</span>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
'use client'
|
||||
|
||||
import { PagePurpose } from '@/components/common/PagePurpose'
|
||||
import { getModuleByHref } from '@/lib/navigation'
|
||||
import { Box, Construction } from 'lucide-react'
|
||||
|
||||
export default function UnityBridgePage() {
|
||||
const moduleInfo = getModuleByHref('/development/unity-bridge')
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{moduleInfo && (
|
||||
<PagePurpose
|
||||
title={moduleInfo.module.name}
|
||||
purpose={moduleInfo.module.purpose}
|
||||
audience={moduleInfo.module.audience}
|
||||
collapsible={true}
|
||||
defaultCollapsed={true}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="bg-white border border-slate-200 rounded-xl p-8 text-center">
|
||||
<div className="flex justify-center mb-4">
|
||||
<div className="p-4 bg-slate-100 rounded-full">
|
||||
<Box className="w-12 h-12 text-slate-400" />
|
||||
</div>
|
||||
</div>
|
||||
<h2 className="text-xl font-semibold text-slate-800 mb-2">Unity Bridge</h2>
|
||||
<p className="text-slate-600 mb-4">
|
||||
Remote-Steuerung des Unity Editors fuer Game-Development.
|
||||
</p>
|
||||
<div className="inline-flex items-center gap-2 px-4 py-2 bg-amber-50 border border-amber-200 rounded-lg text-amber-700">
|
||||
<Construction className="w-4 h-4" />
|
||||
<span className="text-sm font-medium">In Entwicklung</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,665 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect } from 'react'
|
||||
import {
|
||||
GitBranch,
|
||||
Terminal,
|
||||
Server,
|
||||
Database,
|
||||
CheckCircle2,
|
||||
ArrowRight,
|
||||
Laptop,
|
||||
HardDrive,
|
||||
RefreshCw,
|
||||
Clock,
|
||||
Shield,
|
||||
Users,
|
||||
FileCode,
|
||||
Play,
|
||||
Eye,
|
||||
Download,
|
||||
AlertTriangle,
|
||||
Info,
|
||||
Container
|
||||
} from 'lucide-react'
|
||||
|
||||
interface WorkflowStep {
|
||||
id: number
|
||||
title: string
|
||||
description: string
|
||||
command?: string
|
||||
icon: React.ReactNode
|
||||
location: 'macbook' | 'macmini'
|
||||
}
|
||||
|
||||
interface BackupInfo {
|
||||
lastRun: string | null
|
||||
nextRun: string
|
||||
status: 'ok' | 'warning' | 'error'
|
||||
}
|
||||
|
||||
export default function WorkflowPage() {
|
||||
const [activeStep, setActiveStep] = useState<number>(1)
|
||||
const [backupInfo, setBackupInfo] = useState<BackupInfo>({
|
||||
lastRun: null,
|
||||
nextRun: '02:00 Uhr',
|
||||
status: 'ok'
|
||||
})
|
||||
|
||||
const workflowSteps: WorkflowStep[] = [
|
||||
{
|
||||
id: 1,
|
||||
title: 'Code bearbeiten',
|
||||
description: 'Arbeite mit Claude Code im Terminal. Beschreibe was du brauchst und Claude schreibt den Code.',
|
||||
command: 'claude',
|
||||
icon: <Terminal className="h-6 w-6" />,
|
||||
location: 'macbook'
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
title: 'Änderungen stagen',
|
||||
description: 'Füge die geänderten Dateien zum nächsten Commit hinzu.',
|
||||
command: 'git add <dateien>',
|
||||
icon: <FileCode className="h-6 w-6" />,
|
||||
location: 'macbook'
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
title: 'Commit erstellen',
|
||||
description: 'Erstelle einen Commit mit einer aussagekräftigen Nachricht.',
|
||||
command: 'git commit -m "feat: neue Funktion"',
|
||||
icon: <GitBranch className="h-6 w-6" />,
|
||||
location: 'macbook'
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
title: 'Push zum Server',
|
||||
description: 'Sende die Änderungen an den Mac Mini. Dies startet automatisch die CI/CD Pipeline.',
|
||||
command: 'git push origin main',
|
||||
icon: <ArrowRight className="h-6 w-6" />,
|
||||
location: 'macbook'
|
||||
},
|
||||
{
|
||||
id: 5,
|
||||
title: 'CI/CD Pipeline',
|
||||
description: 'Woodpecker führt automatisch Tests aus und baut die Container.',
|
||||
command: '(automatisch)',
|
||||
icon: <RefreshCw className="h-6 w-6" />,
|
||||
location: 'macmini'
|
||||
},
|
||||
{
|
||||
id: 6,
|
||||
title: 'Integration Tests',
|
||||
description: 'Docker Compose Test-Umgebung mit Backend, DB und Consent-Service fuer vollstaendige E2E-Tests.',
|
||||
command: 'docker compose -f docker-compose.test.yml up -d',
|
||||
icon: <Container className="h-6 w-6" />,
|
||||
location: 'macmini'
|
||||
},
|
||||
{
|
||||
id: 7,
|
||||
title: 'Frontend testen',
|
||||
description: 'Teste die Änderungen im Browser auf dem Mac Mini.',
|
||||
command: 'http://macmini:3000',
|
||||
icon: <Eye className="h-6 w-6" />,
|
||||
location: 'macbook'
|
||||
}
|
||||
]
|
||||
|
||||
const services = [
|
||||
{ name: 'Website', url: 'http://macmini:3000', port: 3000, status: 'running' },
|
||||
{ name: 'Admin v2', url: 'http://macmini:3002', port: 3002, status: 'running' },
|
||||
{ name: 'Studio v2', url: 'http://macmini:3001', port: 3001, status: 'running' },
|
||||
{ name: 'Backend', url: 'http://macmini:8000', port: 8000, status: 'running' },
|
||||
{ name: 'Gitea', url: 'http://macmini:3003', port: 3003, status: 'running' },
|
||||
{ name: 'Klausur-Service', url: 'http://macmini:8086', port: 8086, status: 'running' },
|
||||
]
|
||||
|
||||
const commitTypes = [
|
||||
{ type: 'feat:', description: 'Neue Funktion', example: 'feat: add user login' },
|
||||
{ type: 'fix:', description: 'Bugfix', example: 'fix: resolve login timeout' },
|
||||
{ type: 'docs:', description: 'Dokumentation', example: 'docs: update API docs' },
|
||||
{ type: 'style:', description: 'Formatierung', example: 'style: fix indentation' },
|
||||
{ type: 'refactor:', description: 'Code-Umbau', example: 'refactor: extract helper' },
|
||||
{ type: 'test:', description: 'Tests', example: 'test: add unit tests' },
|
||||
{ type: 'chore:', description: 'Wartung', example: 'chore: update deps' },
|
||||
]
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
{/* Header */}
|
||||
<div className="bg-gradient-to-r from-indigo-600 to-purple-600 rounded-2xl p-8 text-white">
|
||||
<h1 className="text-3xl font-bold mb-2">Entwicklungs-Workflow</h1>
|
||||
<p className="text-indigo-100">
|
||||
Wie wir bei BreakPilot entwickeln - von der Idee bis zum Deployment
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Architecture Overview */}
|
||||
<div className="bg-white rounded-xl border border-slate-200 p-6">
|
||||
<h2 className="text-xl font-semibold text-slate-900 mb-4 flex items-center gap-2">
|
||||
<Server className="h-5 w-5 text-indigo-600" />
|
||||
Systemarchitektur
|
||||
</h2>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
{/* MacBook */}
|
||||
<div className="bg-slate-50 rounded-xl p-5 border-2 border-slate-200">
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<div className="p-2 bg-blue-100 rounded-lg">
|
||||
<Laptop className="h-6 w-6 text-blue-600" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-semibold text-slate-900">MacBook (Entwicklung)</h3>
|
||||
<p className="text-sm text-slate-500">Dein Arbeitsplatz</p>
|
||||
</div>
|
||||
</div>
|
||||
<ul className="space-y-2 text-sm">
|
||||
<li className="flex items-center gap-2">
|
||||
<CheckCircle2 className="h-4 w-4 text-green-500" />
|
||||
<span>Terminal + Claude Code</span>
|
||||
</li>
|
||||
<li className="flex items-center gap-2">
|
||||
<CheckCircle2 className="h-4 w-4 text-green-500" />
|
||||
<span>Lokales Git Repository</span>
|
||||
</li>
|
||||
<li className="flex items-center gap-2">
|
||||
<CheckCircle2 className="h-4 w-4 text-green-500" />
|
||||
<span>Browser für Frontend-Tests</span>
|
||||
</li>
|
||||
<li className="flex items-center gap-2">
|
||||
<AlertTriangle className="h-4 w-4 text-amber-500" />
|
||||
<span>Backup manuell (MacBook nachts aus)</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
{/* Mac Mini */}
|
||||
<div className="bg-slate-50 rounded-xl p-5 border-2 border-indigo-200">
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<div className="p-2 bg-indigo-100 rounded-lg">
|
||||
<HardDrive className="h-6 w-6 text-indigo-600" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-semibold text-slate-900">Mac Mini (Server)</h3>
|
||||
<p className="text-sm text-slate-500">192.168.178.100</p>
|
||||
</div>
|
||||
</div>
|
||||
<ul className="space-y-2 text-sm">
|
||||
<li className="flex items-center gap-2">
|
||||
<CheckCircle2 className="h-4 w-4 text-green-500" />
|
||||
<span>Gitea (Git Server)</span>
|
||||
</li>
|
||||
<li className="flex items-center gap-2">
|
||||
<CheckCircle2 className="h-4 w-4 text-green-500" />
|
||||
<span>Woodpecker (CI/CD)</span>
|
||||
</li>
|
||||
<li className="flex items-center gap-2">
|
||||
<CheckCircle2 className="h-4 w-4 text-green-500" />
|
||||
<span>Docker Container (alle Services)</span>
|
||||
</li>
|
||||
<li className="flex items-center gap-2">
|
||||
<CheckCircle2 className="h-4 w-4 text-green-500" />
|
||||
<span>PostgreSQL Datenbank</span>
|
||||
</li>
|
||||
<li className="flex items-center gap-2">
|
||||
<CheckCircle2 className="h-4 w-4 text-green-500" />
|
||||
<span>Automatisches Backup (02:00 Uhr lokal)</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Workflow Steps */}
|
||||
<div className="bg-white rounded-xl border border-slate-200 p-6">
|
||||
<h2 className="text-xl font-semibold text-slate-900 mb-6 flex items-center gap-2">
|
||||
<Play className="h-5 w-5 text-indigo-600" />
|
||||
Entwicklungs-Schritte
|
||||
</h2>
|
||||
|
||||
<div className="space-y-4">
|
||||
{workflowSteps.map((step, index) => (
|
||||
<div
|
||||
key={step.id}
|
||||
className={`relative flex items-start gap-4 p-4 rounded-xl transition-all cursor-pointer ${
|
||||
activeStep === step.id
|
||||
? 'bg-indigo-50 border-2 border-indigo-300'
|
||||
: 'bg-slate-50 border-2 border-transparent hover:border-slate-200'
|
||||
}`}
|
||||
onClick={() => setActiveStep(step.id)}
|
||||
>
|
||||
{/* Step Number */}
|
||||
<div className={`flex-shrink-0 w-10 h-10 rounded-full flex items-center justify-center font-bold ${
|
||||
activeStep === step.id
|
||||
? 'bg-indigo-600 text-white'
|
||||
: 'bg-slate-200 text-slate-600'
|
||||
}`}>
|
||||
{step.id}
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-grow">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<h3 className="font-semibold text-slate-900">{step.title}</h3>
|
||||
<span className={`text-xs px-2 py-0.5 rounded-full ${
|
||||
step.location === 'macbook'
|
||||
? 'bg-blue-100 text-blue-700'
|
||||
: 'bg-purple-100 text-purple-700'
|
||||
}`}>
|
||||
{step.location === 'macbook' ? 'MacBook' : 'Mac Mini'}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-sm text-slate-600 mb-2">{step.description}</p>
|
||||
{step.command && (
|
||||
<code className="text-xs bg-slate-800 text-green-400 px-3 py-1.5 rounded-lg font-mono">
|
||||
{step.command}
|
||||
</code>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Icon */}
|
||||
<div className={`flex-shrink-0 p-2 rounded-lg ${
|
||||
activeStep === step.id ? 'bg-indigo-100 text-indigo-600' : 'bg-slate-100 text-slate-400'
|
||||
}`}>
|
||||
{step.icon}
|
||||
</div>
|
||||
|
||||
{/* Connector Line */}
|
||||
{index < workflowSteps.length - 1 && (
|
||||
<div className="absolute left-9 top-14 w-0.5 h-8 bg-slate-200" />
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Services & URLs */}
|
||||
<div className="bg-white rounded-xl border border-slate-200 p-6">
|
||||
<h2 className="text-xl font-semibold text-slate-900 mb-4 flex items-center gap-2">
|
||||
<Eye className="h-5 w-5 text-indigo-600" />
|
||||
Services & URLs zum Testen
|
||||
</h2>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3">
|
||||
{services.map((service) => (
|
||||
<a
|
||||
key={service.name}
|
||||
href={service.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center justify-between p-4 bg-slate-50 rounded-lg hover:bg-slate-100 transition-colors border border-slate-200"
|
||||
>
|
||||
<div>
|
||||
<h3 className="font-medium text-slate-900">{service.name}</h3>
|
||||
<p className="text-sm text-slate-500">Port {service.port}</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="w-2 h-2 bg-green-500 rounded-full animate-pulse" />
|
||||
<ArrowRight className="h-4 w-4 text-slate-400" />
|
||||
</div>
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Commit Convention */}
|
||||
<div className="bg-white rounded-xl border border-slate-200 p-6">
|
||||
<h2 className="text-xl font-semibold text-slate-900 mb-4 flex items-center gap-2">
|
||||
<GitBranch className="h-5 w-5 text-indigo-600" />
|
||||
Commit-Konventionen
|
||||
</h2>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-3">
|
||||
{commitTypes.map((item) => (
|
||||
<div key={item.type} className="bg-slate-50 rounded-lg p-3 border border-slate-200">
|
||||
<code className="text-sm font-bold text-indigo-600">{item.type}</code>
|
||||
<p className="text-sm text-slate-600 mt-1">{item.description}</p>
|
||||
<p className="text-xs text-slate-400 mt-1 font-mono">{item.example}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Backup Info */}
|
||||
<div className="bg-white rounded-xl border border-slate-200 p-6">
|
||||
<h2 className="text-xl font-semibold text-slate-900 mb-4 flex items-center gap-2">
|
||||
<Shield className="h-5 w-5 text-indigo-600" />
|
||||
Backup & Sicherheit
|
||||
</h2>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||
{/* Mac Mini - Automatisches lokales Backup */}
|
||||
<div className="bg-green-50 rounded-xl p-5 border border-green-200">
|
||||
<div className="flex items-center gap-3 mb-3">
|
||||
<Clock className="h-5 w-5 text-green-600" />
|
||||
<h3 className="font-semibold text-green-900">Mac Mini (Auto)</h3>
|
||||
</div>
|
||||
<ul className="space-y-2 text-sm text-green-800">
|
||||
<li>• Automatisch um 02:00 Uhr</li>
|
||||
<li>• PostgreSQL-Dump lokal</li>
|
||||
<li>• Git Repository gesichert</li>
|
||||
<li>• 7 Tage Aufbewahrung</li>
|
||||
</ul>
|
||||
<div className="mt-4 p-3 bg-green-100 rounded-lg">
|
||||
<code className="text-xs text-green-700 font-mono">
|
||||
~/Projekte/backup-logs/
|
||||
</code>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* MacBook - Manuelles Backup */}
|
||||
<div className="bg-amber-50 rounded-xl p-5 border border-amber-200">
|
||||
<div className="flex items-center gap-3 mb-3">
|
||||
<AlertTriangle className="h-5 w-5 text-amber-600" />
|
||||
<h3 className="font-semibold text-amber-900">MacBook (Manuell)</h3>
|
||||
</div>
|
||||
<ul className="space-y-2 text-sm text-amber-800">
|
||||
<li>• MacBook nachts aus (02:00)</li>
|
||||
<li>• Keine Auto-Synchronisation</li>
|
||||
<li>• Backup manuell anstoßen</li>
|
||||
</ul>
|
||||
<div className="mt-4 p-3 bg-amber-100 rounded-lg">
|
||||
<code className="text-xs text-amber-700 font-mono">
|
||||
rsync -avz macmini:~/Projekte/ ~/Projekte/
|
||||
</code>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Manuelles Backup starten */}
|
||||
<div className="bg-blue-50 rounded-xl p-5 border border-blue-200">
|
||||
<div className="flex items-center gap-3 mb-3">
|
||||
<Download className="h-5 w-5 text-blue-600" />
|
||||
<h3 className="font-semibold text-blue-900">Backup Script</h3>
|
||||
</div>
|
||||
<p className="text-sm text-blue-800 mb-3">
|
||||
Backup jederzeit manuell starten:
|
||||
</p>
|
||||
<code className="block text-xs bg-slate-800 text-green-400 p-3 rounded-lg font-mono">
|
||||
~/Projekte/breakpilot-pwa/scripts/daily-backup.sh
|
||||
</code>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Quick Commands */}
|
||||
<div className="bg-slate-800 rounded-xl p-6 text-white">
|
||||
<h2 className="text-xl font-semibold mb-4 flex items-center gap-2">
|
||||
<Terminal className="h-5 w-5 text-green-400" />
|
||||
Wichtige Befehle
|
||||
</h2>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 font-mono text-sm">
|
||||
<div className="bg-slate-900 rounded-lg p-4">
|
||||
<p className="text-slate-400 mb-2"># CI/CD Logs ansehen</p>
|
||||
<code className="text-green-400">ssh macmini "docker logs breakpilot-pwa-backend --tail 50"</code>
|
||||
</div>
|
||||
<div className="bg-slate-900 rounded-lg p-4">
|
||||
<p className="text-slate-400 mb-2"># Container neu starten</p>
|
||||
<code className="text-green-400">ssh macmini "docker compose restart backend"</code>
|
||||
</div>
|
||||
<div className="bg-slate-900 rounded-lg p-4">
|
||||
<p className="text-slate-400 mb-2"># Alle Container Status</p>
|
||||
<code className="text-green-400">ssh macmini "docker ps"</code>
|
||||
</div>
|
||||
<div className="bg-slate-900 rounded-lg p-4">
|
||||
<p className="text-slate-400 mb-2"># Pipeline Status (Gitea)</p>
|
||||
<code className="text-green-400">open http://macmini:3003</code>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Team Workflow with Feature Branches */}
|
||||
<div className="bg-indigo-50 rounded-xl border border-indigo-200 p-6">
|
||||
<h2 className="text-xl font-semibold text-indigo-900 mb-4 flex items-center gap-2">
|
||||
<GitBranch className="h-5 w-5 text-indigo-600" />
|
||||
Team-Workflow (3+ Entwickler)
|
||||
</h2>
|
||||
|
||||
<div className="bg-white rounded-xl p-5 mb-4">
|
||||
<h3 className="font-semibold text-slate-900 mb-3">Feature Branch Workflow</h3>
|
||||
<div className="flex flex-wrap items-center gap-2 text-sm">
|
||||
<code className="bg-slate-100 px-2 py-1 rounded">main</code>
|
||||
<ArrowRight className="h-4 w-4 text-slate-400" />
|
||||
<code className="bg-blue-100 text-blue-700 px-2 py-1 rounded">feature/neue-funktion</code>
|
||||
<ArrowRight className="h-4 w-4 text-slate-400" />
|
||||
<span className="text-slate-600">Entwicklung</span>
|
||||
<ArrowRight className="h-4 w-4 text-slate-400" />
|
||||
<span className="bg-purple-100 text-purple-700 px-2 py-1 rounded">Pull Request</span>
|
||||
<ArrowRight className="h-4 w-4 text-slate-400" />
|
||||
<span className="bg-green-100 text-green-700 px-2 py-1 rounded">Code Review</span>
|
||||
<ArrowRight className="h-4 w-4 text-slate-400" />
|
||||
<code className="bg-slate-100 px-2 py-1 rounded">main</code>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div className="bg-white rounded-lg p-4 border border-indigo-100">
|
||||
<h4 className="font-medium text-slate-900 mb-2">1. Feature Branch erstellen</h4>
|
||||
<code className="block text-xs bg-slate-800 text-green-400 p-2 rounded font-mono">
|
||||
git checkout -b feature/mein-feature
|
||||
</code>
|
||||
</div>
|
||||
<div className="bg-white rounded-lg p-4 border border-indigo-100">
|
||||
<h4 className="font-medium text-slate-900 mb-2">2. Änderungen committen</h4>
|
||||
<code className="block text-xs bg-slate-800 text-green-400 p-2 rounded font-mono">
|
||||
git commit -m "feat: beschreibung"
|
||||
</code>
|
||||
</div>
|
||||
<div className="bg-white rounded-lg p-4 border border-indigo-100">
|
||||
<h4 className="font-medium text-slate-900 mb-2">3. Branch pushen</h4>
|
||||
<code className="block text-xs bg-slate-800 text-green-400 p-2 rounded font-mono">
|
||||
git push -u origin feature/mein-feature
|
||||
</code>
|
||||
</div>
|
||||
<div className="bg-white rounded-lg p-4 border border-indigo-100">
|
||||
<h4 className="font-medium text-slate-900 mb-2">4. Pull Request in Gitea</h4>
|
||||
<code className="block text-xs bg-slate-800 text-green-400 p-2 rounded font-mono">
|
||||
http://macmini:3003 → Pull Request
|
||||
</code>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 p-4 bg-indigo-100 rounded-lg">
|
||||
<h4 className="font-medium text-indigo-900 mb-2">Branch-Namenskonvention</h4>
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-2 text-sm">
|
||||
<div><code className="text-indigo-700">feature/</code> Neue Funktion</div>
|
||||
<div><code className="text-indigo-700">fix/</code> Bugfix</div>
|
||||
<div><code className="text-indigo-700">hotfix/</code> Dringender Fix</div>
|
||||
<div><code className="text-indigo-700">refactor/</code> Code-Umbau</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Team Rules */}
|
||||
<div className="bg-amber-50 rounded-xl border border-amber-200 p-6">
|
||||
<h2 className="text-xl font-semibold text-amber-900 mb-4 flex items-center gap-2">
|
||||
<Users className="h-5 w-5 text-amber-600" />
|
||||
Team-Regeln
|
||||
</h2>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<CheckCircle2 className="h-5 w-5 text-green-600 flex-shrink-0 mt-0.5" />
|
||||
<div>
|
||||
<h3 className="font-medium text-slate-900">Feature Branches nutzen</h3>
|
||||
<p className="text-sm text-slate-600">Nie direkt auf main pushen - immer über Pull Request</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-start gap-3">
|
||||
<CheckCircle2 className="h-5 w-5 text-green-600 flex-shrink-0 mt-0.5" />
|
||||
<div>
|
||||
<h3 className="font-medium text-slate-900">Code Review erforderlich</h3>
|
||||
<p className="text-sm text-slate-600">Mindestens 1 Approval vor dem Merge</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-start gap-3">
|
||||
<CheckCircle2 className="h-5 w-5 text-green-600 flex-shrink-0 mt-0.5" />
|
||||
<div>
|
||||
<h3 className="font-medium text-slate-900">Tests müssen grün sein</h3>
|
||||
<p className="text-sm text-slate-600">CI/CD Pipeline muss erfolgreich durchlaufen</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-start gap-3">
|
||||
<CheckCircle2 className="h-5 w-5 text-green-600 flex-shrink-0 mt-0.5" />
|
||||
<div>
|
||||
<h3 className="font-medium text-slate-900">Aussagekräftige Commits</h3>
|
||||
<p className="text-sm text-slate-600">Nutze Conventional Commits (feat:, fix:, etc.)</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-start gap-3">
|
||||
<CheckCircle2 className="h-5 w-5 text-green-600 flex-shrink-0 mt-0.5" />
|
||||
<div>
|
||||
<h3 className="font-medium text-slate-900">Branch aktuell halten</h3>
|
||||
<p className="text-sm text-slate-600">Regelmäßig main in deinen Branch mergen</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-start gap-3">
|
||||
<AlertTriangle className="h-5 w-5 text-amber-600 flex-shrink-0 mt-0.5" />
|
||||
<div>
|
||||
<h3 className="font-medium text-slate-900">Nie Force-Push auf main</h3>
|
||||
<p className="text-sm text-slate-600">Geschichte von main nie überschreiben</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* CI/CD Infrastruktur - Automatisierte OAuth Integration */}
|
||||
<div className="bg-white rounded-xl border border-slate-200 p-6">
|
||||
<h2 className="text-xl font-semibold text-slate-900 mb-4 flex items-center gap-2">
|
||||
<Shield className="h-5 w-5 text-indigo-600" />
|
||||
CI/CD Infrastruktur (Automatisiert)
|
||||
</h2>
|
||||
|
||||
<div className="bg-blue-50 rounded-xl p-4 mb-6 border border-blue-200">
|
||||
<div className="flex items-start gap-3">
|
||||
<Info className="h-5 w-5 text-blue-600 flex-shrink-0 mt-0.5" />
|
||||
<div>
|
||||
<h4 className="font-medium text-blue-900">Warum automatisiert?</h4>
|
||||
<p className="text-sm text-blue-800 mt-1">
|
||||
Die OAuth-Integration zwischen Woodpecker und Gitea ist vollautomatisiert.
|
||||
Dies ist eine DevSecOps Best Practice: Credentials werden in HashiCorp Vault gespeichert
|
||||
und können bei Bedarf automatisch regeneriert werden.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
{/* Architektur */}
|
||||
<div className="bg-slate-50 rounded-xl p-5 border border-slate-200">
|
||||
<h3 className="font-semibold text-slate-900 mb-3">Architektur</h3>
|
||||
<div className="space-y-3 text-sm">
|
||||
<div className="flex items-center gap-3 p-2 bg-white rounded-lg border">
|
||||
<div className="w-3 h-3 bg-green-500 rounded-full" />
|
||||
<span className="font-medium">Gitea</span>
|
||||
<span className="text-slate-500">Port 3003</span>
|
||||
<span className="text-xs text-slate-400 ml-auto">Git Server</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-center">
|
||||
<ArrowRight className="h-4 w-4 text-slate-400 rotate-90" />
|
||||
<span className="text-xs text-slate-500 ml-2">OAuth 2.0</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 p-2 bg-white rounded-lg border">
|
||||
<div className="w-3 h-3 bg-blue-500 rounded-full" />
|
||||
<span className="font-medium">Woodpecker</span>
|
||||
<span className="text-slate-500">Port 8090</span>
|
||||
<span className="text-xs text-slate-400 ml-auto">CI/CD Server</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-center">
|
||||
<ArrowRight className="h-4 w-4 text-slate-400 rotate-90" />
|
||||
<span className="text-xs text-slate-500 ml-2">Credentials</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 p-2 bg-white rounded-lg border">
|
||||
<div className="w-3 h-3 bg-purple-500 rounded-full" />
|
||||
<span className="font-medium">Vault</span>
|
||||
<span className="text-slate-500">Port 8200</span>
|
||||
<span className="text-xs text-slate-400 ml-auto">Secrets Manager</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Credentials Speicherort */}
|
||||
<div className="bg-slate-50 rounded-xl p-5 border border-slate-200">
|
||||
<h3 className="font-semibold text-slate-900 mb-3">Credentials Speicherorte</h3>
|
||||
<div className="space-y-3 text-sm">
|
||||
<div className="p-3 bg-white rounded-lg border">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<Database className="h-4 w-4 text-purple-500" />
|
||||
<span className="font-medium">HashiCorp Vault</span>
|
||||
</div>
|
||||
<code className="text-xs bg-slate-100 px-2 py-1 rounded">
|
||||
secret/cicd/woodpecker
|
||||
</code>
|
||||
<p className="text-xs text-slate-500 mt-1">Client ID + Secret (Quelle der Wahrheit)</p>
|
||||
</div>
|
||||
<div className="p-3 bg-white rounded-lg border">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<FileCode className="h-4 w-4 text-blue-500" />
|
||||
<span className="font-medium">.env Datei</span>
|
||||
</div>
|
||||
<code className="text-xs bg-slate-100 px-2 py-1 rounded">
|
||||
WOODPECKER_GITEA_CLIENT/SECRET
|
||||
</code>
|
||||
<p className="text-xs text-slate-500 mt-1">Für Docker Compose (aus Vault geladen)</p>
|
||||
</div>
|
||||
<div className="p-3 bg-white rounded-lg border">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<Database className="h-4 w-4 text-green-500" />
|
||||
<span className="font-medium">Gitea PostgreSQL</span>
|
||||
</div>
|
||||
<code className="text-xs bg-slate-100 px-2 py-1 rounded">
|
||||
oauth2_application
|
||||
</code>
|
||||
<p className="text-xs text-slate-500 mt-1">OAuth App Registration (gehashtes Secret)</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Troubleshooting */}
|
||||
<div className="mt-6 bg-amber-50 rounded-xl p-5 border border-amber-200">
|
||||
<h3 className="font-semibold text-amber-900 mb-3 flex items-center gap-2">
|
||||
<AlertTriangle className="h-5 w-5 text-amber-600" />
|
||||
Troubleshooting: OAuth Fehler beheben
|
||||
</h3>
|
||||
<p className="text-sm text-amber-800 mb-3">
|
||||
Falls der Fehler "Client ID not registered" oder "user does not exist" auftritt:
|
||||
</p>
|
||||
<div className="bg-slate-800 rounded-lg p-4 font-mono text-sm">
|
||||
<p className="text-slate-400"># Credentials automatisch regenerieren</p>
|
||||
<p className="text-green-400">./scripts/sync-woodpecker-credentials.sh --regenerate</p>
|
||||
<p className="text-slate-400 mt-2"># Oder manuell: Vault → Gitea → .env → Restart</p>
|
||||
<p className="text-green-400">rsync .env macmini:~/Projekte/breakpilot-pwa/</p>
|
||||
<p className="text-green-400">ssh macmini "cd ~/Projekte/breakpilot-pwa && docker compose up -d --force-recreate woodpecker-server"</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Team Members Info */}
|
||||
<div className="bg-white rounded-xl border border-slate-200 p-6">
|
||||
<h2 className="text-xl font-semibold text-slate-900 mb-4 flex items-center gap-2">
|
||||
<Users className="h-5 w-5 text-indigo-600" />
|
||||
Team-Kommunikation
|
||||
</h2>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<div className="bg-slate-50 rounded-lg p-4 text-center">
|
||||
<div className="text-3xl mb-2">💬</div>
|
||||
<h3 className="font-medium text-slate-900">Pull Request Kommentare</h3>
|
||||
<p className="text-sm text-slate-600 mt-1">Code-Diskussionen im PR</p>
|
||||
</div>
|
||||
<div className="bg-slate-50 rounded-lg p-4 text-center">
|
||||
<div className="text-3xl mb-2">📋</div>
|
||||
<h3 className="font-medium text-slate-900">Issues in Gitea</h3>
|
||||
<p className="text-sm text-slate-600 mt-1">Bugs & Features tracken</p>
|
||||
</div>
|
||||
<div className="bg-slate-50 rounded-lg p-4 text-center">
|
||||
<div className="text-3xl mb-2">🔔</div>
|
||||
<h3 className="font-medium text-slate-900">CI/CD Notifications</h3>
|
||||
<p className="text-sm text-slate-600 mt-1">Pipeline-Status per Mail</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,223 @@
|
||||
'use client'
|
||||
|
||||
/**
|
||||
* AehnlicheDokumente - RAG-based similar documents panel
|
||||
* Shows documents with similar content based on vector similarity
|
||||
*/
|
||||
|
||||
import { useState, useEffect } from 'react'
|
||||
import { Loader2, FileText, AlertCircle, RefreshCw, ExternalLink } from 'lucide-react'
|
||||
import type { AbiturDokument } from '@/lib/education/abitur-docs-types'
|
||||
import type { SimilarDocument } from '@/lib/education/abitur-archiv-types'
|
||||
import { FAECHER } from '@/lib/education/abitur-docs-types'
|
||||
|
||||
interface AehnlicheDokumenteProps {
|
||||
documentId: string
|
||||
onSelectDocument: (doc: AbiturDokument) => void
|
||||
limit?: number
|
||||
}
|
||||
|
||||
export function AehnlicheDokumente({
|
||||
documentId,
|
||||
onSelectDocument,
|
||||
limit = 5
|
||||
}: AehnlicheDokumenteProps) {
|
||||
const [similarDocs, setSimilarDocs] = useState<SimilarDocument[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
const fetchSimilarDocuments = async () => {
|
||||
if (!documentId) return
|
||||
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
|
||||
try {
|
||||
const res = await fetch(`/api/education/abitur-archiv/similar?id=${documentId}&limit=${limit}`)
|
||||
|
||||
if (!res.ok) {
|
||||
// Use mock data if endpoint not available
|
||||
setSimilarDocs(getMockSimilarDocuments(documentId))
|
||||
return
|
||||
}
|
||||
|
||||
const data = await res.json()
|
||||
setSimilarDocs(data.similar || [])
|
||||
} catch (err) {
|
||||
console.log('Similar docs fetch failed, using mock data')
|
||||
setSimilarDocs(getMockSimilarDocuments(documentId))
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
fetchSimilarDocuments()
|
||||
}, [documentId, limit])
|
||||
|
||||
const handleRefresh = () => {
|
||||
setLoading(true)
|
||||
// Re-trigger the effect
|
||||
setSimilarDocs([])
|
||||
setTimeout(() => {
|
||||
setSimilarDocs(getMockSimilarDocuments(documentId))
|
||||
setLoading(false)
|
||||
}, 500)
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center py-8">
|
||||
<Loader2 className="w-8 h-8 text-blue-600 animate-spin mb-3" />
|
||||
<p className="text-sm text-slate-500">Suche aehnliche Dokumente...</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="text-center py-8">
|
||||
<AlertCircle className="w-10 h-10 text-red-400 mx-auto mb-3" />
|
||||
<p className="text-sm text-red-600 mb-3">{error}</p>
|
||||
<button
|
||||
onClick={handleRefresh}
|
||||
className="px-4 py-2 text-sm text-blue-600 hover:bg-blue-50 rounded-lg flex items-center gap-2 mx-auto"
|
||||
>
|
||||
<RefreshCw className="w-4 h-4" />
|
||||
Erneut versuchen
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (similarDocs.length === 0) {
|
||||
return (
|
||||
<div className="text-center py-8">
|
||||
<FileText className="w-10 h-10 text-slate-300 mx-auto mb-3" />
|
||||
<p className="text-sm text-slate-500">Keine aehnlichen Dokumente gefunden</p>
|
||||
<p className="text-xs text-slate-400 mt-1">
|
||||
Versuchen Sie eine andere Suche oder laden Sie mehr Dokumente hoch.
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<h4 className="text-sm font-medium text-slate-700">Aehnliche Dokumente</h4>
|
||||
<button
|
||||
onClick={handleRefresh}
|
||||
className="p-1.5 text-slate-400 hover:text-slate-600 hover:bg-slate-100 rounded"
|
||||
title="Aktualisieren"
|
||||
>
|
||||
<RefreshCw className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
{similarDocs.map((doc) => (
|
||||
<SimilarDocumentCard
|
||||
key={doc.id}
|
||||
document={doc}
|
||||
onSelect={() => {
|
||||
// Convert SimilarDocument to AbiturDokument for selection
|
||||
// In production, this would fetch the full document
|
||||
onSelectDocument(doc as unknown as AbiturDokument)
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<p className="text-xs text-slate-400 text-center pt-2">
|
||||
Basierend auf semantischer Aehnlichkeit (RAG)
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function SimilarDocumentCard({
|
||||
document,
|
||||
onSelect
|
||||
}: {
|
||||
document: SimilarDocument
|
||||
onSelect: () => void
|
||||
}) {
|
||||
const fachLabel = FAECHER.find(f => f.id === document.fach)?.label || document.fach
|
||||
const similarityPercent = Math.round(document.similarity_score * 100)
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={onSelect}
|
||||
className="w-full flex items-start gap-3 p-3 bg-white border border-slate-200 rounded-lg
|
||||
hover:bg-blue-50 hover:border-blue-200 transition-colors text-left group"
|
||||
>
|
||||
{/* Icon */}
|
||||
<div className="w-10 h-10 bg-slate-100 rounded-lg flex items-center justify-center flex-shrink-0
|
||||
group-hover:bg-blue-100 transition-colors">
|
||||
<FileText className="w-5 h-5 text-slate-400 group-hover:text-blue-500" />
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="font-medium text-slate-800 truncate group-hover:text-blue-700">
|
||||
{fachLabel} {document.jahr}
|
||||
</div>
|
||||
<div className="text-sm text-slate-500 flex items-center gap-2">
|
||||
<span>{document.niveau}</span>
|
||||
<span>|</span>
|
||||
<span>Aufgabe {document.aufgaben_nummer}</span>
|
||||
{document.typ === 'erwartungshorizont' && (
|
||||
<>
|
||||
<span>|</span>
|
||||
<span className="text-orange-600">EWH</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Similarity Score */}
|
||||
<div className="flex-shrink-0">
|
||||
<div className={`px-2 py-1 rounded-full text-xs font-medium ${
|
||||
similarityPercent >= 80
|
||||
? 'bg-green-100 text-green-700'
|
||||
: similarityPercent >= 60
|
||||
? 'bg-yellow-100 text-yellow-700'
|
||||
: 'bg-slate-100 text-slate-600'
|
||||
}`}>
|
||||
{similarityPercent}%
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
// Mock data generator for development
|
||||
function getMockSimilarDocuments(documentId: string): SimilarDocument[] {
|
||||
// Generate consistent mock data based on document ID
|
||||
const idHash = documentId.split('').reduce((acc, char) => acc + char.charCodeAt(0), 0)
|
||||
|
||||
const faecher = ['deutsch', 'englisch']
|
||||
const jahre = [2021, 2022, 2023, 2024, 2025]
|
||||
const niveaus: Array<'eA' | 'gA'> = ['eA', 'gA']
|
||||
const nummern = ['I', 'II', 'III']
|
||||
const typen: Array<'aufgabe' | 'erwartungshorizont'> = ['aufgabe', 'erwartungshorizont']
|
||||
|
||||
const docs: SimilarDocument[] = []
|
||||
|
||||
for (let i = 0; i < 5; i++) {
|
||||
const idx = (idHash + i) % (faecher.length * jahre.length * niveaus.length)
|
||||
docs.push({
|
||||
id: `similar-${documentId}-${i}`,
|
||||
dateiname: `${jahre[idx % jahre.length]}_${faecher[idx % faecher.length]}_${niveaus[idx % niveaus.length]}_${nummern[idx % nummern.length]}.pdf`,
|
||||
similarity_score: 0.95 - (i * 0.1) + (Math.random() * 0.05),
|
||||
fach: faecher[idx % faecher.length],
|
||||
jahr: jahre[(idx + i) % jahre.length],
|
||||
niveau: niveaus[idx % niveaus.length],
|
||||
typ: typen[(idx + i) % typen.length],
|
||||
aufgaben_nummer: nummern[(idx + i) % nummern.length]
|
||||
})
|
||||
}
|
||||
|
||||
return docs.sort((a, b) => b.similarity_score - a.similarity_score)
|
||||
}
|
||||
@@ -0,0 +1,203 @@
|
||||
'use client'
|
||||
|
||||
/**
|
||||
* DokumentCard - Card component for Abitur document grid view
|
||||
* Features: Preview, Download, Add to Klausur actions
|
||||
*/
|
||||
|
||||
import { useState } from 'react'
|
||||
import { FileText, Eye, Download, Plus, Calendar, Layers, BookOpen, ExternalLink } from 'lucide-react'
|
||||
import type { AbiturDokument } from '@/lib/education/abitur-docs-types'
|
||||
import { formatFileSize, FAECHER, NIVEAUS } from '@/lib/education/abitur-docs-types'
|
||||
|
||||
interface DokumentCardProps {
|
||||
document: AbiturDokument
|
||||
onPreview: (doc: AbiturDokument) => void
|
||||
onDownload: (doc: AbiturDokument) => void
|
||||
onAddToKlausur?: (doc: AbiturDokument) => void
|
||||
}
|
||||
|
||||
export function DokumentCard({
|
||||
document,
|
||||
onPreview,
|
||||
onDownload,
|
||||
onAddToKlausur
|
||||
}: DokumentCardProps) {
|
||||
const [isHovered, setIsHovered] = useState(false)
|
||||
|
||||
const fachLabel = FAECHER.find(f => f.id === document.fach)?.label || document.fach
|
||||
const niveauLabel = document.niveau === 'eA' ? 'Erhoehtes Niveau' : 'Grundlegendes Niveau'
|
||||
|
||||
const handleDownload = (e: React.MouseEvent) => {
|
||||
e.stopPropagation()
|
||||
onDownload(document)
|
||||
}
|
||||
|
||||
const handleAddToKlausur = (e: React.MouseEvent) => {
|
||||
e.stopPropagation()
|
||||
onAddToKlausur?.(document)
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="bg-white rounded-xl border border-slate-200 overflow-hidden hover:shadow-lg
|
||||
transition-all duration-200 cursor-pointer group"
|
||||
onMouseEnter={() => setIsHovered(true)}
|
||||
onMouseLeave={() => setIsHovered(false)}
|
||||
onClick={() => onPreview(document)}
|
||||
>
|
||||
{/* Header with Type Badge */}
|
||||
<div className="relative h-32 bg-gradient-to-br from-slate-100 to-slate-50 flex items-center justify-center">
|
||||
<FileText className="w-16 h-16 text-slate-300 group-hover:text-blue-400 transition-colors" />
|
||||
|
||||
{/* Type Badge */}
|
||||
<div className="absolute top-3 left-3">
|
||||
<span className={`px-2.5 py-1 rounded-full text-xs font-medium ${
|
||||
document.typ === 'erwartungshorizont'
|
||||
? 'bg-orange-100 text-orange-700'
|
||||
: 'bg-purple-100 text-purple-700'
|
||||
}`}>
|
||||
{document.typ === 'erwartungshorizont' ? 'Erwartungshorizont' : 'Aufgabe'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Year Badge */}
|
||||
<div className="absolute top-3 right-3">
|
||||
<span className="px-2 py-1 bg-white/80 backdrop-blur-sm rounded-lg text-xs font-semibold text-slate-700">
|
||||
{document.jahr}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Status Badge */}
|
||||
<div className="absolute bottom-3 right-3">
|
||||
<span className={`px-2 py-0.5 rounded-full text-xs ${
|
||||
document.status === 'indexed'
|
||||
? 'bg-green-100 text-green-700'
|
||||
: document.status === 'error'
|
||||
? 'bg-red-100 text-red-700'
|
||||
: 'bg-yellow-100 text-yellow-700'
|
||||
}`}>
|
||||
{document.status === 'indexed' ? 'Indexiert' : document.status === 'error' ? 'Fehler' : 'Ausstehend'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Hover Overlay with Preview */}
|
||||
{isHovered && (
|
||||
<div className="absolute inset-0 bg-black/40 flex items-center justify-center">
|
||||
<button
|
||||
className="px-4 py-2 bg-white text-slate-800 rounded-lg font-medium
|
||||
flex items-center gap-2 shadow-lg hover:bg-blue-50 transition-colors"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
onPreview(document)
|
||||
}}
|
||||
>
|
||||
<Eye className="w-4 h-4" />
|
||||
Vorschau
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="p-4">
|
||||
{/* Title */}
|
||||
<h3 className="font-semibold text-slate-800 mb-2 line-clamp-2 min-h-[2.5rem]">
|
||||
{fachLabel} {document.niveau} - Aufgabe {document.aufgaben_nummer}
|
||||
</h3>
|
||||
|
||||
{/* Metadata */}
|
||||
<div className="space-y-1.5 mb-4">
|
||||
<div className="flex items-center gap-2 text-sm text-slate-500">
|
||||
<BookOpen className="w-4 h-4" />
|
||||
<span>{fachLabel}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-sm text-slate-500">
|
||||
<Layers className="w-4 h-4" />
|
||||
<span>{niveauLabel}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-sm text-slate-500">
|
||||
<ExternalLink className="w-4 h-4" />
|
||||
<span className="capitalize">{document.bundesland}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-xs text-slate-400">
|
||||
<span>{formatFileSize(document.file_size)}</span>
|
||||
<span>|</span>
|
||||
<span>{document.dateiname}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Action Buttons */}
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => onPreview(document)}
|
||||
className="flex-1 px-3 py-2 bg-blue-50 text-blue-700 rounded-lg hover:bg-blue-100
|
||||
transition-colors text-sm font-medium flex items-center justify-center gap-1.5"
|
||||
>
|
||||
<Eye className="w-4 h-4" />
|
||||
Vorschau
|
||||
</button>
|
||||
<button
|
||||
onClick={handleDownload}
|
||||
className="px-3 py-2 text-slate-600 hover:bg-slate-100 rounded-lg transition-colors"
|
||||
title="Herunterladen"
|
||||
>
|
||||
<Download className="w-4 h-4" />
|
||||
</button>
|
||||
{onAddToKlausur && (
|
||||
<button
|
||||
onClick={handleAddToKlausur}
|
||||
className="px-3 py-2 text-green-600 hover:bg-green-50 rounded-lg transition-colors"
|
||||
title="Zur Klausur hinzufuegen"
|
||||
>
|
||||
<Plus className="w-4 h-4" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Compact card variant for list view or similar documents
|
||||
*/
|
||||
export function DokumentCardCompact({
|
||||
document,
|
||||
onPreview,
|
||||
similarity_score
|
||||
}: {
|
||||
document: AbiturDokument
|
||||
onPreview: (doc: AbiturDokument) => void
|
||||
similarity_score?: number
|
||||
}) {
|
||||
const fachLabel = FAECHER.find(f => f.id === document.fach)?.label || document.fach
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={() => onPreview(document)}
|
||||
className="w-full flex items-center gap-3 p-3 bg-white border border-slate-200 rounded-lg
|
||||
hover:bg-slate-50 hover:border-slate-300 transition-colors text-left"
|
||||
>
|
||||
<div className="w-10 h-10 bg-slate-100 rounded-lg flex items-center justify-center flex-shrink-0">
|
||||
<FileText className="w-5 h-5 text-slate-400" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="font-medium text-slate-800 truncate">
|
||||
{fachLabel} {document.jahr} - {document.niveau}
|
||||
</div>
|
||||
<div className="text-sm text-slate-500 truncate">
|
||||
Aufgabe {document.aufgaben_nummer}
|
||||
{document.typ === 'erwartungshorizont' && ' (EWH)'}
|
||||
</div>
|
||||
</div>
|
||||
{similarity_score !== undefined && (
|
||||
<div className="flex-shrink-0">
|
||||
<span className="px-2 py-1 bg-green-100 text-green-700 text-xs rounded-full">
|
||||
{Math.round(similarity_score * 100)}%
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,456 @@
|
||||
'use client'
|
||||
|
||||
/**
|
||||
* FullscreenViewer - Enhanced PDF viewer with fullscreen, zoom, and page navigation
|
||||
* Features: Keyboard shortcuts, zoom controls, similar documents panel
|
||||
*/
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import {
|
||||
X, Download, ZoomIn, ZoomOut, Maximize2, Minimize2,
|
||||
ChevronLeft, ChevronRight, RotateCw, FileText, Search,
|
||||
BookOpen, Calendar, Layers, ExternalLink, Plus
|
||||
} from 'lucide-react'
|
||||
import type { AbiturDokument } from '@/lib/education/abitur-docs-types'
|
||||
import { formatFileSize, formatDocumentTitle, FAECHER, NIVEAUS } from '@/lib/education/abitur-docs-types'
|
||||
import { ZOOM_LEVELS, MIN_ZOOM, MAX_ZOOM, ZOOM_STEP } from '@/lib/education/abitur-archiv-types'
|
||||
import { AehnlicheDokumente } from './AehnlicheDokumente'
|
||||
|
||||
interface FullscreenViewerProps {
|
||||
document: AbiturDokument | null
|
||||
onClose: () => void
|
||||
onAddToKlausur?: (doc: AbiturDokument) => void
|
||||
backendUrl?: string
|
||||
}
|
||||
|
||||
export function FullscreenViewer({
|
||||
document,
|
||||
onClose,
|
||||
onAddToKlausur,
|
||||
backendUrl = ''
|
||||
}: FullscreenViewerProps) {
|
||||
const [isFullscreen, setIsFullscreen] = useState(false)
|
||||
const [zoom, setZoom] = useState(100)
|
||||
const [currentPage, setCurrentPage] = useState(1)
|
||||
const [totalPages, setTotalPages] = useState(1)
|
||||
const [showSidebar, setShowSidebar] = useState(true)
|
||||
const [activeTab, setActiveTab] = useState<'details' | 'similar'>('details')
|
||||
|
||||
// Reset state when document changes
|
||||
useEffect(() => {
|
||||
setZoom(100)
|
||||
setCurrentPage(1)
|
||||
setIsFullscreen(false)
|
||||
}, [document?.id])
|
||||
|
||||
// Keyboard shortcuts
|
||||
useEffect(() => {
|
||||
if (!document) return
|
||||
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
// Ignore if typing in an input
|
||||
if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) {
|
||||
return
|
||||
}
|
||||
|
||||
switch (e.key) {
|
||||
case 'Escape':
|
||||
if (isFullscreen) {
|
||||
setIsFullscreen(false)
|
||||
} else {
|
||||
onClose()
|
||||
}
|
||||
break
|
||||
case 'f':
|
||||
case 'F11':
|
||||
e.preventDefault()
|
||||
setIsFullscreen(prev => !prev)
|
||||
break
|
||||
case '+':
|
||||
case '=':
|
||||
e.preventDefault()
|
||||
setZoom(z => Math.min(MAX_ZOOM, z + ZOOM_STEP))
|
||||
break
|
||||
case '-':
|
||||
e.preventDefault()
|
||||
setZoom(z => Math.max(MIN_ZOOM, z - ZOOM_STEP))
|
||||
break
|
||||
case '0':
|
||||
e.preventDefault()
|
||||
setZoom(100)
|
||||
break
|
||||
case 'ArrowLeft':
|
||||
e.preventDefault()
|
||||
setCurrentPage(p => Math.max(1, p - 1))
|
||||
break
|
||||
case 'ArrowRight':
|
||||
e.preventDefault()
|
||||
setCurrentPage(p => Math.min(totalPages, p + 1))
|
||||
break
|
||||
case 's':
|
||||
if (e.ctrlKey || e.metaKey) {
|
||||
e.preventDefault()
|
||||
handleDownload()
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener('keydown', handleKeyDown)
|
||||
return () => window.removeEventListener('keydown', handleKeyDown)
|
||||
}, [document, isFullscreen, totalPages, onClose])
|
||||
|
||||
// Handle native fullscreen changes
|
||||
useEffect(() => {
|
||||
const handleFullscreenChange = () => {
|
||||
setIsFullscreen(!!window.document.fullscreenElement)
|
||||
}
|
||||
|
||||
window.document.addEventListener('fullscreenchange', handleFullscreenChange)
|
||||
return () => window.document.removeEventListener('fullscreenchange', handleFullscreenChange)
|
||||
}, [])
|
||||
|
||||
const handleDownload = useCallback(() => {
|
||||
if (!document) return
|
||||
const link = window.document.createElement('a')
|
||||
link.href = pdfUrl
|
||||
link.download = document.dateiname
|
||||
link.click()
|
||||
}, [document])
|
||||
|
||||
const handleSearchInRAG = () => {
|
||||
if (!document) return
|
||||
window.location.href = `/education/edu-search?doc=${document.id}&search=1`
|
||||
}
|
||||
|
||||
const handleAddToKlausur = () => {
|
||||
if (!document || !onAddToKlausur) return
|
||||
onAddToKlausur(document)
|
||||
}
|
||||
|
||||
if (!document) return null
|
||||
|
||||
const fachLabel = FAECHER.find(f => f.id === document.fach)?.label || document.fach
|
||||
const niveauLabel = NIVEAUS.find(n => n.id === document.niveau)?.label || document.niveau
|
||||
|
||||
// Build PDF URL
|
||||
const pdfUrl = backendUrl
|
||||
? `${backendUrl}/api/abitur-docs/${document.id}/file`
|
||||
: document.file_path
|
||||
|
||||
return (
|
||||
<div className={`fixed inset-0 z-50 flex ${isFullscreen ? 'bg-black' : 'bg-black/60 backdrop-blur-sm'}`}>
|
||||
{/* Modal Container */}
|
||||
<div className={`relative bg-white flex flex-col ${
|
||||
isFullscreen ? 'w-full h-full' : 'w-[95vw] h-[95vh] max-w-7xl m-auto rounded-2xl overflow-hidden shadow-2xl'
|
||||
}`}>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-4 py-3 bg-white border-b border-slate-200">
|
||||
<div className="flex items-center gap-3">
|
||||
<FileText className="w-5 h-5 text-blue-600" />
|
||||
<div>
|
||||
<h2 className="font-semibold text-slate-900">
|
||||
{formatDocumentTitle(document)}
|
||||
</h2>
|
||||
<p className="text-sm text-slate-500">
|
||||
{document.dateiname}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Toolbar */}
|
||||
<div className="flex items-center gap-2">
|
||||
{/* Zoom Controls */}
|
||||
<div className="flex items-center gap-1 px-2 py-1 bg-slate-100 rounded-lg">
|
||||
<button
|
||||
onClick={() => setZoom(z => Math.max(MIN_ZOOM, z - ZOOM_STEP))}
|
||||
className="p-1.5 hover:bg-slate-200 rounded"
|
||||
title="Verkleinern (-)"
|
||||
>
|
||||
<ZoomOut className="w-4 h-4 text-slate-600" />
|
||||
</button>
|
||||
<span className="text-sm font-medium text-slate-700 w-12 text-center">
|
||||
{zoom}%
|
||||
</span>
|
||||
<button
|
||||
onClick={() => setZoom(z => Math.min(MAX_ZOOM, z + ZOOM_STEP))}
|
||||
className="p-1.5 hover:bg-slate-200 rounded"
|
||||
title="Vergroessern (+)"
|
||||
>
|
||||
<ZoomIn className="w-4 h-4 text-slate-600" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setZoom(100)}
|
||||
className="p-1.5 hover:bg-slate-200 rounded ml-1"
|
||||
title="Zuruecksetzen (0)"
|
||||
>
|
||||
<RotateCw className="w-4 h-4 text-slate-600" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Page Navigation */}
|
||||
{totalPages > 1 && (
|
||||
<div className="flex items-center gap-1 px-2 py-1 bg-slate-100 rounded-lg">
|
||||
<button
|
||||
onClick={() => setCurrentPage(p => Math.max(1, p - 1))}
|
||||
disabled={currentPage === 1}
|
||||
className="p-1.5 hover:bg-slate-200 rounded disabled:opacity-50"
|
||||
>
|
||||
<ChevronLeft className="w-4 h-4 text-slate-600" />
|
||||
</button>
|
||||
<span className="text-sm font-medium text-slate-700 min-w-[60px] text-center">
|
||||
{currentPage} / {totalPages}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => setCurrentPage(p => Math.min(totalPages, p + 1))}
|
||||
disabled={currentPage === totalPages}
|
||||
className="p-1.5 hover:bg-slate-200 rounded disabled:opacity-50"
|
||||
>
|
||||
<ChevronRight className="w-4 h-4 text-slate-600" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="w-px h-6 bg-slate-200" />
|
||||
|
||||
{/* Action Buttons */}
|
||||
<button
|
||||
onClick={handleSearchInRAG}
|
||||
className="px-3 py-1.5 text-sm bg-purple-100 text-purple-700 rounded-lg hover:bg-purple-200 flex items-center gap-1.5"
|
||||
title="In RAG suchen"
|
||||
>
|
||||
<Search className="w-4 h-4" />
|
||||
<span className="hidden sm:inline">RAG-Suche</span>
|
||||
</button>
|
||||
|
||||
{onAddToKlausur && (
|
||||
<button
|
||||
onClick={handleAddToKlausur}
|
||||
className="px-3 py-1.5 text-sm bg-green-100 text-green-700 rounded-lg hover:bg-green-200 flex items-center gap-1.5"
|
||||
title="Als Vorlage verwenden"
|
||||
>
|
||||
<Plus className="w-4 h-4" />
|
||||
<span className="hidden sm:inline">Zur Klausur</span>
|
||||
</button>
|
||||
)}
|
||||
|
||||
<button
|
||||
onClick={handleDownload}
|
||||
className="px-3 py-1.5 text-sm bg-blue-100 text-blue-700 rounded-lg hover:bg-blue-200 flex items-center gap-1.5"
|
||||
title="Herunterladen (Ctrl+S)"
|
||||
>
|
||||
<Download className="w-4 h-4" />
|
||||
<span className="hidden sm:inline">Download</span>
|
||||
</button>
|
||||
|
||||
<div className="w-px h-6 bg-slate-200" />
|
||||
|
||||
<button
|
||||
onClick={() => setShowSidebar(!showSidebar)}
|
||||
className={`p-2 rounded-lg transition-colors ${
|
||||
showSidebar ? 'bg-slate-200 text-slate-700' : 'text-slate-500 hover:bg-slate-100'
|
||||
}`}
|
||||
title="Seitenleiste"
|
||||
>
|
||||
<Layers className="w-4 h-4" />
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => setIsFullscreen(!isFullscreen)}
|
||||
className="p-2 hover:bg-slate-100 rounded-lg"
|
||||
title={isFullscreen ? 'Vollbild beenden (F)' : 'Vollbild (F)'}
|
||||
>
|
||||
{isFullscreen ? (
|
||||
<Minimize2 className="w-5 h-5 text-slate-600" />
|
||||
) : (
|
||||
<Maximize2 className="w-5 h-5 text-slate-600" />
|
||||
)}
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="p-2 hover:bg-slate-100 rounded-lg"
|
||||
title="Schliessen (Esc)"
|
||||
>
|
||||
<X className="w-5 h-5 text-slate-500" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex flex-1 overflow-hidden">
|
||||
{/* PDF Viewer */}
|
||||
<div className="flex-1 bg-slate-100 p-4 overflow-auto">
|
||||
<div
|
||||
className="bg-white rounded-lg border border-slate-200 mx-auto shadow-sm transition-transform duration-200"
|
||||
style={{
|
||||
transform: `scale(${zoom / 100})`,
|
||||
transformOrigin: 'top center',
|
||||
width: '100%',
|
||||
maxWidth: zoom > 100 ? 'none' : '100%'
|
||||
}}
|
||||
>
|
||||
<iframe
|
||||
src={pdfUrl}
|
||||
className="w-full h-[calc(90vh-120px)] rounded-lg"
|
||||
title={document.dateiname}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Sidebar */}
|
||||
{showSidebar && (
|
||||
<div className="w-80 border-l border-slate-200 bg-slate-50 flex flex-col">
|
||||
{/* Sidebar Tabs */}
|
||||
<div className="flex border-b border-slate-200">
|
||||
<button
|
||||
onClick={() => setActiveTab('details')}
|
||||
className={`flex-1 px-4 py-3 text-sm font-medium transition-colors ${
|
||||
activeTab === 'details'
|
||||
? 'text-blue-600 border-b-2 border-blue-600 bg-white'
|
||||
: 'text-slate-600 hover:text-slate-800'
|
||||
}`}
|
||||
>
|
||||
Details
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab('similar')}
|
||||
className={`flex-1 px-4 py-3 text-sm font-medium transition-colors ${
|
||||
activeTab === 'similar'
|
||||
? 'text-blue-600 border-b-2 border-blue-600 bg-white'
|
||||
: 'text-slate-600 hover:text-slate-800'
|
||||
}`}
|
||||
>
|
||||
Aehnliche
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Sidebar Content */}
|
||||
<div className="flex-1 overflow-y-auto p-4">
|
||||
{activeTab === 'details' ? (
|
||||
<div className="space-y-4">
|
||||
{/* Fach */}
|
||||
<div className="flex items-start gap-3">
|
||||
<BookOpen className="w-5 h-5 text-slate-400 mt-0.5" />
|
||||
<div>
|
||||
<div className="text-xs text-slate-500 uppercase tracking-wide">Fach</div>
|
||||
<div className="font-medium text-slate-900">{fachLabel}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Jahr */}
|
||||
<div className="flex items-start gap-3">
|
||||
<Calendar className="w-5 h-5 text-slate-400 mt-0.5" />
|
||||
<div>
|
||||
<div className="text-xs text-slate-500 uppercase tracking-wide">Jahr</div>
|
||||
<div className="font-medium text-slate-900">{document.jahr}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Niveau */}
|
||||
<div className="flex items-start gap-3">
|
||||
<Layers className="w-5 h-5 text-slate-400 mt-0.5" />
|
||||
<div>
|
||||
<div className="text-xs text-slate-500 uppercase tracking-wide">Niveau</div>
|
||||
<div className="font-medium text-slate-900">{niveauLabel}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Aufgabe */}
|
||||
<div className="flex items-start gap-3">
|
||||
<FileText className="w-5 h-5 text-slate-400 mt-0.5" />
|
||||
<div>
|
||||
<div className="text-xs text-slate-500 uppercase tracking-wide">Aufgabe</div>
|
||||
<div className="font-medium text-slate-900">
|
||||
{document.aufgaben_nummer}
|
||||
<span className="ml-2 px-2 py-0.5 bg-slate-200 text-slate-700 text-xs rounded-full">
|
||||
{document.typ === 'erwartungshorizont' ? 'Erwartungshorizont' : 'Aufgabe'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Bundesland */}
|
||||
<div className="flex items-start gap-3">
|
||||
<ExternalLink className="w-5 h-5 text-slate-400 mt-0.5" />
|
||||
<div>
|
||||
<div className="text-xs text-slate-500 uppercase tracking-wide">Bundesland</div>
|
||||
<div className="font-medium text-slate-900 capitalize">{document.bundesland}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<hr className="border-slate-200" />
|
||||
|
||||
{/* File Info */}
|
||||
<div>
|
||||
<div className="text-xs text-slate-500 uppercase tracking-wide mb-2">Datei-Info</div>
|
||||
<div className="bg-white rounded-lg border border-slate-200 p-3 text-sm space-y-2">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-slate-500">Dateiname</span>
|
||||
<span className="text-slate-900 font-mono text-xs truncate max-w-[150px]" title={document.dateiname}>
|
||||
{document.dateiname}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-slate-500">Groesse</span>
|
||||
<span className="text-slate-900">{formatFileSize(document.file_size)}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-slate-500">Status</span>
|
||||
<span className={`px-2 py-0.5 rounded-full text-xs ${
|
||||
document.status === 'indexed'
|
||||
? 'bg-green-100 text-green-700'
|
||||
: document.status === 'error'
|
||||
? 'bg-red-100 text-red-700'
|
||||
: 'bg-yellow-100 text-yellow-700'
|
||||
}`}>
|
||||
{document.status === 'indexed' ? 'Indexiert' : document.status === 'error' ? 'Fehler' : 'Ausstehend'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* RAG Info */}
|
||||
{document.indexed && document.vector_ids.length > 0 && (
|
||||
<div>
|
||||
<div className="text-xs text-slate-500 uppercase tracking-wide mb-2">RAG-Index</div>
|
||||
<div className="bg-purple-50 border border-purple-200 rounded-lg p-3 text-sm">
|
||||
<div className="flex items-center gap-2 text-purple-700">
|
||||
<Search className="w-4 h-4" />
|
||||
<span>{document.vector_ids.length} Vektoren indexiert</span>
|
||||
</div>
|
||||
<div className="mt-2 text-xs text-purple-600">
|
||||
Confidence: {(document.confidence * 100).toFixed(0)}%
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Timestamps */}
|
||||
<div className="text-xs text-slate-400 pt-2">
|
||||
<div>Erstellt: {new Date(document.created_at).toLocaleString('de-DE')}</div>
|
||||
<div>Aktualisiert: {new Date(document.updated_at).toLocaleString('de-DE')}</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<AehnlicheDokumente
|
||||
documentId={document.id}
|
||||
onSelectDocument={(doc) => {
|
||||
// This would be handled by parent - for now just show preview
|
||||
console.log('Selected similar document:', doc.id)
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Keyboard Shortcut Hint */}
|
||||
<div className="absolute bottom-4 left-4 text-xs text-slate-400 bg-white/80 backdrop-blur-sm px-3 py-1.5 rounded-lg shadow-sm">
|
||||
Tastenkuerzel: F (Vollbild) | +/- (Zoom) | 0 (Reset) | Esc (Schliessen)
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,243 @@
|
||||
'use client'
|
||||
|
||||
/**
|
||||
* ThemenSuche - Autocomplete search for Abitur themes
|
||||
* Features debounced API calls, suggestion display, and keyboard navigation
|
||||
*/
|
||||
|
||||
import { useState, useEffect, useRef, useCallback } from 'react'
|
||||
import { Search, X, Loader2 } from 'lucide-react'
|
||||
import type { ThemaSuggestion } from '@/lib/education/abitur-archiv-types'
|
||||
import { POPULAR_THEMES } from '@/lib/education/abitur-archiv-types'
|
||||
|
||||
interface ThemenSucheProps {
|
||||
onSearch: (query: string) => void
|
||||
onClear: () => void
|
||||
placeholder?: string
|
||||
}
|
||||
|
||||
export function ThemenSuche({
|
||||
onSearch,
|
||||
onClear,
|
||||
placeholder = 'Thema suchen (z.B. Gedichtanalyse, Eroerterung, Drama...)'
|
||||
}: ThemenSucheProps) {
|
||||
const [query, setQuery] = useState('')
|
||||
const [suggestions, setSuggestions] = useState<ThemaSuggestion[]>([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [showDropdown, setShowDropdown] = useState(false)
|
||||
const [selectedIndex, setSelectedIndex] = useState(-1)
|
||||
|
||||
const inputRef = useRef<HTMLInputElement>(null)
|
||||
const dropdownRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
// Debounced API call for suggestions
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(async () => {
|
||||
if (query.length >= 2) {
|
||||
setLoading(true)
|
||||
try {
|
||||
const res = await fetch(`/api/education/abitur-archiv/suggest?q=${encodeURIComponent(query)}`)
|
||||
const data = await res.json()
|
||||
setSuggestions(data.suggestions || [])
|
||||
setShowDropdown(true)
|
||||
} catch (error) {
|
||||
console.error('Suggest error:', error)
|
||||
// Fallback to popular themes
|
||||
setSuggestions(POPULAR_THEMES.filter(t =>
|
||||
t.label.toLowerCase().includes(query.toLowerCase())
|
||||
))
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
} else if (query.length === 0) {
|
||||
setSuggestions(POPULAR_THEMES)
|
||||
} else {
|
||||
setSuggestions([])
|
||||
}
|
||||
}, 300)
|
||||
|
||||
return () => clearTimeout(timer)
|
||||
}, [query])
|
||||
|
||||
// Close dropdown when clicking outside
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (e: MouseEvent) => {
|
||||
if (
|
||||
dropdownRef.current &&
|
||||
!dropdownRef.current.contains(e.target as Node) &&
|
||||
inputRef.current &&
|
||||
!inputRef.current.contains(e.target as Node)
|
||||
) {
|
||||
setShowDropdown(false)
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('mousedown', handleClickOutside)
|
||||
return () => document.removeEventListener('mousedown', handleClickOutside)
|
||||
}, [])
|
||||
|
||||
const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
|
||||
if (!showDropdown || suggestions.length === 0) return
|
||||
|
||||
switch (e.key) {
|
||||
case 'ArrowDown':
|
||||
e.preventDefault()
|
||||
setSelectedIndex(prev => Math.min(prev + 1, suggestions.length - 1))
|
||||
break
|
||||
case 'ArrowUp':
|
||||
e.preventDefault()
|
||||
setSelectedIndex(prev => Math.max(prev - 1, -1))
|
||||
break
|
||||
case 'Enter':
|
||||
e.preventDefault()
|
||||
if (selectedIndex >= 0) {
|
||||
handleSelectSuggestion(suggestions[selectedIndex])
|
||||
} else if (query.trim()) {
|
||||
handleSearch()
|
||||
}
|
||||
break
|
||||
case 'Escape':
|
||||
setShowDropdown(false)
|
||||
setSelectedIndex(-1)
|
||||
break
|
||||
}
|
||||
}, [showDropdown, suggestions, selectedIndex, query])
|
||||
|
||||
const handleSelectSuggestion = (suggestion: ThemaSuggestion) => {
|
||||
setQuery(suggestion.label)
|
||||
setShowDropdown(false)
|
||||
setSelectedIndex(-1)
|
||||
onSearch(suggestion.label)
|
||||
}
|
||||
|
||||
const handleSearch = () => {
|
||||
if (query.trim()) {
|
||||
onSearch(query.trim())
|
||||
setShowDropdown(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleClear = () => {
|
||||
setQuery('')
|
||||
setSuggestions(POPULAR_THEMES)
|
||||
setShowDropdown(false)
|
||||
setSelectedIndex(-1)
|
||||
onClear()
|
||||
inputRef.current?.focus()
|
||||
}
|
||||
|
||||
const handleFocus = () => {
|
||||
if (query.length === 0) {
|
||||
setSuggestions(POPULAR_THEMES)
|
||||
}
|
||||
setShowDropdown(true)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
{/* Search Input */}
|
||||
<div className="relative flex items-center">
|
||||
<div className="absolute left-4 text-slate-400">
|
||||
{loading ? (
|
||||
<Loader2 className="w-5 h-5 animate-spin" />
|
||||
) : (
|
||||
<Search className="w-5 h-5" />
|
||||
)}
|
||||
</div>
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
value={query}
|
||||
onChange={(e) => {
|
||||
setQuery(e.target.value)
|
||||
setSelectedIndex(-1)
|
||||
}}
|
||||
onKeyDown={handleKeyDown}
|
||||
onFocus={handleFocus}
|
||||
placeholder={placeholder}
|
||||
className="w-full pl-12 pr-24 py-3 text-lg border border-slate-300 rounded-xl
|
||||
focus:ring-2 focus:ring-blue-500 focus:border-transparent
|
||||
bg-white shadow-sm"
|
||||
/>
|
||||
<div className="absolute right-2 flex items-center gap-2">
|
||||
{query && (
|
||||
<button
|
||||
onClick={handleClear}
|
||||
className="p-1.5 text-slate-400 hover:text-slate-600 hover:bg-slate-100 rounded-lg"
|
||||
title="Suche loeschen"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={handleSearch}
|
||||
disabled={!query.trim()}
|
||||
className="px-4 py-1.5 bg-blue-600 text-white rounded-lg hover:bg-blue-700
|
||||
disabled:opacity-50 disabled:cursor-not-allowed text-sm font-medium"
|
||||
>
|
||||
Suchen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Suggestions Dropdown */}
|
||||
{showDropdown && suggestions.length > 0 && (
|
||||
<div
|
||||
ref={dropdownRef}
|
||||
className="absolute top-full left-0 right-0 mt-2 bg-white rounded-xl border border-slate-200
|
||||
shadow-lg z-50 max-h-80 overflow-y-auto"
|
||||
>
|
||||
<div className="p-2">
|
||||
{query.length === 0 && (
|
||||
<div className="px-3 py-2 text-xs font-medium text-slate-500 uppercase tracking-wide">
|
||||
Beliebte Themen
|
||||
</div>
|
||||
)}
|
||||
{suggestions.map((suggestion, index) => (
|
||||
<button
|
||||
key={`${suggestion.aufgabentyp}-${suggestion.label}`}
|
||||
onClick={() => handleSelectSuggestion(suggestion)}
|
||||
className={`w-full px-3 py-2.5 text-left rounded-lg flex items-center justify-between
|
||||
transition-colors ${
|
||||
index === selectedIndex
|
||||
? 'bg-blue-50 text-blue-700'
|
||||
: 'hover:bg-slate-50'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<Search className="w-4 h-4 text-slate-400" />
|
||||
<div>
|
||||
<div className="font-medium text-slate-800">{suggestion.label}</div>
|
||||
{suggestion.kategorie && (
|
||||
<div className="text-xs text-slate-500">{suggestion.kategorie}</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<span className="text-sm text-slate-400">
|
||||
{suggestion.count} Dokumente
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Quick Theme Tags */}
|
||||
{!showDropdown && query.length === 0 && (
|
||||
<div className="mt-3 flex flex-wrap gap-2">
|
||||
<span className="text-sm text-slate-500">Vorschlaege:</span>
|
||||
{POPULAR_THEMES.slice(0, 5).map((theme) => (
|
||||
<button
|
||||
key={theme.aufgabentyp}
|
||||
onClick={() => handleSelectSuggestion(theme)}
|
||||
className="px-3 py-1 text-sm bg-slate-100 text-slate-700 rounded-full
|
||||
hover:bg-slate-200 transition-colors"
|
||||
>
|
||||
{theme.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,516 @@
|
||||
'use client'
|
||||
|
||||
/**
|
||||
* Abitur-Archiv - Hauptseite
|
||||
* Zentralabitur-Materialien 2021-2025 mit erweiterter Themensuche
|
||||
*/
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import {
|
||||
FileText, Filter, ChevronLeft, ChevronRight, Eye, Download, Search,
|
||||
X, Loader2, Grid, List, LayoutGrid, BarChart3, Archive
|
||||
} from 'lucide-react'
|
||||
import type { AbiturDokument, AbiturDocsResponse } from '@/lib/education/abitur-docs-types'
|
||||
import {
|
||||
formatFileSize,
|
||||
FAECHER,
|
||||
JAHRE,
|
||||
BUNDESLAENDER,
|
||||
NIVEAUS,
|
||||
TYPEN,
|
||||
} from '@/lib/education/abitur-docs-types'
|
||||
import type { ViewMode, ThemaSuggestion } from '@/lib/education/abitur-archiv-types'
|
||||
import { ThemenSuche } from './components/ThemenSuche'
|
||||
import { DokumentCard } from './components/DokumentCard'
|
||||
import { FullscreenViewer } from './components/FullscreenViewer'
|
||||
|
||||
export default function AbiturArchivPage() {
|
||||
// Documents state
|
||||
const [documents, setDocuments] = useState<AbiturDokument[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
// Pagination
|
||||
const [page, setPage] = useState(1)
|
||||
const [totalPages, setTotalPages] = useState(1)
|
||||
const [total, setTotal] = useState(0)
|
||||
const limit = 20
|
||||
|
||||
// View mode
|
||||
const [viewMode, setViewMode] = useState<ViewMode>('grid')
|
||||
|
||||
// Filters
|
||||
const [filterOpen, setFilterOpen] = useState(false)
|
||||
const [filterFach, setFilterFach] = useState<string>('')
|
||||
const [filterJahr, setFilterJahr] = useState<string>('')
|
||||
const [filterBundesland, setFilterBundesland] = useState<string>('')
|
||||
const [filterNiveau, setFilterNiveau] = useState<string>('')
|
||||
const [filterTyp, setFilterTyp] = useState<string>('')
|
||||
|
||||
// Theme search
|
||||
const [searchQuery, setSearchQuery] = useState<string>('')
|
||||
const [themes, setThemes] = useState<ThemaSuggestion[]>([])
|
||||
|
||||
// Modal
|
||||
const [selectedDocument, setSelectedDocument] = useState<AbiturDokument | null>(null)
|
||||
|
||||
// Stats
|
||||
const [stats, setStats] = useState({ total: 0, indexed: 0, faecher: 0 })
|
||||
|
||||
// Fetch documents
|
||||
const fetchDocuments = useCallback(async () => {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
|
||||
const params = new URLSearchParams()
|
||||
params.set('page', page.toString())
|
||||
params.set('limit', limit.toString())
|
||||
if (filterFach) params.set('fach', filterFach)
|
||||
if (filterJahr) params.set('jahr', filterJahr)
|
||||
if (filterBundesland) params.set('bundesland', filterBundesland)
|
||||
if (filterNiveau) params.set('niveau', filterNiveau)
|
||||
if (filterTyp) params.set('typ', filterTyp)
|
||||
if (searchQuery) params.set('thema', searchQuery)
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/education/abitur-archiv?${params.toString()}`)
|
||||
if (!response.ok) throw new Error('Fehler beim Laden der Dokumente')
|
||||
|
||||
const data = await response.json()
|
||||
setDocuments(data.documents || [])
|
||||
setTotalPages(data.total_pages || 1)
|
||||
setTotal(data.total || 0)
|
||||
setThemes(data.themes || [])
|
||||
|
||||
// Update stats
|
||||
const indexed = (data.documents || []).filter((d: AbiturDokument) => d.status === 'indexed').length
|
||||
const uniqueFaecher = new Set((data.documents || []).map((d: AbiturDokument) => d.fach)).size
|
||||
setStats({ total: data.total || 0, indexed, faecher: uniqueFaecher })
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Unbekannter Fehler')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [page, filterFach, filterJahr, filterBundesland, filterNiveau, filterTyp, searchQuery])
|
||||
|
||||
useEffect(() => {
|
||||
fetchDocuments()
|
||||
}, [fetchDocuments])
|
||||
|
||||
const clearFilters = () => {
|
||||
setFilterFach('')
|
||||
setFilterJahr('')
|
||||
setFilterBundesland('')
|
||||
setFilterNiveau('')
|
||||
setFilterTyp('')
|
||||
setSearchQuery('')
|
||||
setPage(1)
|
||||
}
|
||||
|
||||
const handleSearch = (query: string) => {
|
||||
setSearchQuery(query)
|
||||
setPage(1)
|
||||
}
|
||||
|
||||
const handleClearSearch = () => {
|
||||
setSearchQuery('')
|
||||
setPage(1)
|
||||
}
|
||||
|
||||
const handleDownload = (doc: AbiturDokument) => {
|
||||
const link = window.document.createElement('a')
|
||||
link.href = doc.file_path
|
||||
link.download = doc.dateiname
|
||||
link.click()
|
||||
}
|
||||
|
||||
const handleAddToKlausur = (doc: AbiturDokument) => {
|
||||
// Navigate to klausur-korrektur with document reference
|
||||
const params = new URLSearchParams()
|
||||
params.set('archiv_doc_id', doc.id)
|
||||
params.set('aufgabentyp', doc.typ === 'erwartungshorizont' ? 'vorlage' : 'aufgabe')
|
||||
window.location.href = `/education/klausur-korrektur?${params.toString()}`
|
||||
}
|
||||
|
||||
const hasActiveFilters = filterFach || filterJahr || filterBundesland || filterNiveau || filterTyp || searchQuery
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-slate-50">
|
||||
{/* Header */}
|
||||
<div className="bg-white border-b border-slate-200">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-12 h-12 bg-gradient-to-br from-blue-500 to-purple-600 rounded-xl flex items-center justify-center">
|
||||
<Archive className="w-6 h-6 text-white" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-slate-900">Abitur-Archiv</h1>
|
||||
<p className="text-sm text-slate-500">Zentralabitur-Materialien 2021-2025</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Stats */}
|
||||
<div className="hidden md:flex items-center gap-6">
|
||||
<div className="text-center">
|
||||
<div className="text-2xl font-bold text-slate-800">{stats.total}</div>
|
||||
<div className="text-xs text-slate-500">Dokumente</div>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<div className="text-2xl font-bold text-green-600">{stats.indexed}</div>
|
||||
<div className="text-xs text-slate-500">Indexiert</div>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<div className="text-2xl font-bold text-blue-600">{stats.faecher}</div>
|
||||
<div className="text-xs text-slate-500">Faecher</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Main Content */}
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-6 space-y-6">
|
||||
{/* Theme Search */}
|
||||
<div className="bg-white rounded-xl border border-slate-200 p-6">
|
||||
<ThemenSuche
|
||||
onSearch={handleSearch}
|
||||
onClear={handleClearSearch}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Filter Bar */}
|
||||
<div className="bg-white rounded-xl border border-slate-200 p-4">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
onClick={() => setFilterOpen(!filterOpen)}
|
||||
className={`px-4 py-2 rounded-lg font-medium flex items-center gap-2 transition-colors ${
|
||||
filterOpen || hasActiveFilters
|
||||
? 'bg-purple-100 text-purple-700'
|
||||
: 'bg-slate-100 text-slate-600 hover:bg-slate-200'
|
||||
}`}
|
||||
>
|
||||
<Filter className="w-4 h-4" />
|
||||
Filter
|
||||
{hasActiveFilters && (
|
||||
<span className="bg-purple-600 text-white text-xs px-1.5 py-0.5 rounded-full">
|
||||
{[filterFach, filterJahr, filterBundesland, filterNiveau, filterTyp, searchQuery].filter(Boolean).length}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{hasActiveFilters && (
|
||||
<button
|
||||
onClick={clearFilters}
|
||||
className="text-sm text-slate-500 hover:text-slate-700 flex items-center gap-1"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
Filter zuruecksetzen
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
{/* Results count */}
|
||||
<span className="text-sm text-slate-500">
|
||||
{total} Treffer
|
||||
</span>
|
||||
|
||||
{/* View Mode Toggle */}
|
||||
<div className="flex bg-slate-100 rounded-lg p-1">
|
||||
<button
|
||||
onClick={() => setViewMode('grid')}
|
||||
className={`p-2 rounded-md transition-colors ${
|
||||
viewMode === 'grid' ? 'bg-white shadow-sm text-blue-600' : 'text-slate-500 hover:text-slate-700'
|
||||
}`}
|
||||
title="Raster-Ansicht"
|
||||
>
|
||||
<LayoutGrid className="w-4 h-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setViewMode('list')}
|
||||
className={`p-2 rounded-md transition-colors ${
|
||||
viewMode === 'list' ? 'bg-white shadow-sm text-blue-600' : 'text-slate-500 hover:text-slate-700'
|
||||
}`}
|
||||
title="Listen-Ansicht"
|
||||
>
|
||||
<List className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Filter Dropdowns */}
|
||||
{filterOpen && (
|
||||
<div className="grid grid-cols-2 md:grid-cols-5 gap-3 pt-4 border-t border-slate-200">
|
||||
{/* Fach */}
|
||||
<div>
|
||||
<label className="block text-xs text-slate-500 mb-1">Fach</label>
|
||||
<select
|
||||
value={filterFach}
|
||||
onChange={(e) => { setFilterFach(e.target.value); setPage(1) }}
|
||||
className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm focus:ring-2 focus:ring-blue-500"
|
||||
>
|
||||
<option value="">Alle Faecher</option>
|
||||
{FAECHER.map(f => (
|
||||
<option key={f.id} value={f.id}>{f.label}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Jahr */}
|
||||
<div>
|
||||
<label className="block text-xs text-slate-500 mb-1">Jahr</label>
|
||||
<select
|
||||
value={filterJahr}
|
||||
onChange={(e) => { setFilterJahr(e.target.value); setPage(1) }}
|
||||
className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm focus:ring-2 focus:ring-blue-500"
|
||||
>
|
||||
<option value="">Alle Jahre</option>
|
||||
{JAHRE.map(j => (
|
||||
<option key={j} value={j}>{j}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Bundesland */}
|
||||
<div>
|
||||
<label className="block text-xs text-slate-500 mb-1">Bundesland</label>
|
||||
<select
|
||||
value={filterBundesland}
|
||||
onChange={(e) => { setFilterBundesland(e.target.value); setPage(1) }}
|
||||
className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm focus:ring-2 focus:ring-blue-500"
|
||||
>
|
||||
<option value="">Alle Bundeslaender</option>
|
||||
{BUNDESLAENDER.map(b => (
|
||||
<option key={b.id} value={b.id}>{b.label}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Niveau */}
|
||||
<div>
|
||||
<label className="block text-xs text-slate-500 mb-1">Niveau</label>
|
||||
<select
|
||||
value={filterNiveau}
|
||||
onChange={(e) => { setFilterNiveau(e.target.value); setPage(1) }}
|
||||
className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm focus:ring-2 focus:ring-blue-500"
|
||||
>
|
||||
<option value="">Alle Niveaus</option>
|
||||
{NIVEAUS.map(n => (
|
||||
<option key={n.id} value={n.id}>{n.label}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Typ */}
|
||||
<div>
|
||||
<label className="block text-xs text-slate-500 mb-1">Typ</label>
|
||||
<select
|
||||
value={filterTyp}
|
||||
onChange={(e) => { setFilterTyp(e.target.value); setPage(1) }}
|
||||
className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm focus:ring-2 focus:ring-blue-500"
|
||||
>
|
||||
<option value="">Alle Typen</option>
|
||||
{TYPEN.map(t => (
|
||||
<option key={t.id} value={t.id}>{t.label}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Active Search Query Display */}
|
||||
{searchQuery && (
|
||||
<div className="flex items-center gap-2 px-4 py-2 bg-blue-50 border border-blue-200 rounded-lg">
|
||||
<Search className="w-4 h-4 text-blue-600" />
|
||||
<span className="text-sm text-blue-700">
|
||||
Suche: <strong>{searchQuery}</strong>
|
||||
</span>
|
||||
<button
|
||||
onClick={handleClearSearch}
|
||||
className="ml-auto text-blue-600 hover:text-blue-800"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Document Display */}
|
||||
<div className="bg-white rounded-xl border border-slate-200 overflow-hidden">
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center py-16">
|
||||
<Loader2 className="w-8 h-8 text-blue-600 animate-spin" />
|
||||
</div>
|
||||
) : error ? (
|
||||
<div className="text-center py-16 text-red-600">
|
||||
<p>{error}</p>
|
||||
<button
|
||||
onClick={() => fetchDocuments()}
|
||||
className="mt-2 text-sm text-blue-600 hover:underline"
|
||||
>
|
||||
Erneut versuchen
|
||||
</button>
|
||||
</div>
|
||||
) : documents.length === 0 ? (
|
||||
<div className="text-center py-16 text-slate-500">
|
||||
<FileText className="w-12 h-12 mx-auto mb-3 opacity-50" />
|
||||
<p>Keine Dokumente gefunden</p>
|
||||
{hasActiveFilters && (
|
||||
<button
|
||||
onClick={clearFilters}
|
||||
className="mt-2 text-sm text-blue-600 hover:underline"
|
||||
>
|
||||
Filter zuruecksetzen
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
) : viewMode === 'grid' ? (
|
||||
/* Grid View */
|
||||
<div className="p-4">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
|
||||
{documents.map((doc) => (
|
||||
<DokumentCard
|
||||
key={doc.id}
|
||||
document={doc}
|
||||
onPreview={setSelectedDocument}
|
||||
onDownload={handleDownload}
|
||||
onAddToKlausur={handleAddToKlausur}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
/* List View */
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-slate-50 border-b border-slate-200">
|
||||
<tr>
|
||||
<th className="text-left px-4 py-3 font-medium text-slate-600">Dokument</th>
|
||||
<th className="text-left px-4 py-3 font-medium text-slate-600">Fach</th>
|
||||
<th className="text-center px-4 py-3 font-medium text-slate-600">Jahr</th>
|
||||
<th className="text-center px-4 py-3 font-medium text-slate-600">Niveau</th>
|
||||
<th className="text-center px-4 py-3 font-medium text-slate-600">Typ</th>
|
||||
<th className="text-right px-4 py-3 font-medium text-slate-600">Groesse</th>
|
||||
<th className="text-center px-4 py-3 font-medium text-slate-600">Status</th>
|
||||
<th className="text-center px-4 py-3 font-medium text-slate-600">Aktion</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{documents.map((doc) => {
|
||||
const fachLabel = FAECHER.find(f => f.id === doc.fach)?.label || doc.fach
|
||||
return (
|
||||
<tr
|
||||
key={doc.id}
|
||||
className="border-b border-slate-100 hover:bg-slate-50 cursor-pointer"
|
||||
onClick={() => setSelectedDocument(doc)}
|
||||
>
|
||||
<td className="px-4 py-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<FileText className="w-4 h-4 text-red-500" />
|
||||
<span className="font-medium text-slate-900 truncate max-w-[200px]" title={doc.dateiname}>
|
||||
{doc.dateiname}
|
||||
</span>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<span className="capitalize">{fachLabel}</span>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-center">{doc.jahr}</td>
|
||||
<td className="px-4 py-3 text-center">
|
||||
<span className={`px-2 py-0.5 rounded-full text-xs ${
|
||||
doc.niveau === 'eA'
|
||||
? 'bg-blue-100 text-blue-700'
|
||||
: 'bg-slate-100 text-slate-700'
|
||||
}`}>
|
||||
{doc.niveau}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-center">
|
||||
<span className={`px-2 py-0.5 rounded-full text-xs ${
|
||||
doc.typ === 'erwartungshorizont'
|
||||
? 'bg-orange-100 text-orange-700'
|
||||
: 'bg-purple-100 text-purple-700'
|
||||
}`}>
|
||||
{doc.typ === 'erwartungshorizont' ? 'EWH' : 'Aufgabe'}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-right text-slate-500">
|
||||
{formatFileSize(doc.file_size)}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-center">
|
||||
<span className={`px-2 py-0.5 rounded-full text-xs ${
|
||||
doc.status === 'indexed'
|
||||
? 'bg-green-100 text-green-700'
|
||||
: doc.status === 'error'
|
||||
? 'bg-red-100 text-red-700'
|
||||
: 'bg-yellow-100 text-yellow-700'
|
||||
}`}>
|
||||
{doc.status === 'indexed' ? 'Indexiert' : doc.status === 'error' ? 'Fehler' : 'Ausstehend'}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-center">
|
||||
<div className="flex items-center justify-center gap-1" onClick={(e) => e.stopPropagation()}>
|
||||
<button
|
||||
onClick={() => setSelectedDocument(doc)}
|
||||
className="p-1.5 text-blue-600 hover:bg-blue-50 rounded"
|
||||
title="Vorschau"
|
||||
>
|
||||
<Eye className="w-4 h-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleDownload(doc)}
|
||||
className="p-1.5 text-slate-600 hover:bg-slate-100 rounded"
|
||||
title="Download"
|
||||
>
|
||||
<Download className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
|
||||
{/* Pagination */}
|
||||
{documents.length > 0 && (
|
||||
<div className="flex items-center justify-between px-4 py-3 border-t border-slate-200 bg-slate-50">
|
||||
<div className="text-sm text-slate-500">
|
||||
Zeige {(page - 1) * limit + 1}-{Math.min(page * limit, total)} von {total} Dokumenten
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => setPage(p => Math.max(1, p - 1))}
|
||||
disabled={page === 1}
|
||||
className="p-2 rounded-lg hover:bg-slate-200 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
<ChevronLeft className="w-4 h-4" />
|
||||
</button>
|
||||
<span className="text-sm text-slate-600">
|
||||
Seite {page} von {totalPages}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => setPage(p => Math.min(totalPages, p + 1))}
|
||||
disabled={page === totalPages}
|
||||
className="p-2 rounded-lg hover:bg-slate-200 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
<ChevronRight className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Fullscreen Viewer Modal */}
|
||||
<FullscreenViewer
|
||||
document={selectedDocument}
|
||||
onClose={() => setSelectedDocument(null)}
|
||||
onAddToKlausur={handleAddToKlausur}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,319 @@
|
||||
'use client'
|
||||
|
||||
/**
|
||||
* Education Search Page
|
||||
* Bildungsquellen und Crawler-Verwaltung
|
||||
*/
|
||||
|
||||
import { useState, useCallback } from 'react'
|
||||
import { PagePurpose } from '@/components/common/PagePurpose'
|
||||
import { Search, Database, RefreshCw, ExternalLink, FileText, BookOpen, FolderOpen } from 'lucide-react'
|
||||
import { DokumenteTab } from '@/components/education/DokumenteTab'
|
||||
|
||||
interface DataSource {
|
||||
id: string
|
||||
name: string
|
||||
type: 'api' | 'crawler' | 'manual'
|
||||
status: 'active' | 'inactive' | 'error'
|
||||
lastUpdate?: string
|
||||
documentCount: number
|
||||
url?: string
|
||||
}
|
||||
|
||||
const DATA_SOURCES: DataSource[] = [
|
||||
{
|
||||
id: 'nibis',
|
||||
name: 'NiBiS (Niedersachsen)',
|
||||
type: 'crawler',
|
||||
status: 'active',
|
||||
lastUpdate: '2026-01-20',
|
||||
documentCount: 1250,
|
||||
url: 'https://nibis.de',
|
||||
},
|
||||
{
|
||||
id: 'kmk',
|
||||
name: 'KMK Beschluesse',
|
||||
type: 'crawler',
|
||||
status: 'active',
|
||||
lastUpdate: '2026-01-10',
|
||||
documentCount: 450,
|
||||
url: 'https://kmk.org',
|
||||
},
|
||||
]
|
||||
|
||||
export default function EduSearchPage() {
|
||||
const [searchQuery, setSearchQuery] = useState('')
|
||||
const [activeTab, setActiveTab] = useState<'search' | 'documents' | 'sources' | 'crawler'>('search')
|
||||
const [documentCount, setDocumentCount] = useState<number>(0)
|
||||
|
||||
const handleDocumentCountChange = useCallback((count: number) => {
|
||||
setDocumentCount(count)
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<PagePurpose
|
||||
title="Education Search"
|
||||
purpose="Durchsuchen Sie Bildungsquellen und verwalten Sie Crawler fuer Lehrplaene, Erlasse und Schulinformationen. Zentraler Zugang zu bildungsrelevanten Dokumenten."
|
||||
audience={['Content Manager', 'Entwickler', 'Bildungs-Admins']}
|
||||
architecture={{
|
||||
services: ['edu-search-service (Go)', 'OpenSearch'],
|
||||
databases: ['OpenSearch (bp_documents_v1)', 'PostgreSQL'],
|
||||
}}
|
||||
collapsible={true}
|
||||
defaultCollapsed={true}
|
||||
/>
|
||||
|
||||
{/* Stats */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
|
||||
<div className="bg-white rounded-xl border border-slate-200 p-4">
|
||||
<div className="text-3xl font-bold text-blue-600">
|
||||
{DATA_SOURCES.reduce((sum, s) => sum + s.documentCount, 0).toLocaleString()}
|
||||
</div>
|
||||
<div className="text-sm text-slate-500">Dokumente gesamt</div>
|
||||
</div>
|
||||
<div className="bg-white rounded-xl border border-slate-200 p-4">
|
||||
<div className="text-3xl font-bold text-green-600">{DATA_SOURCES.length}</div>
|
||||
<div className="text-sm text-slate-500">Datenquellen</div>
|
||||
</div>
|
||||
<div className="bg-white rounded-xl border border-slate-200 p-4">
|
||||
<div className="text-3xl font-bold text-purple-600">
|
||||
{DATA_SOURCES.filter(s => s.type === 'crawler').length}
|
||||
</div>
|
||||
<div className="text-sm text-slate-500">Aktive Crawler</div>
|
||||
</div>
|
||||
<div className="bg-white rounded-xl border border-slate-200 p-4">
|
||||
<div className="text-3xl font-bold text-orange-600">16</div>
|
||||
<div className="text-sm text-slate-500">Bundeslaender</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tabs */}
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => setActiveTab('search')}
|
||||
className={`px-4 py-2 rounded-lg font-medium transition-colors ${
|
||||
activeTab === 'search'
|
||||
? 'bg-blue-600 text-white'
|
||||
: 'bg-slate-100 text-slate-600 hover:bg-slate-200'
|
||||
}`}
|
||||
>
|
||||
<Search className="w-4 h-4 inline mr-2" />
|
||||
Suche
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab('documents')}
|
||||
className={`px-4 py-2 rounded-lg font-medium transition-colors ${
|
||||
activeTab === 'documents'
|
||||
? 'bg-blue-600 text-white'
|
||||
: 'bg-slate-100 text-slate-600 hover:bg-slate-200'
|
||||
}`}
|
||||
>
|
||||
<FolderOpen className="w-4 h-4 inline mr-2" />
|
||||
Dokumente
|
||||
{documentCount > 0 && (
|
||||
<span className="ml-2 px-1.5 py-0.5 bg-white/20 rounded text-xs">
|
||||
{documentCount}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab('sources')}
|
||||
className={`px-4 py-2 rounded-lg font-medium transition-colors ${
|
||||
activeTab === 'sources'
|
||||
? 'bg-blue-600 text-white'
|
||||
: 'bg-slate-100 text-slate-600 hover:bg-slate-200'
|
||||
}`}
|
||||
>
|
||||
<Database className="w-4 h-4 inline mr-2" />
|
||||
Datenquellen
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab('crawler')}
|
||||
className={`px-4 py-2 rounded-lg font-medium transition-colors ${
|
||||
activeTab === 'crawler'
|
||||
? 'bg-blue-600 text-white'
|
||||
: 'bg-slate-100 text-slate-600 hover:bg-slate-200'
|
||||
}`}
|
||||
>
|
||||
<RefreshCw className="w-4 h-4 inline mr-2" />
|
||||
Crawler
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Search Tab */}
|
||||
{activeTab === 'search' && (
|
||||
<div className="bg-white rounded-xl border border-slate-200 p-6">
|
||||
<div className="flex gap-4 mb-6">
|
||||
<input
|
||||
type="text"
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
placeholder="Suche nach Lehrplaenen, Erlassen, Curricula..."
|
||||
className="flex-1 px-4 py-3 border border-slate-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 text-lg"
|
||||
/>
|
||||
<button className="px-8 py-3 bg-blue-600 text-white rounded-lg hover:bg-blue-700 font-medium">
|
||||
Suchen
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-2 mb-6">
|
||||
<span className="text-sm text-slate-500 mr-2">Schnellfilter:</span>
|
||||
<button className="px-3 py-1 bg-slate-100 text-slate-700 rounded-full text-sm hover:bg-slate-200">
|
||||
Lehrplaene
|
||||
</button>
|
||||
<button className="px-3 py-1 bg-slate-100 text-slate-700 rounded-full text-sm hover:bg-slate-200">
|
||||
Erlasse
|
||||
</button>
|
||||
<button className="px-3 py-1 bg-slate-100 text-slate-700 rounded-full text-sm hover:bg-slate-200">
|
||||
Kerncurricula
|
||||
</button>
|
||||
<button className="px-3 py-1 bg-slate-100 text-slate-700 rounded-full text-sm hover:bg-slate-200">
|
||||
Abitur
|
||||
</button>
|
||||
<button className="px-3 py-1 bg-slate-100 text-slate-700 rounded-full text-sm hover:bg-slate-200">
|
||||
Niedersachsen
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="text-center py-12 text-slate-500">
|
||||
<BookOpen className="w-16 h-16 mx-auto mb-4 opacity-50" />
|
||||
<p>Geben Sie einen Suchbegriff ein, um Bildungsdokumente zu durchsuchen</p>
|
||||
<p className="text-sm mt-2">Die Suche durchsucht alle angebundenen Datenquellen</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Documents Tab */}
|
||||
{activeTab === 'documents' && (
|
||||
<DokumenteTab onDocumentCountChange={handleDocumentCountChange} />
|
||||
)}
|
||||
|
||||
{/* Sources Tab */}
|
||||
{activeTab === 'sources' && (
|
||||
<div className="bg-white rounded-xl border border-slate-200 overflow-hidden">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-slate-50 border-b border-slate-200">
|
||||
<tr>
|
||||
<th className="text-left px-4 py-3 font-medium text-slate-600">Datenquelle</th>
|
||||
<th className="text-left px-4 py-3 font-medium text-slate-600">Typ</th>
|
||||
<th className="text-center px-4 py-3 font-medium text-slate-600">Status</th>
|
||||
<th className="text-right px-4 py-3 font-medium text-slate-600">Dokumente</th>
|
||||
<th className="text-left px-4 py-3 font-medium text-slate-600">Letztes Update</th>
|
||||
<th className="text-center px-4 py-3 font-medium text-slate-600">Aktion</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{DATA_SOURCES.map((source) => (
|
||||
<tr key={source.id} className="border-b border-slate-100 hover:bg-slate-50">
|
||||
<td className="px-4 py-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Database className="w-4 h-4 text-slate-400" />
|
||||
<div className="font-medium text-slate-900">{source.name}</div>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<span className={`px-2 py-0.5 rounded-full text-xs ${
|
||||
source.type === 'api' ? 'bg-blue-100 text-blue-700' :
|
||||
source.type === 'crawler' ? 'bg-purple-100 text-purple-700' :
|
||||
'bg-slate-100 text-slate-700'
|
||||
}`}>
|
||||
{source.type.toUpperCase()}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-center">
|
||||
<span className={`px-2 py-0.5 rounded-full text-xs ${
|
||||
source.status === 'active' ? 'bg-green-100 text-green-700' :
|
||||
source.status === 'error' ? 'bg-red-100 text-red-700' :
|
||||
'bg-slate-100 text-slate-700'
|
||||
}`}>
|
||||
{source.status === 'active' ? 'Aktiv' : source.status === 'error' ? 'Fehler' : 'Inaktiv'}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-right font-medium">
|
||||
{source.documentCount.toLocaleString()}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-slate-500">
|
||||
{source.lastUpdate || '-'}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-center">
|
||||
{source.url && (
|
||||
<a
|
||||
href={source.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="p-1 text-blue-600 hover:bg-blue-50 rounded inline-block"
|
||||
title="Quelle oeffnen"
|
||||
>
|
||||
<ExternalLink className="w-4 h-4" />
|
||||
</a>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Crawler Tab */}
|
||||
{activeTab === 'crawler' && (
|
||||
<div className="space-y-6">
|
||||
<div className="bg-white rounded-xl border border-slate-200 p-6">
|
||||
<h3 className="text-lg font-semibold text-slate-900 mb-4">Crawler-Verwaltung</h3>
|
||||
<p className="text-sm text-slate-600 mb-4">
|
||||
Hier koennen Sie die Crawler fuer verschiedene Bildungsquellen steuern.
|
||||
Das System crawlt ausschliesslich oeffentliche Bildungsdokumente (Lehrplaene, Erlasse, Curricula). Keine Personendaten.
|
||||
</p>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div className="border border-slate-200 rounded-lg p-4">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<FileText className="w-5 h-5 text-purple-600" />
|
||||
<span className="font-medium">NiBiS Crawler</span>
|
||||
</div>
|
||||
<p className="text-sm text-slate-600 mb-3">
|
||||
Crawlt Lehrplaene und Erlasse aus Niedersachsen
|
||||
</p>
|
||||
<button className="px-4 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700 text-sm">
|
||||
Crawl starten
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="border border-slate-200 rounded-lg p-4">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<FileText className="w-5 h-5 text-blue-600" />
|
||||
<span className="font-medium">KMK Crawler</span>
|
||||
</div>
|
||||
<p className="text-sm text-slate-600 mb-3">
|
||||
Crawlt Beschluesse der Kultusministerkonferenz
|
||||
</p>
|
||||
<button className="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 text-sm">
|
||||
Crawl starten
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Info Box */}
|
||||
<div className="bg-blue-50 border border-blue-200 rounded-xl p-6">
|
||||
<h3 className="font-semibold text-blue-800 flex items-center gap-2">
|
||||
<span>ℹ️</span>
|
||||
Verwandte Module
|
||||
</h3>
|
||||
<div className="mt-3 grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<a href="/education/zeugnisse-crawler" className="block p-3 bg-white rounded-lg hover:shadow-md transition-shadow">
|
||||
<div className="font-medium text-slate-900">Zeugnisse-Crawler</div>
|
||||
<div className="text-sm text-slate-500">Zeugnis-Strukturen verwalten</div>
|
||||
</a>
|
||||
<a href="/ai/rag-pipeline" className="block p-3 bg-white rounded-lg hover:shadow-md transition-shadow">
|
||||
<div className="font-medium text-slate-900">RAG Pipeline</div>
|
||||
<div className="text-sm text-slate-500">Bildungsdokumente indexieren</div>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
+1320
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,484 @@
|
||||
'use client'
|
||||
|
||||
/**
|
||||
* Fairness-Dashboard
|
||||
*
|
||||
* Visualizes grading consistency and identifies outliers for review.
|
||||
*/
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import { useParams, useRouter } from 'next/navigation'
|
||||
import Link from 'next/link'
|
||||
|
||||
// Same-origin proxy to avoid CORS issues
|
||||
const API_BASE = '/klausur-api'
|
||||
|
||||
const GRADE_LABELS: Record<number, string> = {
|
||||
15: '1+', 14: '1', 13: '1-', 12: '2+', 11: '2', 10: '2-',
|
||||
9: '3+', 8: '3', 7: '3-', 6: '4+', 5: '4', 4: '4-',
|
||||
3: '5+', 2: '5', 1: '5-', 0: '6'
|
||||
}
|
||||
|
||||
const CRITERION_COLORS: Record<string, string> = {
|
||||
rechtschreibung: '#dc2626',
|
||||
grammatik: '#2563eb',
|
||||
inhalt: '#16a34a',
|
||||
struktur: '#9333ea',
|
||||
stil: '#ea580c',
|
||||
}
|
||||
|
||||
interface FairnessData {
|
||||
klausur_id: string
|
||||
students_count: number
|
||||
graded_count: number
|
||||
statistics: {
|
||||
average_grade: number
|
||||
average_raw_points: number
|
||||
min_grade: number
|
||||
max_grade: number
|
||||
spread: number
|
||||
standard_deviation: number
|
||||
}
|
||||
criteria_breakdown: Record<string, {
|
||||
average: number
|
||||
min: number
|
||||
max: number
|
||||
count: number
|
||||
}>
|
||||
outliers: Array<{
|
||||
student_id: string
|
||||
student_name: string
|
||||
grade_points: number
|
||||
deviation: number
|
||||
direction: 'above' | 'below'
|
||||
}>
|
||||
fairness_score: number
|
||||
warnings: string[]
|
||||
recommendation: string
|
||||
}
|
||||
|
||||
interface Klausur {
|
||||
id: string
|
||||
title: string
|
||||
subject: string
|
||||
students: Array<{
|
||||
id: string
|
||||
student_name: string
|
||||
anonym_id: string
|
||||
grade_points: number
|
||||
criteria_scores: Record<string, { score: number }>
|
||||
}>
|
||||
}
|
||||
|
||||
export default function FairnessDashboardPage() {
|
||||
const params = useParams()
|
||||
const router = useRouter()
|
||||
const klausurId = params.klausurId as string
|
||||
|
||||
const [klausur, setKlausur] = useState<Klausur | null>(null)
|
||||
const [fairnessData, setFairnessData] = useState<FairnessData | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
const fetchData = useCallback(async () => {
|
||||
try {
|
||||
setLoading(true)
|
||||
|
||||
const klausurRes = await fetch(`${API_BASE}/api/v1/klausuren/${klausurId}`)
|
||||
if (klausurRes.ok) {
|
||||
setKlausur(await klausurRes.json())
|
||||
}
|
||||
|
||||
const fairnessRes = await fetch(`${API_BASE}/api/v1/klausuren/${klausurId}/fairness`)
|
||||
if (fairnessRes.ok) {
|
||||
setFairnessData(await fairnessRes.json())
|
||||
} else {
|
||||
const errData = await fairnessRes.json()
|
||||
setError(errData.detail || 'Fehler beim Laden der Fairness-Analyse')
|
||||
}
|
||||
|
||||
setError(null)
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch data:', err)
|
||||
setError('Fehler beim Laden der Daten')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [klausurId])
|
||||
|
||||
useEffect(() => {
|
||||
fetchData()
|
||||
}, [fetchData])
|
||||
|
||||
const getGradeDistribution = () => {
|
||||
if (!klausur?.students) return []
|
||||
|
||||
const distribution: Record<number, number> = {}
|
||||
for (let i = 0; i <= 15; i++) {
|
||||
distribution[i] = 0
|
||||
}
|
||||
|
||||
klausur.students.forEach(s => {
|
||||
if (s.grade_points >= 0 && s.grade_points <= 15) {
|
||||
distribution[s.grade_points]++
|
||||
}
|
||||
})
|
||||
|
||||
return Object.entries(distribution).map(([grade, count]) => ({
|
||||
grade: parseInt(grade),
|
||||
count,
|
||||
label: GRADE_LABELS[parseInt(grade)] || grade
|
||||
}))
|
||||
}
|
||||
|
||||
const gradeDistribution = getGradeDistribution()
|
||||
const maxCount = Math.max(...gradeDistribution.map(d => d.count), 1)
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="min-h-screen bg-slate-50 flex items-center justify-center">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-purple-600"></div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-slate-50">
|
||||
<div className="max-w-7xl mx-auto px-4 py-6">
|
||||
{/* Header */}
|
||||
<div className="bg-white border-b border-slate-200 -mx-4 -mt-6 px-4 py-4 mb-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<Link
|
||||
href={`/education/klausur-korrektur/${klausurId}`}
|
||||
className="text-purple-600 hover:text-purple-800 flex items-center gap-1 text-sm"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
|
||||
</svg>
|
||||
Zurueck zur Klausur
|
||||
</Link>
|
||||
|
||||
<div className="text-sm text-slate-500">
|
||||
{fairnessData?.graded_count || 0} von {fairnessData?.students_count || 0} Arbeiten bewertet
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Page header */}
|
||||
<div className="mb-6">
|
||||
<h1 className="text-2xl font-bold text-slate-800">Fairness-Analyse</h1>
|
||||
<p className="text-sm text-slate-500">{klausur?.title || ''}</p>
|
||||
</div>
|
||||
|
||||
{/* Error display */}
|
||||
{error && (
|
||||
<div className="mb-6 p-4 bg-red-50 border border-red-200 rounded-lg text-red-800">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{fairnessData && (
|
||||
<div className="space-y-6">
|
||||
{/* Top Row: Fairness Score + Statistics */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||
{/* Fairness Score Gauge */}
|
||||
<div className="bg-white rounded-lg border border-slate-200 p-6">
|
||||
<h3 className="text-sm font-medium text-slate-500 mb-4">Fairness-Score</h3>
|
||||
<div className="flex items-center justify-center">
|
||||
<div className="relative w-32 h-32">
|
||||
<svg className="w-32 h-32 transform -rotate-90">
|
||||
<circle
|
||||
cx="64"
|
||||
cy="64"
|
||||
r="56"
|
||||
fill="none"
|
||||
stroke="#e2e8f0"
|
||||
strokeWidth="12"
|
||||
/>
|
||||
<circle
|
||||
cx="64"
|
||||
cy="64"
|
||||
r="56"
|
||||
fill="none"
|
||||
stroke={
|
||||
fairnessData.fairness_score >= 70 ? '#16a34a' :
|
||||
fairnessData.fairness_score >= 40 ? '#eab308' : '#dc2626'
|
||||
}
|
||||
strokeWidth="12"
|
||||
strokeLinecap="round"
|
||||
strokeDasharray={`${(fairnessData.fairness_score / 100) * 352} 352`}
|
||||
/>
|
||||
</svg>
|
||||
<div className="absolute inset-0 flex flex-col items-center justify-center">
|
||||
<span className="text-3xl font-bold">{fairnessData.fairness_score}</span>
|
||||
<span className="text-xs text-slate-500">von 100</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className={`mt-4 text-center text-sm font-medium ${
|
||||
fairnessData.fairness_score >= 70 ? 'text-green-600' :
|
||||
fairnessData.fairness_score >= 40 ? 'text-yellow-600' : 'text-red-600'
|
||||
}`}>
|
||||
{fairnessData.recommendation}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Statistics */}
|
||||
<div className="bg-white rounded-lg border border-slate-200 p-6">
|
||||
<h3 className="text-sm font-medium text-slate-500 mb-4">Statistik</h3>
|
||||
<div className="space-y-3">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-slate-600">Durchschnitt</span>
|
||||
<span className="font-semibold">
|
||||
{fairnessData.statistics.average_grade} P ({GRADE_LABELS[Math.round(fairnessData.statistics.average_grade)]})
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-slate-600">Minimum</span>
|
||||
<span className="font-semibold">
|
||||
{fairnessData.statistics.min_grade} P ({GRADE_LABELS[fairnessData.statistics.min_grade]})
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-slate-600">Maximum</span>
|
||||
<span className="font-semibold">
|
||||
{fairnessData.statistics.max_grade} P ({GRADE_LABELS[fairnessData.statistics.max_grade]})
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-slate-600">Spreizung</span>
|
||||
<span className="font-semibold">{fairnessData.statistics.spread} P</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-slate-600">Standardabweichung</span>
|
||||
<span className="font-semibold">{fairnessData.statistics.standard_deviation}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Warnings */}
|
||||
<div className="bg-white rounded-lg border border-slate-200 p-6">
|
||||
<h3 className="text-sm font-medium text-slate-500 mb-4">Hinweise</h3>
|
||||
{fairnessData.warnings.length > 0 ? (
|
||||
<div className="space-y-2">
|
||||
{fairnessData.warnings.map((warning, i) => (
|
||||
<div key={i} className="flex items-start gap-2 text-sm">
|
||||
<svg className="w-5 h-5 text-amber-500 flex-shrink-0 mt-0.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
|
||||
</svg>
|
||||
<span className="text-slate-700">{warning}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center gap-2 text-green-600">
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
<span className="text-sm">Keine Auffaelligkeiten erkannt</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Grade Distribution Histogram */}
|
||||
<div className="bg-white rounded-lg border border-slate-200 p-6">
|
||||
<h3 className="text-sm font-medium text-slate-500 mb-4">Notenverteilung</h3>
|
||||
<div className="flex items-end gap-1 h-48">
|
||||
{gradeDistribution.map(({ grade, count, label }) => (
|
||||
<div key={grade} className="flex-1 flex flex-col items-center">
|
||||
<div
|
||||
className={`w-full rounded-t transition-all ${
|
||||
count > 0 ? 'bg-purple-500' : 'bg-slate-200'
|
||||
}`}
|
||||
style={{ height: `${(count / maxCount) * 160}px`, minHeight: count > 0 ? '8px' : '2px' }}
|
||||
title={`${count} Arbeiten`}
|
||||
/>
|
||||
<div className="text-xs text-slate-500 mt-1 transform -rotate-45 origin-top-left w-6 text-center">
|
||||
{label}
|
||||
</div>
|
||||
{count > 0 && (
|
||||
<div className="text-xs font-medium text-slate-700 mt-1">{count}</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex justify-between text-xs text-slate-400 mt-6">
|
||||
<span>6 (0 Punkte)</span>
|
||||
<span>1+ (15 Punkte)</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Criteria Breakdown Heatmap */}
|
||||
<div className="bg-white rounded-lg border border-slate-200 p-6">
|
||||
<h3 className="text-sm font-medium text-slate-500 mb-4">Kriterien-Vergleich</h3>
|
||||
<div className="space-y-3">
|
||||
{Object.entries(fairnessData.criteria_breakdown).map(([criterion, data]) => {
|
||||
const color = CRITERION_COLORS[criterion] || '#6b7280'
|
||||
const range = data.max - data.min
|
||||
|
||||
return (
|
||||
<div key={criterion} className="flex items-center gap-4">
|
||||
<div className="w-32 flex items-center gap-2">
|
||||
<div className="w-3 h-3 rounded-full" style={{ backgroundColor: color }} />
|
||||
<span className="text-sm font-medium capitalize">{criterion}</span>
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<div className="relative h-6 bg-slate-100 rounded-full overflow-hidden">
|
||||
<div
|
||||
className="absolute h-full opacity-30"
|
||||
style={{
|
||||
backgroundColor: color,
|
||||
left: `${data.min}%`,
|
||||
width: `${range}%`
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
className="absolute top-0 bottom-0 w-1 rounded"
|
||||
style={{
|
||||
backgroundColor: color,
|
||||
left: `${data.average}%`
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="w-24 text-right">
|
||||
<span className="text-sm font-semibold">{data.average}%</span>
|
||||
<span className="text-xs text-slate-400 ml-1">avg</span>
|
||||
</div>
|
||||
<div className="w-20 text-right text-xs text-slate-500">
|
||||
{data.min}% - {data.max}%
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Outliers List */}
|
||||
{fairnessData.outliers.length > 0 && (
|
||||
<div className="bg-white rounded-lg border border-slate-200 p-6">
|
||||
<h3 className="text-sm font-medium text-slate-500 mb-4">
|
||||
Ausreisser ({fairnessData.outliers.length})
|
||||
</h3>
|
||||
<div className="space-y-2">
|
||||
{fairnessData.outliers.map((outlier) => (
|
||||
<div
|
||||
key={outlier.student_id}
|
||||
className={`flex items-center justify-between p-3 rounded-lg border ${
|
||||
outlier.direction === 'above'
|
||||
? 'bg-green-50 border-green-200'
|
||||
: 'bg-red-50 border-red-200'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className={`w-8 h-8 rounded-full flex items-center justify-center text-white font-bold ${
|
||||
outlier.direction === 'above' ? 'bg-green-500' : 'bg-red-500'
|
||||
}`}>
|
||||
{outlier.direction === 'above' ? '↑' : '↓'}
|
||||
</div>
|
||||
<div>
|
||||
<div className="font-medium">{outlier.student_name}</div>
|
||||
<div className="text-sm text-slate-500">
|
||||
{outlier.grade_points} Punkte ({GRADE_LABELS[outlier.grade_points]}) -
|
||||
Abweichung: {outlier.deviation} Punkte {outlier.direction === 'above' ? 'ueber' : 'unter'} Durchschnitt
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<Link
|
||||
href={`/education/klausur-korrektur/${klausurId}/${outlier.student_id}`}
|
||||
className="px-4 py-2 bg-white border border-slate-300 rounded-lg text-sm hover:bg-slate-50 flex items-center gap-2"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z" />
|
||||
</svg>
|
||||
Pruefen
|
||||
</Link>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* All Students Table */}
|
||||
<div className="bg-white rounded-lg border border-slate-200 p-6">
|
||||
<h3 className="text-sm font-medium text-slate-500 mb-4">
|
||||
Alle Arbeiten ({klausur?.students.length || 0})
|
||||
</h3>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-slate-200">
|
||||
<th className="text-left py-2 px-3 font-medium text-slate-600">Student</th>
|
||||
<th className="text-center py-2 px-3 font-medium text-slate-600">Note</th>
|
||||
<th className="text-center py-2 px-3 font-medium text-slate-600">RS</th>
|
||||
<th className="text-center py-2 px-3 font-medium text-slate-600">Gram</th>
|
||||
<th className="text-center py-2 px-3 font-medium text-slate-600">Inhalt</th>
|
||||
<th className="text-center py-2 px-3 font-medium text-slate-600">Struktur</th>
|
||||
<th className="text-center py-2 px-3 font-medium text-slate-600">Stil</th>
|
||||
<th className="text-right py-2 px-3 font-medium text-slate-600">Aktion</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{klausur?.students
|
||||
.sort((a, b) => b.grade_points - a.grade_points)
|
||||
.map((student) => {
|
||||
const isOutlier = fairnessData.outliers.some(o => o.student_id === student.id)
|
||||
const outlierInfo = fairnessData.outliers.find(o => o.student_id === student.id)
|
||||
|
||||
return (
|
||||
<tr
|
||||
key={student.id}
|
||||
className={`border-b border-slate-100 ${
|
||||
isOutlier
|
||||
? outlierInfo?.direction === 'above'
|
||||
? 'bg-green-50'
|
||||
: 'bg-red-50'
|
||||
: ''
|
||||
}`}
|
||||
>
|
||||
<td className="py-2 px-3">
|
||||
<div className="font-medium">{student.anonym_id}</div>
|
||||
</td>
|
||||
<td className="py-2 px-3 text-center">
|
||||
<span className="font-bold">
|
||||
{student.grade_points} ({GRADE_LABELS[student.grade_points] || '-'})
|
||||
</span>
|
||||
</td>
|
||||
<td className="py-2 px-3 text-center">
|
||||
{student.criteria_scores?.rechtschreibung?.score ?? '-'}%
|
||||
</td>
|
||||
<td className="py-2 px-3 text-center">
|
||||
{student.criteria_scores?.grammatik?.score ?? '-'}%
|
||||
</td>
|
||||
<td className="py-2 px-3 text-center">
|
||||
{student.criteria_scores?.inhalt?.score ?? '-'}%
|
||||
</td>
|
||||
<td className="py-2 px-3 text-center">
|
||||
{student.criteria_scores?.struktur?.score ?? '-'}%
|
||||
</td>
|
||||
<td className="py-2 px-3 text-center">
|
||||
{student.criteria_scores?.stil?.score ?? '-'}%
|
||||
</td>
|
||||
<td className="py-2 px-3 text-right">
|
||||
<Link
|
||||
href={`/education/klausur-korrektur/${klausurId}/${student.id}`}
|
||||
className="text-purple-600 hover:text-purple-800 text-sm"
|
||||
>
|
||||
Bearbeiten
|
||||
</Link>
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,489 @@
|
||||
'use client'
|
||||
|
||||
/**
|
||||
* Klausur Detail Page - Student List
|
||||
*
|
||||
* Shows all student works for a specific Klausur with upload capability.
|
||||
* Allows navigation to individual correction workspaces.
|
||||
*/
|
||||
|
||||
import { useState, useEffect, useCallback, useRef } from 'react'
|
||||
import { useParams, useRouter } from 'next/navigation'
|
||||
import Link from 'next/link'
|
||||
import type { Klausur, StudentWork } from '../types'
|
||||
|
||||
// Same-origin proxy to avoid CORS issues
|
||||
const API_BASE = '/klausur-api'
|
||||
|
||||
const statusConfig: Record<string, { color: string; label: string; bg: string }> = {
|
||||
UPLOADED: { color: 'text-gray-600', label: 'Hochgeladen', bg: 'bg-gray-100' },
|
||||
OCR_PROCESSING: { color: 'text-yellow-600', label: 'OCR laeuft', bg: 'bg-yellow-100' },
|
||||
OCR_COMPLETE: { color: 'text-blue-600', label: 'OCR fertig', bg: 'bg-blue-100' },
|
||||
ANALYZING: { color: 'text-purple-600', label: 'Analyse', bg: 'bg-purple-100' },
|
||||
FIRST_EXAMINER: { color: 'text-orange-600', label: 'Erstkorrektur', bg: 'bg-orange-100' },
|
||||
SECOND_EXAMINER: { color: 'text-cyan-600', label: 'Zweitkorrektur', bg: 'bg-cyan-100' },
|
||||
COMPLETED: { color: 'text-green-600', label: 'Fertig', bg: 'bg-green-100' },
|
||||
ERROR: { color: 'text-red-600', label: 'Fehler', bg: 'bg-red-100' },
|
||||
}
|
||||
|
||||
export default function KlausurDetailPage() {
|
||||
const params = useParams()
|
||||
const router = useRouter()
|
||||
const klausurId = params.klausurId as string
|
||||
|
||||
const [klausur, setKlausur] = useState<Klausur | null>(null)
|
||||
const [students, setStudents] = useState<StudentWork[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [uploading, setUploading] = useState(false)
|
||||
const [uploadProgress, setUploadProgress] = useState(0)
|
||||
const [exporting, setExporting] = useState(false)
|
||||
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
const fetchKlausur = useCallback(async () => {
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/api/v1/klausuren/${klausurId}`)
|
||||
if (res.ok) {
|
||||
const data = await res.json()
|
||||
setKlausur(data)
|
||||
} else if (res.status === 404) {
|
||||
setError('Klausur nicht gefunden')
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch klausur:', err)
|
||||
setError('Verbindung fehlgeschlagen')
|
||||
}
|
||||
}, [klausurId])
|
||||
|
||||
const fetchStudents = useCallback(async () => {
|
||||
try {
|
||||
setLoading(true)
|
||||
const res = await fetch(`${API_BASE}/api/v1/klausuren/${klausurId}/students`)
|
||||
if (res.ok) {
|
||||
const data = await res.json()
|
||||
setStudents(Array.isArray(data) ? data : data.students || [])
|
||||
setError(null)
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch students:', err)
|
||||
setError('Fehler beim Laden der Arbeiten')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [klausurId])
|
||||
|
||||
useEffect(() => {
|
||||
fetchKlausur()
|
||||
fetchStudents()
|
||||
}, [fetchKlausur, fetchStudents])
|
||||
|
||||
const exportOverviewPDF = async () => {
|
||||
try {
|
||||
setExporting(true)
|
||||
const res = await fetch(`${API_BASE}/api/v1/klausuren/${klausurId}/export/overview`)
|
||||
if (res.ok) {
|
||||
const blob = await res.blob()
|
||||
const url = window.URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = `Notenuebersicht_${klausur?.title?.replace(/\s+/g, '_') || 'Klausur'}_${new Date().toISOString().split('T')[0]}.pdf`
|
||||
document.body.appendChild(a)
|
||||
a.click()
|
||||
document.body.removeChild(a)
|
||||
window.URL.revokeObjectURL(url)
|
||||
} else {
|
||||
setError('Fehler beim PDF-Export')
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to export overview PDF:', err)
|
||||
setError('Fehler beim PDF-Export')
|
||||
} finally {
|
||||
setExporting(false)
|
||||
}
|
||||
}
|
||||
|
||||
const exportAllGutachtenPDF = async () => {
|
||||
try {
|
||||
setExporting(true)
|
||||
const res = await fetch(`${API_BASE}/api/v1/klausuren/${klausurId}/export/all-gutachten`)
|
||||
if (res.ok) {
|
||||
const blob = await res.blob()
|
||||
const url = window.URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = `Alle_Gutachten_${klausur?.title?.replace(/\s+/g, '_') || 'Klausur'}_${new Date().toISOString().split('T')[0]}.pdf`
|
||||
document.body.appendChild(a)
|
||||
a.click()
|
||||
document.body.removeChild(a)
|
||||
window.URL.revokeObjectURL(url)
|
||||
} else {
|
||||
setError('Fehler beim PDF-Export')
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to export all gutachten PDF:', err)
|
||||
setError('Fehler beim PDF-Export')
|
||||
} finally {
|
||||
setExporting(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleFileUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const files = e.target.files
|
||||
if (!files || files.length === 0) return
|
||||
|
||||
setUploading(true)
|
||||
setUploadProgress(0)
|
||||
setError(null)
|
||||
|
||||
const totalFiles = files.length
|
||||
let uploadedCount = 0
|
||||
|
||||
for (const file of Array.from(files)) {
|
||||
try {
|
||||
const formData = new FormData()
|
||||
formData.append('file', file)
|
||||
|
||||
const res = await fetch(`${API_BASE}/api/v1/klausuren/${klausurId}/students`, {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
})
|
||||
|
||||
if (!res.ok) {
|
||||
const errorData = await res.json()
|
||||
console.error(`Failed to upload ${file.name}:`, errorData)
|
||||
}
|
||||
|
||||
uploadedCount++
|
||||
setUploadProgress(Math.round((uploadedCount / totalFiles) * 100))
|
||||
} catch (err) {
|
||||
console.error(`Failed to upload ${file.name}:`, err)
|
||||
}
|
||||
}
|
||||
|
||||
setUploading(false)
|
||||
setUploadProgress(0)
|
||||
fetchStudents()
|
||||
|
||||
if (fileInputRef.current) {
|
||||
fileInputRef.current.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
const handleDeleteStudent = async (studentId: string) => {
|
||||
if (!confirm('Studentenarbeit wirklich loeschen?')) return
|
||||
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/api/v1/students/${studentId}`, {
|
||||
method: 'DELETE',
|
||||
})
|
||||
|
||||
if (res.ok) {
|
||||
setStudents(prev => prev.filter(s => s.id !== studentId))
|
||||
} else {
|
||||
setError('Fehler beim Loeschen')
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to delete student:', err)
|
||||
setError('Fehler beim Loeschen')
|
||||
}
|
||||
}
|
||||
|
||||
const getGradeDisplay = (student: StudentWork) => {
|
||||
if (student.grade_points === undefined || student.grade_points === null) {
|
||||
return { points: '-', label: '-' }
|
||||
}
|
||||
const labels: Record<number, string> = {
|
||||
15: '1+', 14: '1', 13: '1-', 12: '2+', 11: '2', 10: '2-',
|
||||
9: '3+', 8: '3', 7: '3-', 6: '4+', 5: '4', 4: '4-',
|
||||
3: '5+', 2: '5', 1: '5-', 0: '6'
|
||||
}
|
||||
return {
|
||||
points: student.grade_points.toString(),
|
||||
label: labels[student.grade_points] || '-'
|
||||
}
|
||||
}
|
||||
|
||||
const stats = {
|
||||
total: students.length,
|
||||
completed: students.filter(s => s.status === 'COMPLETED').length,
|
||||
inProgress: students.filter(s => ['FIRST_EXAMINER', 'SECOND_EXAMINER', 'ANALYZING'].includes(s.status)).length,
|
||||
pending: students.filter(s => ['UPLOADED', 'OCR_PROCESSING', 'OCR_COMPLETE'].includes(s.status)).length,
|
||||
avgGrade: students.filter(s => s.grade_points !== undefined && s.grade_points !== null)
|
||||
.reduce((sum, s, _, arr) => sum + (s.grade_points || 0) / arr.length, 0).toFixed(1),
|
||||
}
|
||||
|
||||
if (loading && !klausur) {
|
||||
return (
|
||||
<div className="min-h-screen bg-slate-50 flex items-center justify-center">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-purple-600"></div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-slate-50">
|
||||
<div className="max-w-7xl mx-auto px-4 py-6">
|
||||
{/* Breadcrumb */}
|
||||
<div className="mb-4">
|
||||
<Link
|
||||
href="/education/klausur-korrektur"
|
||||
className="text-purple-600 hover:text-purple-800 flex items-center gap-1 text-sm"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
|
||||
</svg>
|
||||
Zurueck zur Uebersicht
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* Page header */}
|
||||
<div className="mb-6">
|
||||
<h1 className="text-2xl font-bold text-slate-800">{klausur?.title || 'Klausur'}</h1>
|
||||
<p className="text-sm text-slate-500">{klausur?.subject} - {klausur?.year} | {students.length} Arbeiten</p>
|
||||
</div>
|
||||
|
||||
{/* Error display */}
|
||||
{error && (
|
||||
<div className="mb-4 p-4 bg-red-50 border border-red-200 rounded-lg flex items-center gap-3">
|
||||
<svg className="w-5 h-5 text-red-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
<span className="text-red-800">{error}</span>
|
||||
<button onClick={() => setError(null)} className="ml-auto text-red-600 hover:text-red-800">
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Statistics Cards */}
|
||||
<div className="grid grid-cols-2 md:grid-cols-5 gap-4 mb-6">
|
||||
<div className="bg-white rounded-lg border border-slate-200 p-4">
|
||||
<div className="text-2xl font-bold text-slate-800">{stats.total}</div>
|
||||
<div className="text-sm text-slate-500">Gesamt</div>
|
||||
</div>
|
||||
<div className="bg-white rounded-lg border border-slate-200 p-4">
|
||||
<div className="text-2xl font-bold text-green-600">{stats.completed}</div>
|
||||
<div className="text-sm text-slate-500">Fertig</div>
|
||||
</div>
|
||||
<div className="bg-white rounded-lg border border-slate-200 p-4">
|
||||
<div className="text-2xl font-bold text-orange-600">{stats.inProgress}</div>
|
||||
<div className="text-sm text-slate-500">In Arbeit</div>
|
||||
</div>
|
||||
<div className="bg-white rounded-lg border border-slate-200 p-4">
|
||||
<div className="text-2xl font-bold text-gray-600">{stats.pending}</div>
|
||||
<div className="text-sm text-slate-500">Ausstehend</div>
|
||||
</div>
|
||||
<div className="bg-white rounded-lg border border-slate-200 p-4">
|
||||
<div className="text-2xl font-bold text-purple-600">{stats.avgGrade}</div>
|
||||
<div className="text-sm text-slate-500">Durchschnitt Note</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Fairness Analysis Button */}
|
||||
{stats.completed >= 2 && (
|
||||
<div className="mb-6 flex flex-wrap gap-3">
|
||||
<Link
|
||||
href={`/education/klausur-korrektur/${klausurId}/fairness`}
|
||||
className="inline-flex items-center gap-2 px-4 py-3 bg-gradient-to-r from-purple-600 to-indigo-600 text-white rounded-lg hover:from-purple-700 hover:to-indigo-700 transition-all shadow-sm"
|
||||
>
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z" />
|
||||
</svg>
|
||||
Fairness-Analyse oeffnen
|
||||
<span className="text-xs bg-white/20 px-2 py-0.5 rounded-full">
|
||||
{stats.completed} bewertet
|
||||
</span>
|
||||
</Link>
|
||||
|
||||
<button
|
||||
onClick={exportOverviewPDF}
|
||||
disabled={exporting}
|
||||
className="inline-flex items-center gap-2 px-4 py-3 bg-white border border-slate-300 text-slate-700 rounded-lg hover:bg-slate-50 transition-all shadow-sm disabled:opacity-50"
|
||||
>
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 10v6m0 0l-3-3m3 3l3-3m2 8H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
|
||||
</svg>
|
||||
{exporting ? 'Exportiere...' : 'Notenuebersicht PDF'}
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={exportAllGutachtenPDF}
|
||||
disabled={exporting}
|
||||
className="inline-flex items-center gap-2 px-4 py-3 bg-white border border-slate-300 text-slate-700 rounded-lg hover:bg-slate-50 transition-all shadow-sm disabled:opacity-50"
|
||||
>
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
|
||||
</svg>
|
||||
{exporting ? 'Exportiere...' : 'Alle Gutachten PDF'}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Upload Section */}
|
||||
<div className="bg-white rounded-lg border border-slate-200 p-6 mb-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-slate-800">Studentenarbeiten hochladen</h2>
|
||||
<p className="text-sm text-slate-500">PDF oder Bilder (JPG, PNG) der gescannten Arbeiten</p>
|
||||
</div>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
multiple
|
||||
accept=".pdf,.jpg,.jpeg,.png"
|
||||
onChange={handleFileUpload}
|
||||
className="hidden"
|
||||
id="file-upload"
|
||||
/>
|
||||
<label
|
||||
htmlFor="file-upload"
|
||||
className={`px-4 py-2 rounded-lg flex items-center gap-2 cursor-pointer ${
|
||||
uploading
|
||||
? 'bg-gray-100 text-gray-400 cursor-not-allowed'
|
||||
: 'bg-purple-600 text-white hover:bg-purple-700'
|
||||
}`}
|
||||
>
|
||||
{uploading ? (
|
||||
<>
|
||||
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white"></div>
|
||||
{uploadProgress}%
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-8l-4-4m0 0L8 8m4-4v12" />
|
||||
</svg>
|
||||
Dateien hochladen
|
||||
</>
|
||||
)}
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{uploading && (
|
||||
<div className="h-2 bg-slate-100 rounded-full overflow-hidden">
|
||||
<div
|
||||
className="h-full bg-purple-600 rounded-full transition-all"
|
||||
style={{ width: `${uploadProgress}%` }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Students List */}
|
||||
<div className="bg-white rounded-lg border border-slate-200 overflow-hidden">
|
||||
<div className="p-4 border-b border-slate-200">
|
||||
<h2 className="text-lg font-semibold text-slate-800">Studentenarbeiten ({students.length})</h2>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center h-32">
|
||||
<div className="animate-spin rounded-full h-6 w-6 border-b-2 border-purple-600"></div>
|
||||
</div>
|
||||
) : students.length === 0 ? (
|
||||
<div className="p-8 text-center text-slate-500">
|
||||
<svg className="mx-auto h-12 w-12 text-slate-300 mb-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
|
||||
</svg>
|
||||
<p>Noch keine Arbeiten hochgeladen</p>
|
||||
<p className="text-sm">Laden Sie gescannte PDFs oder Bilder hoch</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="divide-y divide-slate-200">
|
||||
{students.map((student, index) => {
|
||||
const grade = getGradeDisplay(student)
|
||||
const status = statusConfig[student.status] || statusConfig.UPLOADED
|
||||
|
||||
return (
|
||||
<div
|
||||
key={student.id}
|
||||
className="p-4 hover:bg-slate-50 flex items-center gap-4"
|
||||
>
|
||||
<div className="w-8 h-8 rounded-full bg-slate-100 flex items-center justify-center text-sm font-medium text-slate-600">
|
||||
{index + 1}
|
||||
</div>
|
||||
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="font-medium text-slate-800 truncate">
|
||||
{student.anonym_id || `Arbeit ${index + 1}`}
|
||||
</div>
|
||||
<div className="text-sm text-slate-500 flex items-center gap-2">
|
||||
<span className={`px-2 py-0.5 rounded-full text-xs ${status.bg} ${status.color}`}>
|
||||
{status.label}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="text-center w-20">
|
||||
<div className="text-lg font-bold text-slate-800">{grade.points}</div>
|
||||
<div className="text-xs text-slate-500">{grade.label}</div>
|
||||
</div>
|
||||
|
||||
<div className="w-24">
|
||||
{student.criteria_scores && Object.keys(student.criteria_scores).length > 0 ? (
|
||||
<div className="flex gap-1">
|
||||
{['rechtschreibung', 'grammatik', 'inhalt', 'struktur', 'stil'].map(criterion => (
|
||||
<div
|
||||
key={criterion}
|
||||
className={`h-2 flex-1 rounded-full ${
|
||||
student.criteria_scores[criterion] !== undefined
|
||||
? 'bg-green-500'
|
||||
: 'bg-slate-200'
|
||||
}`}
|
||||
title={criterion}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-xs text-slate-400">Keine Bewertung</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Link
|
||||
href={`/education/klausur-korrektur/${klausurId}/${student.id}`}
|
||||
className="px-3 py-1.5 bg-purple-600 text-white text-sm rounded-lg hover:bg-purple-700"
|
||||
>
|
||||
Korrigieren
|
||||
</Link>
|
||||
<button
|
||||
onClick={() => handleDeleteStudent(student.id)}
|
||||
className="p-1.5 text-red-600 hover:bg-red-50 rounded-lg"
|
||||
title="Loeschen"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Fairness Check Button */}
|
||||
{students.filter(s => s.status === 'COMPLETED').length >= 3 && (
|
||||
<div className="mt-6 p-4 bg-blue-50 border border-blue-200 rounded-lg">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h3 className="font-semibold text-blue-800">Fairness-Check verfuegbar</h3>
|
||||
<p className="text-sm text-blue-600">
|
||||
Pruefen Sie die Bewertungen auf Konsistenz und Fairness
|
||||
</p>
|
||||
</div>
|
||||
<Link
|
||||
href={`/education/klausur-korrektur/${klausurId}/fairness`}
|
||||
className="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700"
|
||||
>
|
||||
Fairness-Check starten
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,281 @@
|
||||
'use client'
|
||||
|
||||
/**
|
||||
* AnnotationLayer
|
||||
*
|
||||
* SVG overlay component for displaying and creating annotations on documents.
|
||||
* Renders positioned rectangles with color-coding by annotation type.
|
||||
*/
|
||||
|
||||
import { useState, useRef, useCallback } from 'react'
|
||||
import type { Annotation, AnnotationType, AnnotationPosition } from '../types'
|
||||
import { ANNOTATION_COLORS } from '../types'
|
||||
|
||||
interface AnnotationLayerProps {
|
||||
annotations: Annotation[]
|
||||
selectedTool: AnnotationType | null
|
||||
onCreateAnnotation: (position: AnnotationPosition, type: AnnotationType) => void
|
||||
onSelectAnnotation: (annotation: Annotation) => void
|
||||
selectedAnnotationId?: string
|
||||
disabled?: boolean
|
||||
}
|
||||
|
||||
export default function AnnotationLayer({
|
||||
annotations,
|
||||
selectedTool,
|
||||
onCreateAnnotation,
|
||||
onSelectAnnotation,
|
||||
selectedAnnotationId,
|
||||
disabled = false,
|
||||
}: AnnotationLayerProps) {
|
||||
const svgRef = useRef<SVGSVGElement>(null)
|
||||
const [isDrawing, setIsDrawing] = useState(false)
|
||||
const [startPos, setStartPos] = useState<{ x: number; y: number } | null>(null)
|
||||
const [currentRect, setCurrentRect] = useState<AnnotationPosition | null>(null)
|
||||
|
||||
// Convert mouse position to percentage
|
||||
const getPercentPosition = useCallback((e: React.MouseEvent<SVGSVGElement>) => {
|
||||
if (!svgRef.current) return null
|
||||
|
||||
const rect = svgRef.current.getBoundingClientRect()
|
||||
const x = ((e.clientX - rect.left) / rect.width) * 100
|
||||
const y = ((e.clientY - rect.top) / rect.height) * 100
|
||||
|
||||
return { x: Math.max(0, Math.min(100, x)), y: Math.max(0, Math.min(100, y)) }
|
||||
}, [])
|
||||
|
||||
// Handle mouse down - start drawing
|
||||
const handleMouseDown = useCallback(
|
||||
(e: React.MouseEvent<SVGSVGElement>) => {
|
||||
if (disabled || !selectedTool) return
|
||||
|
||||
const pos = getPercentPosition(e)
|
||||
if (!pos) return
|
||||
|
||||
setIsDrawing(true)
|
||||
setStartPos(pos)
|
||||
setCurrentRect({ x: pos.x, y: pos.y, width: 0, height: 0 })
|
||||
},
|
||||
[disabled, selectedTool, getPercentPosition]
|
||||
)
|
||||
|
||||
// Handle mouse move - update rectangle
|
||||
const handleMouseMove = useCallback(
|
||||
(e: React.MouseEvent<SVGSVGElement>) => {
|
||||
if (!isDrawing || !startPos) return
|
||||
|
||||
const pos = getPercentPosition(e)
|
||||
if (!pos) return
|
||||
|
||||
const x = Math.min(startPos.x, pos.x)
|
||||
const y = Math.min(startPos.y, pos.y)
|
||||
const width = Math.abs(pos.x - startPos.x)
|
||||
const height = Math.abs(pos.y - startPos.y)
|
||||
|
||||
setCurrentRect({ x, y, width, height })
|
||||
},
|
||||
[isDrawing, startPos, getPercentPosition]
|
||||
)
|
||||
|
||||
// Handle mouse up - finish drawing
|
||||
const handleMouseUp = useCallback(() => {
|
||||
if (!isDrawing || !currentRect || !selectedTool) {
|
||||
setIsDrawing(false)
|
||||
setStartPos(null)
|
||||
setCurrentRect(null)
|
||||
return
|
||||
}
|
||||
|
||||
// Only create annotation if rectangle is large enough (min 1% x 0.5%)
|
||||
if (currentRect.width > 1 && currentRect.height > 0.5) {
|
||||
onCreateAnnotation(currentRect, selectedTool)
|
||||
}
|
||||
|
||||
setIsDrawing(false)
|
||||
setStartPos(null)
|
||||
setCurrentRect(null)
|
||||
}, [isDrawing, currentRect, selectedTool, onCreateAnnotation])
|
||||
|
||||
// Handle clicking on existing annotation
|
||||
const handleAnnotationClick = useCallback(
|
||||
(e: React.MouseEvent, annotation: Annotation) => {
|
||||
e.stopPropagation()
|
||||
onSelectAnnotation(annotation)
|
||||
},
|
||||
[onSelectAnnotation]
|
||||
)
|
||||
|
||||
return (
|
||||
<svg
|
||||
ref={svgRef}
|
||||
className={`absolute inset-0 w-full h-full ${
|
||||
selectedTool && !disabled ? 'cursor-crosshair' : 'cursor-default'
|
||||
}`}
|
||||
style={{ pointerEvents: disabled ? 'none' : 'auto' }}
|
||||
onMouseDown={handleMouseDown}
|
||||
onMouseMove={handleMouseMove}
|
||||
onMouseUp={handleMouseUp}
|
||||
onMouseLeave={handleMouseUp}
|
||||
>
|
||||
{/* SVG Defs for patterns */}
|
||||
<defs>
|
||||
{/* Wavy pattern for Rechtschreibung errors */}
|
||||
<pattern id="wavyPattern" patternUnits="userSpaceOnUse" width="10" height="4">
|
||||
<path
|
||||
d="M0 2 Q 2.5 0, 5 2 T 10 2"
|
||||
stroke="#dc2626"
|
||||
strokeWidth="1.5"
|
||||
fill="none"
|
||||
/>
|
||||
</pattern>
|
||||
{/* Straight underline pattern for Grammatik errors */}
|
||||
<pattern id="straightPattern" patternUnits="userSpaceOnUse" width="6" height="3">
|
||||
<line x1="0" y1="1.5" x2="6" y2="1.5" stroke="#2563eb" strokeWidth="1.5" />
|
||||
</pattern>
|
||||
</defs>
|
||||
|
||||
{/* Existing annotations */}
|
||||
{annotations.map((annotation) => {
|
||||
const isSelected = annotation.id === selectedAnnotationId
|
||||
const color = ANNOTATION_COLORS[annotation.type] || '#6b7280'
|
||||
const isRS = annotation.type === 'rechtschreibung'
|
||||
const isGram = annotation.type === 'grammatik'
|
||||
|
||||
return (
|
||||
<g key={annotation.id} onClick={(e) => handleAnnotationClick(e, annotation)}>
|
||||
{/* Background rectangle - different styles for RS/Gram */}
|
||||
{isRS || isGram ? (
|
||||
<>
|
||||
{/* Light highlight background */}
|
||||
<rect
|
||||
x={`${annotation.position.x}%`}
|
||||
y={`${annotation.position.y}%`}
|
||||
width={`${annotation.position.width}%`}
|
||||
height={`${annotation.position.height}%`}
|
||||
fill={color}
|
||||
fillOpacity={isSelected ? 0.25 : 0.15}
|
||||
className="cursor-pointer hover:fill-opacity-25 transition-all"
|
||||
/>
|
||||
{/* Underline - wavy for RS, straight for Gram */}
|
||||
<rect
|
||||
x={`${annotation.position.x}%`}
|
||||
y={`${annotation.position.y + annotation.position.height - 0.5}%`}
|
||||
width={`${annotation.position.width}%`}
|
||||
height="0.5%"
|
||||
fill={isRS ? 'url(#wavyPattern)' : color}
|
||||
stroke="none"
|
||||
/>
|
||||
{/* Border when selected */}
|
||||
{isSelected && (
|
||||
<rect
|
||||
x={`${annotation.position.x}%`}
|
||||
y={`${annotation.position.y}%`}
|
||||
width={`${annotation.position.width}%`}
|
||||
height={`${annotation.position.height}%`}
|
||||
fill="none"
|
||||
stroke={color}
|
||||
strokeWidth={2}
|
||||
strokeDasharray="4,2"
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
/* Standard rectangle for other annotation types */
|
||||
<rect
|
||||
x={`${annotation.position.x}%`}
|
||||
y={`${annotation.position.y}%`}
|
||||
width={`${annotation.position.width}%`}
|
||||
height={`${annotation.position.height}%`}
|
||||
fill={color}
|
||||
fillOpacity={0.2}
|
||||
stroke={color}
|
||||
strokeWidth={isSelected ? 3 : 2}
|
||||
strokeDasharray={annotation.severity === 'minor' ? '4,2' : undefined}
|
||||
className="cursor-pointer hover:fill-opacity-30 transition-all"
|
||||
rx="2"
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Type indicator icon (small circle in corner) */}
|
||||
<circle
|
||||
cx={`${annotation.position.x}%`}
|
||||
cy={`${annotation.position.y}%`}
|
||||
r="6"
|
||||
fill={color}
|
||||
stroke="white"
|
||||
strokeWidth="1"
|
||||
/>
|
||||
|
||||
{/* Type letter */}
|
||||
<text
|
||||
x={`${annotation.position.x}%`}
|
||||
y={`${annotation.position.y}%`}
|
||||
textAnchor="middle"
|
||||
dominantBaseline="middle"
|
||||
fill="white"
|
||||
fontSize="8"
|
||||
fontWeight="bold"
|
||||
style={{ pointerEvents: 'none' }}
|
||||
>
|
||||
{annotation.type.charAt(0).toUpperCase()}
|
||||
</text>
|
||||
|
||||
{/* Severity indicator (small dot) */}
|
||||
{annotation.severity === 'critical' && (
|
||||
<circle
|
||||
cx={`${annotation.position.x + annotation.position.width}%`}
|
||||
cy={`${annotation.position.y}%`}
|
||||
r="4"
|
||||
fill="#dc2626"
|
||||
stroke="white"
|
||||
strokeWidth="1"
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Selection indicator */}
|
||||
{isSelected && (
|
||||
<>
|
||||
{/* Corner handles */}
|
||||
{[
|
||||
{ cx: annotation.position.x, cy: annotation.position.y },
|
||||
{ cx: annotation.position.x + annotation.position.width, cy: annotation.position.y },
|
||||
{ cx: annotation.position.x, cy: annotation.position.y + annotation.position.height },
|
||||
{
|
||||
cx: annotation.position.x + annotation.position.width,
|
||||
cy: annotation.position.y + annotation.position.height,
|
||||
},
|
||||
].map((corner, i) => (
|
||||
<circle
|
||||
key={i}
|
||||
cx={`${corner.cx}%`}
|
||||
cy={`${corner.cy}%`}
|
||||
r="4"
|
||||
fill="white"
|
||||
stroke={color}
|
||||
strokeWidth="2"
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</g>
|
||||
)
|
||||
})}
|
||||
|
||||
{/* Currently drawing rectangle */}
|
||||
{currentRect && selectedTool && (
|
||||
<rect
|
||||
x={`${currentRect.x}%`}
|
||||
y={`${currentRect.y}%`}
|
||||
width={`${currentRect.width}%`}
|
||||
height={`${currentRect.height}%`}
|
||||
fill={ANNOTATION_COLORS[selectedTool]}
|
||||
fillOpacity={0.3}
|
||||
stroke={ANNOTATION_COLORS[selectedTool]}
|
||||
strokeWidth={2}
|
||||
strokeDasharray="5,5"
|
||||
rx="2"
|
||||
/>
|
||||
)}
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,267 @@
|
||||
'use client'
|
||||
|
||||
/**
|
||||
* AnnotationPanel
|
||||
*
|
||||
* Panel for viewing, editing, and managing annotations.
|
||||
* Shows a list of all annotations with options to edit text, change severity, or delete.
|
||||
*/
|
||||
|
||||
import { useState } from 'react'
|
||||
import type { Annotation, AnnotationType } from '../types'
|
||||
import { ANNOTATION_COLORS } from '../types'
|
||||
|
||||
interface AnnotationPanelProps {
|
||||
annotations: Annotation[]
|
||||
selectedAnnotation: Annotation | null
|
||||
onSelectAnnotation: (annotation: Annotation | null) => void
|
||||
onUpdateAnnotation: (id: string, updates: Partial<Annotation>) => void
|
||||
onDeleteAnnotation: (id: string) => void
|
||||
}
|
||||
|
||||
const SEVERITY_OPTIONS = [
|
||||
{ value: 'minor', label: 'Leicht', color: '#fbbf24' },
|
||||
{ value: 'major', label: 'Mittel', color: '#f97316' },
|
||||
{ value: 'critical', label: 'Schwer', color: '#dc2626' },
|
||||
] as const
|
||||
|
||||
const TYPE_LABELS: Record<AnnotationType, string> = {
|
||||
rechtschreibung: 'Rechtschreibung',
|
||||
grammatik: 'Grammatik',
|
||||
inhalt: 'Inhalt',
|
||||
struktur: 'Struktur',
|
||||
stil: 'Stil',
|
||||
comment: 'Kommentar',
|
||||
highlight: 'Markierung',
|
||||
}
|
||||
|
||||
export default function AnnotationPanel({
|
||||
annotations,
|
||||
selectedAnnotation,
|
||||
onSelectAnnotation,
|
||||
onUpdateAnnotation,
|
||||
onDeleteAnnotation,
|
||||
}: AnnotationPanelProps) {
|
||||
const [editingId, setEditingId] = useState<string | null>(null)
|
||||
const [editText, setEditText] = useState('')
|
||||
const [editSuggestion, setEditSuggestion] = useState('')
|
||||
|
||||
// Group annotations by type
|
||||
const groupedAnnotations = annotations.reduce(
|
||||
(acc, ann) => {
|
||||
if (!acc[ann.type]) {
|
||||
acc[ann.type] = []
|
||||
}
|
||||
acc[ann.type].push(ann)
|
||||
return acc
|
||||
},
|
||||
{} as Record<AnnotationType, Annotation[]>
|
||||
)
|
||||
|
||||
const handleEdit = (annotation: Annotation) => {
|
||||
setEditingId(annotation.id)
|
||||
setEditText(annotation.text)
|
||||
setEditSuggestion(annotation.suggestion || '')
|
||||
}
|
||||
|
||||
const handleSaveEdit = (id: string) => {
|
||||
onUpdateAnnotation(id, { text: editText, suggestion: editSuggestion || undefined })
|
||||
setEditingId(null)
|
||||
setEditText('')
|
||||
setEditSuggestion('')
|
||||
}
|
||||
|
||||
const handleCancelEdit = () => {
|
||||
setEditingId(null)
|
||||
setEditText('')
|
||||
setEditSuggestion('')
|
||||
}
|
||||
|
||||
if (annotations.length === 0) {
|
||||
return (
|
||||
<div className="p-4 text-center text-slate-500">
|
||||
<svg className="w-12 h-12 mx-auto mb-3 text-slate-300" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={1.5}
|
||||
d="M7 8h10M7 12h4m1 8l-4-4H5a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v8a2 2 0 01-2 2h-3l-4 4z"
|
||||
/>
|
||||
</svg>
|
||||
<p className="text-sm">Keine Annotationen vorhanden</p>
|
||||
<p className="text-xs mt-1">Waehlen Sie ein Werkzeug und markieren Sie Stellen im Dokument</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="h-full overflow-auto">
|
||||
{/* Summary */}
|
||||
<div className="p-3 border-b border-slate-200 bg-slate-50">
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="font-medium text-slate-700">{annotations.length} Annotationen</span>
|
||||
<div className="flex gap-2">
|
||||
{Object.entries(groupedAnnotations).map(([type, anns]) => (
|
||||
<span
|
||||
key={type}
|
||||
className="px-2 py-0.5 text-xs rounded-full text-white"
|
||||
style={{ backgroundColor: ANNOTATION_COLORS[type as AnnotationType] }}
|
||||
>
|
||||
{anns.length}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Annotations list by type */}
|
||||
<div className="divide-y divide-slate-100">
|
||||
{(Object.entries(groupedAnnotations) as [AnnotationType, Annotation[]][]).map(([type, anns]) => (
|
||||
<div key={type}>
|
||||
{/* Type header */}
|
||||
<div
|
||||
className="px-3 py-2 text-xs font-semibold text-white"
|
||||
style={{ backgroundColor: ANNOTATION_COLORS[type] }}
|
||||
>
|
||||
{TYPE_LABELS[type]} ({anns.length})
|
||||
</div>
|
||||
|
||||
{/* Annotations in this type */}
|
||||
{anns.map((annotation) => {
|
||||
const isSelected = selectedAnnotation?.id === annotation.id
|
||||
const isEditing = editingId === annotation.id
|
||||
const severityInfo = SEVERITY_OPTIONS.find((s) => s.value === annotation.severity)
|
||||
|
||||
return (
|
||||
<div
|
||||
key={annotation.id}
|
||||
className={`p-3 cursor-pointer transition-colors ${
|
||||
isSelected ? 'bg-blue-50 border-l-4 border-blue-500' : 'hover:bg-slate-50'
|
||||
}`}
|
||||
onClick={() => onSelectAnnotation(isSelected ? null : annotation)}
|
||||
>
|
||||
{isEditing ? (
|
||||
/* Edit mode */
|
||||
<div className="space-y-2" onClick={(e) => e.stopPropagation()}>
|
||||
<textarea
|
||||
value={editText}
|
||||
onChange={(e) => setEditText(e.target.value)}
|
||||
placeholder="Kommentar..."
|
||||
className="w-full p-2 text-sm border border-slate-300 rounded resize-none focus:ring-2 focus:ring-purple-500"
|
||||
rows={2}
|
||||
autoFocus
|
||||
/>
|
||||
|
||||
{(type === 'rechtschreibung' || type === 'grammatik') && (
|
||||
<input
|
||||
type="text"
|
||||
value={editSuggestion}
|
||||
onChange={(e) => setEditSuggestion(e.target.value)}
|
||||
placeholder="Korrekturvorschlag..."
|
||||
className="w-full p-2 text-sm border border-slate-300 rounded focus:ring-2 focus:ring-purple-500"
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => handleSaveEdit(annotation.id)}
|
||||
className="flex-1 py-1 text-xs bg-purple-600 text-white rounded hover:bg-purple-700"
|
||||
>
|
||||
Speichern
|
||||
</button>
|
||||
<button
|
||||
onClick={handleCancelEdit}
|
||||
className="flex-1 py-1 text-xs bg-slate-200 text-slate-700 rounded hover:bg-slate-300"
|
||||
>
|
||||
Abbrechen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
/* View mode */
|
||||
<>
|
||||
{/* Severity badge */}
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<span
|
||||
className="px-1.5 py-0.5 text-[10px] rounded text-white"
|
||||
style={{ backgroundColor: severityInfo?.color || '#6b7280' }}
|
||||
>
|
||||
{severityInfo?.label || 'Unbekannt'}
|
||||
</span>
|
||||
<span className="text-[10px] text-slate-400">Seite {annotation.page}</span>
|
||||
</div>
|
||||
|
||||
{/* Text */}
|
||||
{annotation.text && <p className="text-sm text-slate-700 mb-1">{annotation.text}</p>}
|
||||
|
||||
{/* Suggestion */}
|
||||
{annotation.suggestion && (
|
||||
<p className="text-xs text-green-700 bg-green-50 px-2 py-1 rounded mb-1">
|
||||
<span className="font-medium">Korrektur:</span> {annotation.suggestion}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Actions (only when selected) */}
|
||||
{isSelected && (
|
||||
<div className="flex gap-2 mt-2 pt-2 border-t border-slate-200">
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
handleEdit(annotation)
|
||||
}}
|
||||
className="flex-1 py-1 text-xs bg-slate-100 text-slate-700 rounded hover:bg-slate-200"
|
||||
>
|
||||
Bearbeiten
|
||||
</button>
|
||||
|
||||
{/* Severity buttons */}
|
||||
<div className="flex gap-1">
|
||||
{SEVERITY_OPTIONS.map((sev) => (
|
||||
<button
|
||||
key={sev.value}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
onUpdateAnnotation(annotation.id, { severity: sev.value })
|
||||
}}
|
||||
className={`w-6 h-6 rounded text-xs text-white font-bold ${
|
||||
annotation.severity === sev.value ? 'ring-2 ring-offset-1 ring-slate-400' : ''
|
||||
}`}
|
||||
style={{ backgroundColor: sev.color }}
|
||||
title={sev.label}
|
||||
>
|
||||
{sev.label[0]}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
if (confirm('Annotation loeschen?')) {
|
||||
onDeleteAnnotation(annotation.id)
|
||||
}
|
||||
}}
|
||||
className="px-2 py-1 text-xs bg-red-100 text-red-700 rounded hover:bg-red-200"
|
||||
>
|
||||
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
+139
@@ -0,0 +1,139 @@
|
||||
'use client'
|
||||
|
||||
/**
|
||||
* AnnotationToolbar
|
||||
*
|
||||
* Toolbar for selecting annotation tools and controlling the document viewer.
|
||||
*/
|
||||
|
||||
import type { AnnotationType } from '../types'
|
||||
import { ANNOTATION_COLORS } from '../types'
|
||||
|
||||
interface AnnotationToolbarProps {
|
||||
selectedTool: AnnotationType | null
|
||||
onSelectTool: (tool: AnnotationType | null) => void
|
||||
zoom: number
|
||||
onZoomChange: (zoom: number) => void
|
||||
annotationCounts: Record<AnnotationType, number>
|
||||
disabled?: boolean
|
||||
}
|
||||
|
||||
const ANNOTATION_TOOLS: { type: AnnotationType; label: string; shortcut: string }[] = [
|
||||
{ type: 'rechtschreibung', label: 'Rechtschreibung', shortcut: 'R' },
|
||||
{ type: 'grammatik', label: 'Grammatik', shortcut: 'G' },
|
||||
{ type: 'inhalt', label: 'Inhalt', shortcut: 'I' },
|
||||
{ type: 'struktur', label: 'Struktur', shortcut: 'S' },
|
||||
{ type: 'stil', label: 'Stil', shortcut: 'T' },
|
||||
{ type: 'comment', label: 'Kommentar', shortcut: 'K' },
|
||||
]
|
||||
|
||||
export default function AnnotationToolbar({
|
||||
selectedTool,
|
||||
onSelectTool,
|
||||
zoom,
|
||||
onZoomChange,
|
||||
annotationCounts,
|
||||
disabled = false,
|
||||
}: AnnotationToolbarProps) {
|
||||
const handleToolClick = (type: AnnotationType) => {
|
||||
if (disabled) return
|
||||
onSelectTool(selectedTool === type ? null : type)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-3 border-b border-slate-200 flex items-center justify-between bg-slate-50">
|
||||
{/* Annotation tools */}
|
||||
<div className="flex items-center gap-1">
|
||||
<span className="text-xs text-slate-500 mr-2">Markieren:</span>
|
||||
{ANNOTATION_TOOLS.map(({ type, label, shortcut }) => {
|
||||
const isSelected = selectedTool === type
|
||||
const count = annotationCounts[type] || 0
|
||||
const color = ANNOTATION_COLORS[type]
|
||||
|
||||
return (
|
||||
<button
|
||||
key={type}
|
||||
onClick={() => handleToolClick(type)}
|
||||
disabled={disabled}
|
||||
className={`
|
||||
relative px-2 py-1.5 text-xs rounded border-2 transition-all
|
||||
${disabled ? 'opacity-50 cursor-not-allowed' : 'hover:opacity-80'}
|
||||
${isSelected ? 'ring-2 ring-offset-1 ring-slate-400' : ''}
|
||||
`}
|
||||
style={{
|
||||
borderColor: color,
|
||||
color: isSelected ? 'white' : color,
|
||||
backgroundColor: isSelected ? color : 'transparent',
|
||||
}}
|
||||
title={`${label} (${shortcut})`}
|
||||
>
|
||||
<span className="font-medium">{shortcut}</span>
|
||||
{count > 0 && (
|
||||
<span
|
||||
className="absolute -top-2 -right-2 w-4 h-4 text-[10px] rounded-full flex items-center justify-center text-white"
|
||||
style={{ backgroundColor: color }}
|
||||
>
|
||||
{count > 99 ? '99+' : count}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
|
||||
{/* Clear selection button */}
|
||||
{selectedTool && (
|
||||
<button
|
||||
onClick={() => onSelectTool(null)}
|
||||
className="ml-2 px-2 py-1 text-xs text-slate-500 hover:text-slate-700 hover:bg-slate-200 rounded"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Mode indicator */}
|
||||
{selectedTool && (
|
||||
<div
|
||||
className="px-3 py-1 text-xs rounded-full text-white"
|
||||
style={{ backgroundColor: ANNOTATION_COLORS[selectedTool] }}
|
||||
>
|
||||
{ANNOTATION_TOOLS.find((t) => t.type === selectedTool)?.label || selectedTool}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Zoom controls */}
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => onZoomChange(Math.max(50, zoom - 10))}
|
||||
disabled={zoom <= 50}
|
||||
className="p-1 rounded hover:bg-slate-200 disabled:opacity-50"
|
||||
title="Verkleinern"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M20 12H4" />
|
||||
</svg>
|
||||
</button>
|
||||
<span className="text-sm w-12 text-center">{zoom}%</span>
|
||||
<button
|
||||
onClick={() => onZoomChange(Math.min(200, zoom + 10))}
|
||||
disabled={zoom >= 200}
|
||||
className="p-1 rounded hover:bg-slate-200 disabled:opacity-50"
|
||||
title="Vergroessern"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => onZoomChange(100)}
|
||||
className="px-2 py-1 text-xs rounded hover:bg-slate-200"
|
||||
title="Zuruecksetzen"
|
||||
>
|
||||
Fit
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
+279
@@ -0,0 +1,279 @@
|
||||
'use client'
|
||||
|
||||
/**
|
||||
* EHSuggestionPanel
|
||||
*
|
||||
* Panel for displaying Erwartungshorizont-based suggestions.
|
||||
* Uses RAG to find relevant passages from the linked EH.
|
||||
*/
|
||||
|
||||
import { useState, useCallback } from 'react'
|
||||
import type { AnnotationType } from '../types'
|
||||
import { ANNOTATION_COLORS } from '../types'
|
||||
|
||||
interface EHSuggestion {
|
||||
id: string
|
||||
eh_id: string
|
||||
eh_title: string
|
||||
text: string
|
||||
score: number
|
||||
criterion: string
|
||||
source_chunk_index: number
|
||||
decrypted: boolean
|
||||
}
|
||||
|
||||
interface EHSuggestionPanelProps {
|
||||
studentId: string
|
||||
klausurId: string
|
||||
hasEH: boolean
|
||||
apiBase: string
|
||||
onInsertSuggestion?: (text: string, criterion: string) => void
|
||||
}
|
||||
|
||||
const CRITERIA = [
|
||||
{ id: 'allgemein', label: 'Alle Kriterien' },
|
||||
{ id: 'inhalt', label: 'Inhalt', color: '#16a34a' },
|
||||
{ id: 'struktur', label: 'Struktur', color: '#9333ea' },
|
||||
{ id: 'stil', label: 'Stil', color: '#ea580c' },
|
||||
]
|
||||
|
||||
export default function EHSuggestionPanel({
|
||||
studentId,
|
||||
klausurId,
|
||||
hasEH,
|
||||
apiBase,
|
||||
onInsertSuggestion,
|
||||
}: EHSuggestionPanelProps) {
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [suggestions, setSuggestions] = useState<EHSuggestion[]>([])
|
||||
const [selectedCriterion, setSelectedCriterion] = useState<string>('allgemein')
|
||||
const [passphrase, setPassphrase] = useState('')
|
||||
const [needsPassphrase, setNeedsPassphrase] = useState(false)
|
||||
const [queryPreview, setQueryPreview] = useState<string | null>(null)
|
||||
|
||||
const fetchSuggestions = useCallback(async () => {
|
||||
try {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
|
||||
const res = await fetch(`${apiBase}/api/v1/students/${studentId}/eh-suggestions`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
criterion: selectedCriterion === 'allgemein' ? null : selectedCriterion,
|
||||
passphrase: passphrase || null,
|
||||
limit: 5,
|
||||
}),
|
||||
})
|
||||
|
||||
if (!res.ok) {
|
||||
const data = await res.json()
|
||||
throw new Error(data.detail || 'Fehler beim Laden der Vorschlaege')
|
||||
}
|
||||
|
||||
const data = await res.json()
|
||||
|
||||
if (data.needs_passphrase) {
|
||||
setNeedsPassphrase(true)
|
||||
setSuggestions([])
|
||||
setError(data.message)
|
||||
} else {
|
||||
setNeedsPassphrase(false)
|
||||
setSuggestions(data.suggestions || [])
|
||||
setQueryPreview(data.query_preview || null)
|
||||
|
||||
if (data.suggestions?.length === 0) {
|
||||
setError(data.message || 'Keine passenden Vorschlaege gefunden')
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch EH suggestions:', err)
|
||||
setError(err instanceof Error ? err.message : 'Unbekannter Fehler')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [apiBase, studentId, selectedCriterion, passphrase])
|
||||
|
||||
const handleInsert = (suggestion: EHSuggestion) => {
|
||||
if (onInsertSuggestion) {
|
||||
onInsertSuggestion(suggestion.text, suggestion.criterion)
|
||||
}
|
||||
}
|
||||
|
||||
if (!hasEH) {
|
||||
return (
|
||||
<div className="p-4 text-center">
|
||||
<div className="text-slate-400 mb-4">
|
||||
<svg className="w-12 h-12 mx-auto mb-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={1.5}
|
||||
d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"
|
||||
/>
|
||||
</svg>
|
||||
<p className="text-sm">Kein Erwartungshorizont verknuepft</p>
|
||||
<p className="text-xs mt-1">Laden Sie einen EH in der RAG-Verwaltung hoch</p>
|
||||
</div>
|
||||
<a
|
||||
href="/ai/rag"
|
||||
className="inline-block px-4 py-2 bg-purple-600 text-white text-sm rounded-lg hover:bg-purple-700"
|
||||
>
|
||||
Zur RAG-Verwaltung
|
||||
</a>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="h-full flex flex-col">
|
||||
{/* Criterion selector */}
|
||||
<div className="p-3 border-b border-slate-200 bg-slate-50">
|
||||
<div className="flex gap-1 flex-wrap">
|
||||
{CRITERIA.map((c) => (
|
||||
<button
|
||||
key={c.id}
|
||||
onClick={() => setSelectedCriterion(c.id)}
|
||||
className={`px-2 py-1 text-xs rounded transition-colors ${
|
||||
selectedCriterion === c.id
|
||||
? 'text-white'
|
||||
: 'bg-slate-200 text-slate-600 hover:bg-slate-300'
|
||||
}`}
|
||||
style={
|
||||
selectedCriterion === c.id
|
||||
? { backgroundColor: c.color || '#6366f1' }
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{c.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Passphrase input (if needed) */}
|
||||
{needsPassphrase && (
|
||||
<div className="p-3 bg-yellow-50 border-b border-yellow-200">
|
||||
<label className="block text-xs font-medium text-yellow-800 mb-1">
|
||||
EH-Passphrase (verschluesselt)
|
||||
</label>
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
type="password"
|
||||
value={passphrase}
|
||||
onChange={(e) => setPassphrase(e.target.value)}
|
||||
placeholder="Passphrase eingeben..."
|
||||
className="flex-1 px-2 py-1 text-sm border border-yellow-300 rounded focus:ring-2 focus:ring-yellow-500"
|
||||
/>
|
||||
<button
|
||||
onClick={fetchSuggestions}
|
||||
disabled={!passphrase}
|
||||
className="px-3 py-1 text-xs bg-yellow-600 text-white rounded hover:bg-yellow-700 disabled:opacity-50"
|
||||
>
|
||||
Laden
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Fetch button */}
|
||||
<div className="p-3 border-b border-slate-200">
|
||||
<button
|
||||
onClick={fetchSuggestions}
|
||||
disabled={loading}
|
||||
className="w-full py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700 disabled:opacity-50 flex items-center justify-center gap-2"
|
||||
>
|
||||
{loading ? (
|
||||
<>
|
||||
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white"></div>
|
||||
Lade Vorschlaege...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"
|
||||
/>
|
||||
</svg>
|
||||
EH-Vorschlaege laden
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Query preview */}
|
||||
{queryPreview && (
|
||||
<div className="px-3 py-2 bg-slate-50 border-b border-slate-200">
|
||||
<div className="text-xs text-slate-500 mb-1">Basierend auf:</div>
|
||||
<div className="text-xs text-slate-700 italic truncate">"{queryPreview}"</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Error message */}
|
||||
{error && !needsPassphrase && (
|
||||
<div className="p-3 bg-red-50 border-b border-red-200">
|
||||
<p className="text-sm text-red-700">{error}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Suggestions list */}
|
||||
<div className="flex-1 overflow-auto">
|
||||
{suggestions.length === 0 && !loading && !error && (
|
||||
<div className="p-4 text-center text-slate-400 text-sm">
|
||||
Klicken Sie auf "EH-Vorschlaege laden" um passende Stellen aus dem Erwartungshorizont zu
|
||||
finden.
|
||||
</div>
|
||||
)}
|
||||
|
||||
{suggestions.map((suggestion, idx) => (
|
||||
<div
|
||||
key={suggestion.id}
|
||||
className="p-3 border-b border-slate-100 hover:bg-slate-50 transition-colors"
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs font-medium text-slate-500">#{idx + 1}</span>
|
||||
<span
|
||||
className="px-1.5 py-0.5 text-[10px] rounded text-white"
|
||||
style={{
|
||||
backgroundColor:
|
||||
ANNOTATION_COLORS[suggestion.criterion as AnnotationType] || '#6366f1',
|
||||
}}
|
||||
>
|
||||
{suggestion.criterion}
|
||||
</span>
|
||||
<span className="text-[10px] text-slate-400">
|
||||
Relevanz: {Math.round(suggestion.score * 100)}%
|
||||
</span>
|
||||
</div>
|
||||
{!suggestion.decrypted && (
|
||||
<span className="text-[10px] text-yellow-600">Verschluesselt</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<p className="text-sm text-slate-700 mb-2 line-clamp-4">{suggestion.text}</p>
|
||||
|
||||
{/* Source */}
|
||||
<div className="flex items-center justify-between text-[10px] text-slate-400">
|
||||
<span>Quelle: {suggestion.eh_title}</span>
|
||||
{onInsertSuggestion && suggestion.decrypted && (
|
||||
<button
|
||||
onClick={() => handleInsert(suggestion)}
|
||||
className="px-2 py-1 bg-purple-100 text-purple-700 rounded hover:bg-purple-200"
|
||||
>
|
||||
Im Gutachten verwenden
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
export { default as AnnotationLayer } from './AnnotationLayer'
|
||||
export { default as AnnotationPanel } from './AnnotationPanel'
|
||||
export { default as AnnotationToolbar } from './AnnotationToolbar'
|
||||
export { default as EHSuggestionPanel } from './EHSuggestionPanel'
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,195 @@
|
||||
// TypeScript Interfaces für Klausur-Korrektur
|
||||
|
||||
export interface Klausur {
|
||||
id: string
|
||||
title: string
|
||||
subject: string
|
||||
year: number
|
||||
semester: string
|
||||
modus: 'abitur' | 'vorabitur'
|
||||
eh_id?: string
|
||||
created_at: string
|
||||
student_count?: number
|
||||
completed_count?: number
|
||||
status?: 'draft' | 'in_progress' | 'completed'
|
||||
}
|
||||
|
||||
export interface StudentWork {
|
||||
id: string
|
||||
klausur_id: string
|
||||
anonym_id: string
|
||||
file_path: string
|
||||
file_type: 'pdf' | 'image'
|
||||
ocr_text: string
|
||||
criteria_scores: CriteriaScores
|
||||
gutachten: string
|
||||
status: StudentStatus
|
||||
raw_points: number
|
||||
grade_points: number
|
||||
grade_label?: string
|
||||
created_at: string
|
||||
examiner_id?: string
|
||||
second_examiner_id?: string
|
||||
second_examiner_grade?: number
|
||||
}
|
||||
|
||||
export type StudentStatus =
|
||||
| 'UPLOADED'
|
||||
| 'OCR_PROCESSING'
|
||||
| 'OCR_COMPLETE'
|
||||
| 'ANALYZING'
|
||||
| 'FIRST_EXAMINER'
|
||||
| 'SECOND_EXAMINER'
|
||||
| 'COMPLETED'
|
||||
| 'ERROR'
|
||||
|
||||
export interface CriteriaScores {
|
||||
rechtschreibung?: number
|
||||
grammatik?: number
|
||||
inhalt?: number
|
||||
struktur?: number
|
||||
stil?: number
|
||||
[key: string]: number | undefined
|
||||
}
|
||||
|
||||
export interface Criterion {
|
||||
id: string
|
||||
name: string
|
||||
weight: number
|
||||
description?: string
|
||||
}
|
||||
|
||||
export interface GradeInfo {
|
||||
thresholds: Record<number, number>
|
||||
labels: Record<number, string>
|
||||
criteria: Record<string, Criterion>
|
||||
}
|
||||
|
||||
export interface Annotation {
|
||||
id: string
|
||||
student_work_id: string
|
||||
page: number
|
||||
position: AnnotationPosition
|
||||
type: AnnotationType
|
||||
text: string
|
||||
severity: 'minor' | 'major' | 'critical'
|
||||
suggestion?: string
|
||||
created_by: string
|
||||
created_at: string
|
||||
role: 'first_examiner' | 'second_examiner'
|
||||
linked_criterion?: string
|
||||
}
|
||||
|
||||
export interface AnnotationPosition {
|
||||
x: number // Prozent (0-100)
|
||||
y: number // Prozent (0-100)
|
||||
width: number // Prozent (0-100)
|
||||
height: number // Prozent (0-100)
|
||||
}
|
||||
|
||||
export type AnnotationType =
|
||||
| 'rechtschreibung'
|
||||
| 'grammatik'
|
||||
| 'inhalt'
|
||||
| 'struktur'
|
||||
| 'stil'
|
||||
| 'comment'
|
||||
| 'highlight'
|
||||
|
||||
export interface FairnessAnalysis {
|
||||
klausur_id: string
|
||||
student_count: number
|
||||
average_grade: number
|
||||
std_deviation: number
|
||||
spread: number
|
||||
outliers: OutlierInfo[]
|
||||
criteria_analysis: Record<string, CriteriaStats>
|
||||
fairness_score: number
|
||||
warnings: string[]
|
||||
}
|
||||
|
||||
export interface OutlierInfo {
|
||||
student_id: string
|
||||
anonym_id: string
|
||||
grade_points: number
|
||||
deviation: number
|
||||
reason: string
|
||||
}
|
||||
|
||||
export interface CriteriaStats {
|
||||
min: number
|
||||
max: number
|
||||
average: number
|
||||
std_deviation: number
|
||||
}
|
||||
|
||||
export interface EHSuggestion {
|
||||
criterion: string
|
||||
excerpt: string
|
||||
relevance_score: number
|
||||
source_chunk_id: string
|
||||
}
|
||||
|
||||
export interface GutachtenSection {
|
||||
title: string
|
||||
content: string
|
||||
evidence_links?: string[]
|
||||
}
|
||||
|
||||
export interface Gutachten {
|
||||
einleitung: string
|
||||
hauptteil: string
|
||||
fazit: string
|
||||
staerken: string[]
|
||||
schwaechen: string[]
|
||||
generated_at?: string
|
||||
}
|
||||
|
||||
// API Response Types
|
||||
export interface KlausurenResponse {
|
||||
klausuren: Klausur[]
|
||||
total: number
|
||||
}
|
||||
|
||||
export interface StudentsResponse {
|
||||
students: StudentWork[]
|
||||
total: number
|
||||
}
|
||||
|
||||
export interface AnnotationsResponse {
|
||||
annotations: Annotation[]
|
||||
}
|
||||
|
||||
// Color mapping for annotation types
|
||||
export const ANNOTATION_COLORS: Record<AnnotationType, string> = {
|
||||
rechtschreibung: '#dc2626', // Red
|
||||
grammatik: '#2563eb', // Blue
|
||||
inhalt: '#16a34a', // Green
|
||||
struktur: '#9333ea', // Purple
|
||||
stil: '#ea580c', // Orange
|
||||
comment: '#6b7280', // Gray
|
||||
highlight: '#eab308', // Yellow
|
||||
}
|
||||
|
||||
// Status colors
|
||||
export const STATUS_COLORS: Record<StudentStatus, string> = {
|
||||
UPLOADED: '#6b7280',
|
||||
OCR_PROCESSING: '#eab308',
|
||||
OCR_COMPLETE: '#3b82f6',
|
||||
ANALYZING: '#8b5cf6',
|
||||
FIRST_EXAMINER: '#f97316',
|
||||
SECOND_EXAMINER: '#06b6d4',
|
||||
COMPLETED: '#22c55e',
|
||||
ERROR: '#ef4444',
|
||||
}
|
||||
|
||||
export const STATUS_LABELS: Record<StudentStatus, string> = {
|
||||
UPLOADED: 'Hochgeladen',
|
||||
OCR_PROCESSING: 'OCR laeuft',
|
||||
OCR_COMPLETE: 'OCR fertig',
|
||||
ANALYZING: 'Analyse laeuft',
|
||||
FIRST_EXAMINER: 'Erstkorrektur',
|
||||
SECOND_EXAMINER: 'Zweitkorrektur',
|
||||
COMPLETED: 'Abgeschlossen',
|
||||
ERROR: 'Fehler',
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
'use client'
|
||||
|
||||
import { getCategoryById } from '@/lib/navigation'
|
||||
import { ModuleCard } from '@/components/common/ModuleCard'
|
||||
import { PagePurpose } from '@/components/common/PagePurpose'
|
||||
|
||||
export default function EducationPage() {
|
||||
const category = getCategoryById('education')
|
||||
|
||||
if (!category) {
|
||||
return <div>Kategorie nicht gefunden</div>
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* Page Purpose */}
|
||||
<PagePurpose
|
||||
title={category.name}
|
||||
purpose="Diese Kategorie umfasst Module fuer Bildungsdokumente. Hier verwalten Sie Crawler fuer Lehrplaene, Erlasse und amtliche Bildungsquellen."
|
||||
audience={['Content Manager', 'Entwickler']}
|
||||
architecture={{
|
||||
services: ['edu-search-service (Go)'],
|
||||
databases: ['PostgreSQL', 'OpenSearch'],
|
||||
}}
|
||||
collapsible={true}
|
||||
defaultCollapsed={false}
|
||||
/>
|
||||
|
||||
{/* Stats */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 mb-8">
|
||||
<div className="bg-white rounded-xl border border-slate-200 p-4">
|
||||
<div className="text-3xl font-bold text-blue-600">2</div>
|
||||
<div className="text-sm text-slate-500">Aktive Crawler</div>
|
||||
<div className="text-xs text-slate-400">NiBiS, KMK</div>
|
||||
</div>
|
||||
<div className="bg-white rounded-xl border border-slate-200 p-4">
|
||||
<div className="text-3xl font-bold text-blue-600">16</div>
|
||||
<div className="text-sm text-slate-500">Bundeslaender</div>
|
||||
<div className="text-xs text-slate-400">Geplant</div>
|
||||
</div>
|
||||
<div className="bg-white rounded-xl border border-slate-200 p-4">
|
||||
<div className="text-3xl font-bold text-green-600">0</div>
|
||||
<div className="text-sm text-slate-500">Personendaten</div>
|
||||
<div className="text-xs text-green-500">Datenschutz-konform</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Modules Grid */}
|
||||
<h2 className="text-lg font-semibold text-slate-900 mb-4">Module</h2>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{category.modules.map((module) => (
|
||||
<ModuleCard key={module.id} module={module} category={category} />
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Info Section */}
|
||||
<div className="mt-8 bg-blue-50 border border-blue-200 rounded-xl p-6">
|
||||
<h3 className="font-semibold text-blue-800 flex items-center gap-2">
|
||||
<span>📚</span>
|
||||
Bildungsdokumente
|
||||
</h3>
|
||||
<p className="text-sm text-blue-700 mt-2">
|
||||
Das System crawlt ausschliesslich oeffentliche Bildungsdokumente (Lehrplaene, Erlasse, Beschluesse).
|
||||
<strong> Keine personenbezogenen Daten</strong> werden erfasst oder gespeichert.
|
||||
Alle Crawler respektieren robots.txt und verwenden Rate-Limiting.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Compliance Note */}
|
||||
<div className="mt-4 bg-green-50 border border-green-200 rounded-xl p-6">
|
||||
<h3 className="font-semibold text-green-800 flex items-center gap-2">
|
||||
<span>✅</span>
|
||||
Datenschutz-Hinweis
|
||||
</h3>
|
||||
<p className="text-sm text-green-700 mt-2">
|
||||
Dieses Modul verarbeitet <strong>keine personenbezogenen Daten</strong>.
|
||||
Es werden ausschliesslich amtliche Dokumente und Metadaten aus oeffentlichen Quellen indexiert.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,181 @@
|
||||
'use client'
|
||||
|
||||
/**
|
||||
* Zeugnisse-Crawler Page
|
||||
* Verwaltet Zeugnis-Strukturen und -Vorlagen
|
||||
*/
|
||||
|
||||
import { PagePurpose } from '@/components/common/PagePurpose'
|
||||
import { getModuleByHref } from '@/lib/navigation'
|
||||
import { FileText, Upload, Settings, Database, RefreshCw } from 'lucide-react'
|
||||
|
||||
export default function ZeugnisseCrawlerPage() {
|
||||
const moduleInfo = getModuleByHref('/education/zeugnisse-crawler')
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{moduleInfo && (
|
||||
<PagePurpose
|
||||
title={moduleInfo.module.name}
|
||||
purpose={moduleInfo.module.purpose}
|
||||
audience={moduleInfo.module.audience}
|
||||
collapsible={true}
|
||||
defaultCollapsed={true}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Stats */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
|
||||
<div className="bg-white rounded-xl border border-slate-200 p-4">
|
||||
<div className="text-3xl font-bold text-blue-600">16</div>
|
||||
<div className="text-sm text-slate-500">Bundeslaender</div>
|
||||
</div>
|
||||
<div className="bg-white rounded-xl border border-slate-200 p-4">
|
||||
<div className="text-3xl font-bold text-green-600">48</div>
|
||||
<div className="text-sm text-slate-500">Zeugnis-Vorlagen</div>
|
||||
</div>
|
||||
<div className="bg-white rounded-xl border border-slate-200 p-4">
|
||||
<div className="text-3xl font-bold text-purple-600">12</div>
|
||||
<div className="text-sm text-slate-500">Schulformen</div>
|
||||
</div>
|
||||
<div className="bg-white rounded-xl border border-slate-200 p-4">
|
||||
<div className="text-3xl font-bold text-orange-600">156</div>
|
||||
<div className="text-sm text-slate-500">Felder erkannt</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Main Content */}
|
||||
<div className="bg-white rounded-xl border border-slate-200 p-6">
|
||||
<h2 className="text-lg font-semibold text-slate-900 mb-4">Zeugnis-Strukturen</h2>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{/* Upload Card */}
|
||||
<div className="border border-dashed border-slate-300 rounded-xl p-6 text-center hover:border-blue-500 hover:bg-blue-50/50 transition-colors cursor-pointer">
|
||||
<Upload className="w-10 h-10 mx-auto mb-3 text-slate-400" />
|
||||
<div className="font-medium text-slate-700">Zeugnis hochladen</div>
|
||||
<div className="text-sm text-slate-500 mt-1">PDF oder Bild</div>
|
||||
</div>
|
||||
|
||||
{/* Niedersachsen */}
|
||||
<div className="border border-slate-200 rounded-xl p-4 hover:shadow-md transition-shadow">
|
||||
<div className="flex items-center gap-3 mb-3">
|
||||
<FileText className="w-8 h-8 text-blue-600" />
|
||||
<div>
|
||||
<div className="font-medium text-slate-900">Niedersachsen</div>
|
||||
<div className="text-xs text-slate-500">12 Vorlagen</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
<span className="px-2 py-0.5 bg-slate-100 text-slate-600 text-xs rounded">Grundschule</span>
|
||||
<span className="px-2 py-0.5 bg-slate-100 text-slate-600 text-xs rounded">Gymnasium</span>
|
||||
<span className="px-2 py-0.5 bg-slate-100 text-slate-600 text-xs rounded">IGS</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Bayern */}
|
||||
<div className="border border-slate-200 rounded-xl p-4 hover:shadow-md transition-shadow">
|
||||
<div className="flex items-center gap-3 mb-3">
|
||||
<FileText className="w-8 h-8 text-blue-600" />
|
||||
<div>
|
||||
<div className="font-medium text-slate-900">Bayern</div>
|
||||
<div className="text-xs text-slate-500">10 Vorlagen</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
<span className="px-2 py-0.5 bg-slate-100 text-slate-600 text-xs rounded">Grundschule</span>
|
||||
<span className="px-2 py-0.5 bg-slate-100 text-slate-600 text-xs rounded">Gymnasium</span>
|
||||
<span className="px-2 py-0.5 bg-slate-100 text-slate-600 text-xs rounded">Realschule</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* NRW */}
|
||||
<div className="border border-slate-200 rounded-xl p-4 hover:shadow-md transition-shadow">
|
||||
<div className="flex items-center gap-3 mb-3">
|
||||
<FileText className="w-8 h-8 text-blue-600" />
|
||||
<div>
|
||||
<div className="font-medium text-slate-900">Nordrhein-Westfalen</div>
|
||||
<div className="text-xs text-slate-500">14 Vorlagen</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
<span className="px-2 py-0.5 bg-slate-100 text-slate-600 text-xs rounded">Grundschule</span>
|
||||
<span className="px-2 py-0.5 bg-slate-100 text-slate-600 text-xs rounded">Gesamtschule</span>
|
||||
<span className="px-2 py-0.5 bg-slate-100 text-slate-600 text-xs rounded">Gymnasium</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Baden-Württemberg */}
|
||||
<div className="border border-slate-200 rounded-xl p-4 hover:shadow-md transition-shadow">
|
||||
<div className="flex items-center gap-3 mb-3">
|
||||
<FileText className="w-8 h-8 text-blue-600" />
|
||||
<div>
|
||||
<div className="font-medium text-slate-900">Baden-Wuerttemberg</div>
|
||||
<div className="text-xs text-slate-500">8 Vorlagen</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
<span className="px-2 py-0.5 bg-slate-100 text-slate-600 text-xs rounded">Grundschule</span>
|
||||
<span className="px-2 py-0.5 bg-slate-100 text-slate-600 text-xs rounded">Gymnasium</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Weitere */}
|
||||
<div className="border border-slate-200 rounded-xl p-4 hover:shadow-md transition-shadow bg-slate-50">
|
||||
<div className="flex items-center gap-3 mb-3">
|
||||
<Database className="w-8 h-8 text-slate-400" />
|
||||
<div>
|
||||
<div className="font-medium text-slate-700">Weitere Bundeslaender</div>
|
||||
<div className="text-xs text-slate-500">4 Vorlagen</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-sm text-slate-500">
|
||||
Hessen, Sachsen, Berlin, Hamburg...
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Crawler Section */}
|
||||
<div className="bg-white rounded-xl border border-slate-200 p-6">
|
||||
<h3 className="text-lg font-semibold text-slate-900 mb-4 flex items-center gap-2">
|
||||
<RefreshCw className="w-5 h-5" />
|
||||
Crawler-Status
|
||||
</h3>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div className="border border-slate-200 rounded-lg p-4">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="font-medium">Schulportal NI</span>
|
||||
<span className="px-2 py-0.5 bg-green-100 text-green-700 text-xs rounded-full">Aktiv</span>
|
||||
</div>
|
||||
<div className="text-sm text-slate-500">Letzter Crawl: vor 2 Stunden</div>
|
||||
</div>
|
||||
<div className="border border-slate-200 rounded-lg p-4">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="font-medium">KMK Vorlagen</span>
|
||||
<span className="px-2 py-0.5 bg-green-100 text-green-700 text-xs rounded-full">Aktiv</span>
|
||||
</div>
|
||||
<div className="text-sm text-slate-500">Letzter Crawl: vor 1 Tag</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Info Box */}
|
||||
<div className="bg-blue-50 border border-blue-200 rounded-xl p-6">
|
||||
<h3 className="font-semibold text-blue-800 flex items-center gap-2">
|
||||
<Settings className="w-5 h-5" />
|
||||
Verwandte Module
|
||||
</h3>
|
||||
<div className="mt-3 grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<a href="/education/edu-search" className="block p-3 bg-white rounded-lg hover:shadow-md transition-shadow">
|
||||
<div className="font-medium text-slate-900">Education Search</div>
|
||||
<div className="text-sm text-slate-500">Bildungsdokumente durchsuchen</div>
|
||||
</a>
|
||||
<a href="/ai/rag-pipeline" className="block p-3 bg-white rounded-lg hover:shadow-md transition-shadow">
|
||||
<div className="font-medium text-slate-900">RAG Pipeline</div>
|
||||
<div className="text-sm text-slate-500">Dokumente indexieren</div>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user