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:
Benjamin Boenisch
2026-02-11 23:47:26 +01:00
commit 5a31f52310
1224 changed files with 425430 additions and 0 deletions
+51
View File
@@ -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
+56
View File
@@ -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
View File
@@ -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
+44
View File
@@ -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
+55
View File
@@ -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"]
+45
View File
@@ -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
+11
View File
@@ -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">
&larr; 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>
)
}
+390
View File
@@ -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 &rarr;
</Link>
<Link
href="/ai/agents/architecture#soul-files"
className="text-sm font-medium text-teal-600 hover:text-teal-800"
>
SOUL-Files verstehen &rarr;
</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>
)
}
+396
View File
@@ -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 &quot;Vergleich starten&quot;, 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
}
+53
View File
@@ -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 += `&regulations=${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)}&regulation=${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"
>
&times;
</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>
)
}
+991
View File
@@ -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 &quot;Zuruecksetzen&quot;
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} &quot;{rule.conditions[0]?.value}&quot;
</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 &quot;Inklusion&quot;</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 &quot;Werbung&quot;</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 &quot;new&quot;</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 &quot;\d&#123;4&#125;&quot;</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: &quot;{intent.example}&quot;
</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">
&larr; 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>
)
}
+155
View File
@@ -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&sections=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">&apos;de&apos;</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&lt;ConsentState&gt;</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&lt;boolean&gt;</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&lt;boolean&gt;</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&lt;boolean&gt;</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&lt;void&gt;</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&lt;void&gt;</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&lt;void&gt;</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) =&gt; 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">() =&gt; 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">() =&gt; 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) =&gt; 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">() =&gt; 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">() =&gt; 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) =&gt; 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&lt;ConsentState&gt;</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&lt;boolean&gt;</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">() =&gt; 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">() =&gt; 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) =&gt; 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">&gt;= 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">&gt;= 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">&gt;= 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">&gt;= 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">&gt;= 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">&gt;= 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">&gt;= 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">&gt;= 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 &quot;docker logs breakpilot-pwa-backend --tail 50&quot;</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 &quot;docker compose restart backend&quot;</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 &quot;docker ps&quot;</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 &quot;feat: beschreibung&quot;
</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 &quot;Client ID not registered&quot; oder &quot;user does not exist&quot; 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 &quot;cd ~/Projekte/breakpilot-pwa && docker compose up -d --force-recreate woodpecker-server&quot;</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>
)
}
@@ -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>
)
}
@@ -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>
)
}
@@ -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">&quot;{queryPreview}&quot;</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 &quot;EH-Vorschlaege laden&quot; 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