fix: Restore all files lost during destructive rebase

A previous `git pull --rebase origin main` dropped 177 local commits,
losing 3400+ files across admin-v2, backend, studio-v2, website,
klausur-service, and many other services. The partial restore attempt
(660295e2) only recovered some files.

This commit restores all missing files from pre-rebase ref 98933f5e
while preserving post-rebase additions (night-scheduler, night-mode UI,
NightModeWidget dashboard integration).

Restored features include:
- AI Module Sidebar (FAB), OCR Labeling, OCR Compare
- GPU Dashboard, RAG Pipeline, Magic Help
- Klausur-Korrektur (8 files), Abitur-Archiv (5+ files)
- Companion, Zeugnisse-Crawler, Screen Flow
- Full backend, studio-v2, website, klausur-service
- All compliance SDKs, agent-core, voice-service
- CI/CD configs, documentation, scripts

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Benjamin Admin
2026-02-09 09:51:32 +01:00
parent f7487ee240
commit 21a844cb8a
1986 changed files with 744143 additions and 1731 deletions

View File

@@ -16,11 +16,13 @@ COPY . .
ARG NEXT_PUBLIC_API_URL
ARG NEXT_PUBLIC_OLD_ADMIN_URL
ARG NEXT_PUBLIC_SDK_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_SDK_URL=$NEXT_PUBLIC_SDK_URL
ENV NEXT_PUBLIC_KLAUSUR_SERVICE_URL=$NEXT_PUBLIC_KLAUSUR_SERVICE_URL
# Build the application
RUN npm run build

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"]

View File

@@ -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()
}
}

View File

@@ -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

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
)

View File

@@ -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
}

View File

@@ -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()}
`
}

View File

@@ -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
}

View File

@@ -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)
}

View File

@@ -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] + "\""
}

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -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]
}

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>
)
}

View File

@@ -12,6 +12,7 @@
import { useState, useEffect, useCallback } from 'react'
import { PagePurpose } from '@/components/common/PagePurpose'
import { AIToolsSidebarResponsive } from '@/components/ai/AIToolsSidebar'
interface LLMResponse {
provider: string
@@ -210,21 +211,24 @@ export default function LLMComparePage() {
{/* 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."
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: 'RAG Management', href: '/ai/rag', description: 'Training Data verwalten' },
{ name: 'GPU Infrastruktur', href: '/infrastructure/gpu', description: 'GPU-Ressourcen' },
{ name: 'OCR-Labeling', href: '/ai/ocr-labeling', description: 'Handschrift-Training' },
{ 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">

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -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>
)
}

View File

@@ -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
}

File diff suppressed because it is too large Load Diff

View File

@@ -10,6 +10,7 @@
import React, { useState, useEffect, useCallback } from 'react'
import Link from 'next/link'
import { PagePurpose } from '@/components/common/PagePurpose'
import { AIModuleSidebarResponsive } from '@/components/ai/AIModuleSidebar'
// API uses local proxy route to klausur-service
const API_PROXY = '/api/legal-corpus'
@@ -1021,20 +1022,25 @@ export default function RAGPage() {
<div className="p-6">
{/* Page Purpose */}
<PagePurpose
title="Legal Corpus RAG"
purpose="Das Legal Corpus RAG System indexiert alle 19 relevanten Regulierungen (DSGVO, AI Act, CRA, BSI TR-03161, etc.) fuer semantische Suche waehrend UCCA-Assessments. Die Dokumente werden in Chunks aufgeteilt und mit BGE-M3 Embeddings indexiert."
title="Daten & RAG"
purpose="Verwalten und durchsuchen Sie indexierte Dokumente im RAG-System. Das Legal Corpus enthält 19+ Regulierungen (DSGVO, AI Act, CRA, BSI TR-03161, etc.) für semantische Suche. Teil der KI-Daten-Pipeline: Empfängt Embeddings von der RAG Pipeline und liefert Suchergebnisse an die Klausur-Korrektur."
audience={['DSB', 'Compliance Officer', 'Entwickler']}
gdprArticles={['§5 UrhG (Amtliche Werke)', 'Art. 5 DSGVO (Rechenschaftspflicht)']}
architecture={{
services: ['klausur-service (Python)', 'embedding-service (BGE-M3)', 'Qdrant (Vector DB)'],
databases: ['Qdrant Collection: bp_legal_corpus'],
databases: ['Qdrant Collections: bp_legal_corpus, bp_nibis_eh, bp_eh'],
}}
relatedPages={[
{ name: 'RAG Pipeline', href: '/ai/rag-pipeline', description: 'Neue Dokumente indexieren' },
{ name: 'Klausur-Korrektur', href: '/ai/klausur-korrektur', description: 'RAG-Suche nutzen' },
{ name: 'OCR-Labeling', href: '/ai/ocr-labeling', description: 'Ground Truth erstellen' },
{ name: 'Compliance Hub', href: '/compliance/hub', description: 'Compliance-Dashboard' },
{ name: 'Requirements', href: '/compliance/requirements', description: 'Anforderungskatalog' },
]}
/>
{/* AI Module Sidebar - Desktop: Fixed, Mobile: FAB + Drawer */}
<AIModuleSidebarResponsive currentModule="rag" />
{/* Stats Cards */}
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 mb-6">
<div className="bg-white rounded-xl p-4 border border-slate-200">

View File

@@ -14,6 +14,7 @@
import { useState, useEffect, useCallback, useRef } from 'react'
import Link from 'next/link'
import { PagePurpose } from '@/components/common/PagePurpose'
import { AIToolsSidebarResponsive } from '@/components/ai/AIToolsSidebar'
import type { TestRun, BQASMetrics, TrendData, TabType } from './types'
// API Configuration - Use internal proxy to avoid CORS issues
@@ -1429,14 +1430,17 @@ export default function TestQualityPage() {
databases: ['Qdrant', 'PostgreSQL'],
}}
relatedPages={[
{ name: 'CI/CD Scheduler', href: '/infrastructure/ci-cd', description: 'Automatische Test-Planung' },
{ name: 'RAG Management', href: '/ai/rag', description: 'Training Data & RAG Pipelines' },
{ name: 'LLM Vergleich', href: '/ai/llm-compare', description: 'Provider-Vergleich' },
{ name: 'GPU Infrastruktur', href: '/ai/gpu', description: 'GPU-Ressourcen verwalten' },
{ name: 'RAG Management', href: '/ai/rag', description: 'Training Data & RAG Pipelines' },
]}
collapsible={true}
defaultCollapsed={true}
/>
{/* KI-Werkzeuge Sidebar */}
<AIToolsSidebarResponsive currentTool="test-quality" />
{error && (
<div className="p-4 bg-red-50 border border-red-200 rounded-lg text-red-700 flex items-center gap-3">
<svg className="w-5 h-5 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">

View File

@@ -99,47 +99,51 @@ export default function ArchitecturePage() {
<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-slate-200 rounded-lg">
<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-slate-200 rounded-lg">
<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</span>
<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-slate-200 rounded-lg">
<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</span>
<span className="text-slate-700">Consent Verwaltung migriert (inkl. Einwilligungen)</span>
</div>
<div className="flex items-center gap-3 p-3 border border-slate-200 rounded-lg">
<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-yellow-200 bg-yellow-50 rounded-lg">
<input type="checkbox" className="w-4 h-4" />
<span className="text-slate-700">DSR-Modul migrieren</span>
<span className="text-xs text-yellow-600 ml-auto">Prioritaet: Hoch</span>
<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">Cookie-Kategorien migrieren</span>
<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">KI-Module migrieren (LLM Compare, OCR, RAG)</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">Infrastruktur-Module migrieren</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">Alle Module getestet und deployed</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 und dokumentiert</span>
<span className="text-slate-700">Verwaiste Module identifiziert (voice, training, multiplayer, pca-platform)</span>
</div>
</div>
</div>

View File

@@ -6,6 +6,7 @@ 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 {
@@ -111,7 +112,18 @@ export default function DashboardPage() {
))}
</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">
@@ -127,9 +139,6 @@ export default function DashboardPage() {
</p>
</div>
</div>
{/* System Status */}
<ServiceStatus />
</div>
{/* Info Box */}

View File

@@ -0,0 +1,769 @@
'use client'
/**
* Admin Panel for Website Content
*
* Allows editing all website texts:
* - Hero Section
* - Features
* - FAQ
* - Pricing
* - Trust Indicators
* - Testimonial
*
* Includes Live-Preview of website
*/
import { useState, useEffect, useRef, useCallback } from 'react'
import { WebsiteContent, HeroContent, FeatureContent } from '@/lib/content-types'
// Admin Key (in production via login)
const ADMIN_KEY = 'breakpilot-admin-2024'
// Mapping tabs to website sections
const SECTION_MAP: Record<string, { selector: string; scrollTo: string }> = {
hero: { selector: '#hero', scrollTo: 'hero' },
features: { selector: '#features', scrollTo: 'features' },
faq: { selector: '#faq', scrollTo: 'faq' },
pricing: { selector: '#pricing', scrollTo: 'pricing' },
other: { selector: '#trust', scrollTo: 'trust' },
}
export default function ContentPage() {
const [content, setContent] = useState<WebsiteContent | null>(null)
const [loading, setLoading] = useState(true)
const [saving, setSaving] = useState(false)
const [message, setMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null)
const [activeTab, setActiveTab] = useState<'hero' | 'features' | 'faq' | 'pricing' | 'other'>('hero')
const [showPreview, setShowPreview] = useState(true)
const iframeRef = useRef<HTMLIFrameElement>(null)
// Scroll preview to section
const scrollToSection = useCallback((tab: string) => {
if (!iframeRef.current?.contentWindow) return
const section = SECTION_MAP[tab]
if (section) {
try {
iframeRef.current.contentWindow.postMessage(
{ type: 'scrollTo', section: section.scrollTo },
'*'
)
} catch {
// Same-origin policy - fallback
}
}
}, [])
// Scroll to section on tab change
useEffect(() => {
scrollToSection(activeTab)
}, [activeTab, scrollToSection])
// Load content
useEffect(() => {
loadContent()
}, [])
async function loadContent() {
try {
const res = await fetch('/api/development/content')
if (res.ok) {
const data = await res.json()
setContent(data)
} else {
setMessage({ type: 'error', text: 'Fehler beim Laden' })
}
} catch {
setMessage({ type: 'error', text: 'Fehler beim Laden' })
} finally {
setLoading(false)
}
}
async function saveChanges() {
if (!content) return
setSaving(true)
setMessage(null)
try {
const res = await fetch('/api/development/content', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'x-admin-key': ADMIN_KEY,
},
body: JSON.stringify(content),
})
if (res.ok) {
setMessage({ type: 'success', text: 'Gespeichert!' })
} else {
const error = await res.json()
setMessage({ type: 'error', text: error.error || 'Fehler beim Speichern' })
}
} catch {
setMessage({ type: 'error', text: 'Fehler beim Speichern' })
} finally {
setSaving(false)
}
}
// Hero Section update
function updateHero(field: keyof HeroContent, value: string) {
if (!content) return
setContent({
...content,
hero: { ...content.hero, [field]: value },
})
}
// Feature update
function updateFeature(index: number, field: keyof FeatureContent, value: string) {
if (!content) return
const newFeatures = [...content.features]
newFeatures[index] = { ...newFeatures[index], [field]: value }
setContent({ ...content, features: newFeatures })
}
// FAQ update
function updateFAQ(index: number, field: 'question' | 'answer', value: string | string[]) {
if (!content) return
const newFAQ = [...content.faq]
if (field === 'answer' && typeof value === 'string') {
newFAQ[index] = { ...newFAQ[index], answer: value.split('\n') }
} else if (field === 'question' && typeof value === 'string') {
newFAQ[index] = { ...newFAQ[index], question: value }
}
setContent({ ...content, faq: newFAQ })
}
// Add FAQ
function addFAQ() {
if (!content) return
setContent({
...content,
faq: [...content.faq, { question: 'Neue Frage?', answer: ['Antwort hier...'] }],
})
}
// Remove FAQ
function removeFAQ(index: number) {
if (!content) return
const newFAQ = content.faq.filter((_, i) => i !== index)
setContent({ ...content, faq: newFAQ })
}
// Pricing update
function updatePricing(index: number, field: string, value: string | number | boolean) {
if (!content) return
const newPricing = [...content.pricing]
if (field === 'price') {
newPricing[index] = { ...newPricing[index], price: Number(value) }
} else if (field === 'popular') {
newPricing[index] = { ...newPricing[index], popular: Boolean(value) }
} else if (field.startsWith('features.')) {
const subField = field.replace('features.', '')
if (subField === 'included' && typeof value === 'string') {
newPricing[index] = {
...newPricing[index],
features: {
...newPricing[index].features,
included: value.split('\n'),
},
}
} else {
newPricing[index] = {
...newPricing[index],
features: {
...newPricing[index].features,
[subField]: value,
},
}
}
} else {
newPricing[index] = { ...newPricing[index], [field]: value }
}
setContent({ ...content, pricing: newPricing })
}
if (loading) {
return (
<div className="flex items-center justify-center py-12">
<div className="text-xl text-slate-600">Laden...</div>
</div>
)
}
if (!content) {
return (
<div className="flex items-center justify-center py-12">
<div className="text-xl text-red-600">Fehler beim Laden</div>
</div>
)
}
return (
<div>
{/* Toolbar */}
<div className="bg-white rounded-xl border border-slate-200 p-4 mb-6 flex items-center justify-between">
<div className="flex items-center gap-4">
<h1 className="text-lg font-semibold text-slate-900">Website Content</h1>
{/* Preview Toggle */}
<button
onClick={() => setShowPreview(!showPreview)}
className={`flex items-center gap-2 px-3 py-2 rounded-lg text-sm font-medium transition-colors ${
showPreview
? 'bg-blue-100 text-blue-700'
: 'bg-slate-100 text-slate-600 hover:bg-slate-200'
}`}
title={showPreview ? 'Preview ausblenden' : 'Preview einblenden'}
>
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<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>
Live-Preview
</button>
</div>
<div className="flex items-center gap-4">
{message && (
<span
className={`px-3 py-1 rounded text-sm ${
message.type === 'success'
? 'bg-green-100 text-green-800'
: 'bg-red-100 text-red-800'
}`}
>
{message.text}
</span>
)}
<button
onClick={saveChanges}
disabled={saving}
className="bg-blue-600 text-white px-6 py-2 rounded-lg font-medium hover:bg-blue-700 disabled:opacity-50 transition-colors"
>
{saving ? 'Speichern...' : 'Speichern'}
</button>
</div>
</div>
{/* Tabs */}
<div className="mb-6">
<div className="flex gap-1 bg-slate-100 p-1 rounded-lg w-fit">
{(['hero', 'features', 'faq', 'pricing', 'other'] as const).map((tab) => (
<button
key={tab}
onClick={() => setActiveTab(tab)}
className={`px-4 py-2 text-sm font-medium rounded-md transition-colors ${
activeTab === tab
? 'bg-white text-slate-900 shadow-sm'
: 'text-slate-600 hover:text-slate-900'
}`}
>
{tab === 'hero' && 'Hero'}
{tab === 'features' && 'Features'}
{tab === 'faq' && 'FAQ'}
{tab === 'pricing' && 'Preise'}
{tab === 'other' && 'Sonstige'}
</button>
))}
</div>
</div>
{/* Split Layout: Editor + Preview */}
<div className={`grid gap-6 ${showPreview ? 'grid-cols-2' : 'grid-cols-1'}`}>
{/* Editor Panel */}
<div className="bg-white rounded-xl border border-slate-200 shadow-sm p-6 max-h-[calc(100vh-280px)] overflow-y-auto">
{/* Hero Tab */}
{activeTab === 'hero' && (
<div className="space-y-6">
<h2 className="text-xl font-semibold text-slate-900">Hero Section</h2>
<div className="grid gap-4">
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Badge</label>
<input
type="text"
value={content.hero.badge}
onChange={(e) => updateHero('badge', e.target.value)}
className="w-full px-3 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
/>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">
Titel (vor Highlight)
</label>
<input
type="text"
value={content.hero.title}
onChange={(e) => updateHero('title', e.target.value)}
className="w-full px-3 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">
Highlight 1
</label>
<input
type="text"
value={content.hero.titleHighlight1}
onChange={(e) => updateHero('titleHighlight1', e.target.value)}
className="w-full px-3 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
/>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">
Highlight 2
</label>
<input
type="text"
value={content.hero.titleHighlight2}
onChange={(e) => updateHero('titleHighlight2', e.target.value)}
className="w-full px-3 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
/>
</div>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Untertitel</label>
<textarea
value={content.hero.subtitle}
onChange={(e) => updateHero('subtitle', e.target.value)}
rows={3}
className="w-full px-3 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
/>
</div>
<div className="grid grid-cols-3 gap-4">
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">
CTA Primaer
</label>
<input
type="text"
value={content.hero.ctaPrimary}
onChange={(e) => updateHero('ctaPrimary', e.target.value)}
className="w-full px-3 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
/>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">
CTA Sekundaer
</label>
<input
type="text"
value={content.hero.ctaSecondary}
onChange={(e) => updateHero('ctaSecondary', e.target.value)}
className="w-full px-3 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
/>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">CTA Hinweis</label>
<input
type="text"
value={content.hero.ctaHint}
onChange={(e) => updateHero('ctaHint', e.target.value)}
className="w-full px-3 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
/>
</div>
</div>
</div>
</div>
)}
{/* Features Tab */}
{activeTab === 'features' && (
<div className="space-y-6">
<h2 className="text-xl font-semibold text-slate-900">Features</h2>
{content.features.map((feature, index) => (
<div key={feature.id} className="border border-slate-200 rounded-lg p-4">
<div className="grid gap-4">
<div className="grid grid-cols-3 gap-4">
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Icon</label>
<input
type="text"
value={feature.icon}
onChange={(e) => updateFeature(index, 'icon', e.target.value)}
className="w-full px-3 py-2 border border-slate-300 rounded-lg text-2xl text-center"
/>
</div>
<div className="col-span-2">
<label className="block text-sm font-medium text-slate-700 mb-1">Titel</label>
<input
type="text"
value={feature.title}
onChange={(e) => updateFeature(index, 'title', e.target.value)}
className="w-full px-3 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
/>
</div>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">
Beschreibung
</label>
<textarea
value={feature.description}
onChange={(e) => updateFeature(index, 'description', e.target.value)}
rows={2}
className="w-full px-3 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
/>
</div>
</div>
</div>
))}
</div>
)}
{/* FAQ Tab */}
{activeTab === 'faq' && (
<div className="space-y-6">
<div className="flex items-center justify-between">
<h2 className="text-xl font-semibold text-slate-900">FAQ</h2>
<button
onClick={addFAQ}
className="px-4 py-2 bg-slate-100 text-slate-700 rounded-lg hover:bg-slate-200 transition-colors"
>
+ Frage hinzufuegen
</button>
</div>
{content.faq.map((item, index) => (
<div key={index} className="border border-slate-200 rounded-lg p-4">
<div className="flex items-start justify-between gap-4">
<div className="flex-1 space-y-4">
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">
Frage {index + 1}
</label>
<input
type="text"
value={item.question}
onChange={(e) => updateFAQ(index, 'question', e.target.value)}
className="w-full px-3 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
/>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">
Antwort
</label>
<textarea
value={item.answer.join('\n')}
onChange={(e) => updateFAQ(index, 'answer', e.target.value)}
rows={4}
className="w-full px-3 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 font-mono text-sm"
/>
</div>
</div>
<button
onClick={() => removeFAQ(index)}
className="p-2 text-red-600 hover:bg-red-50 rounded-lg transition-colors"
title="Frage entfernen"
>
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<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>
)}
{/* Pricing Tab */}
{activeTab === 'pricing' && (
<div className="space-y-6">
<h2 className="text-xl font-semibold text-slate-900">Preise</h2>
{content.pricing.map((plan, index) => (
<div key={plan.id} className="border border-slate-200 rounded-lg p-4">
<div className="grid gap-4">
<div className="grid grid-cols-4 gap-4">
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Name</label>
<input
type="text"
value={plan.name}
onChange={(e) => updatePricing(index, 'name', e.target.value)}
className="w-full px-3 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
/>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">
Preis (EUR)
</label>
<input
type="number"
step="0.01"
value={plan.price}
onChange={(e) => updatePricing(index, 'price', e.target.value)}
className="w-full px-3 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
/>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">
Intervall
</label>
<input
type="text"
value={plan.interval}
onChange={(e) => updatePricing(index, 'interval', e.target.value)}
className="w-full px-3 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
/>
</div>
<div className="flex items-end">
<label className="flex items-center gap-2">
<input
type="checkbox"
checked={plan.popular || false}
onChange={(e) => updatePricing(index, 'popular', e.target.checked)}
className="w-4 h-4 text-blue-600 rounded"
/>
<span className="text-sm text-slate-700">Beliebt</span>
</label>
</div>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">
Beschreibung
</label>
<input
type="text"
value={plan.description}
onChange={(e) => updatePricing(index, 'description', e.target.value)}
className="w-full px-3 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">
Aufgaben
</label>
<input
type="text"
value={plan.features.tasks}
onChange={(e) => updatePricing(index, 'features.tasks', e.target.value)}
className="w-full px-3 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
/>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">
Aufgaben-Beschreibung
</label>
<input
type="text"
value={plan.features.taskDescription}
onChange={(e) =>
updatePricing(index, 'features.taskDescription', e.target.value)
}
className="w-full px-3 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
/>
</div>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">
Features (eine pro Zeile)
</label>
<textarea
value={plan.features.included.join('\n')}
onChange={(e) => updatePricing(index, 'features.included', e.target.value)}
rows={4}
className="w-full px-3 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 font-mono text-sm"
/>
</div>
</div>
</div>
))}
</div>
)}
{/* Other Tab */}
{activeTab === 'other' && (
<div className="space-y-8">
{/* Trust Indicators */}
<div>
<h2 className="text-xl font-semibold text-slate-900 mb-4">Trust Indicators</h2>
<div className="grid grid-cols-3 gap-4">
{(['item1', 'item2', 'item3'] as const).map((key, index) => (
<div key={key} className="border border-slate-200 rounded-lg p-4">
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">
Wert {index + 1}
</label>
<input
type="text"
value={content.trust[key].value}
onChange={(e) =>
setContent({
...content,
trust: {
...content.trust,
[key]: { ...content.trust[key], value: e.target.value },
},
})
}
className="w-full px-3 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
/>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">
Label {index + 1}
</label>
<input
type="text"
value={content.trust[key].label}
onChange={(e) =>
setContent({
...content,
trust: {
...content.trust,
[key]: { ...content.trust[key], label: e.target.value },
},
})
}
className="w-full px-3 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
/>
</div>
</div>
</div>
))}
</div>
</div>
{/* Testimonial */}
<div>
<h2 className="text-xl font-semibold text-slate-900 mb-4">Testimonial</h2>
<div className="border border-slate-200 rounded-lg p-4 space-y-4">
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Zitat</label>
<textarea
value={content.testimonial.quote}
onChange={(e) =>
setContent({
...content,
testimonial: { ...content.testimonial, quote: e.target.value },
})
}
rows={3}
className="w-full px-3 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Autor</label>
<input
type="text"
value={content.testimonial.author}
onChange={(e) =>
setContent({
...content,
testimonial: { ...content.testimonial, author: e.target.value },
})
}
className="w-full px-3 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
/>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Rolle</label>
<input
type="text"
value={content.testimonial.role}
onChange={(e) =>
setContent({
...content,
testimonial: { ...content.testimonial, role: e.target.value },
})
}
className="w-full px-3 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
/>
</div>
</div>
</div>
</div>
</div>
)}
</div>
{/* Live Preview Panel */}
{showPreview && (
<div className="bg-white rounded-xl border border-slate-200 shadow-sm overflow-hidden">
{/* Preview Header */}
<div className="bg-slate-50 border-b border-slate-200 px-4 py-3 flex items-center justify-between">
<div className="flex items-center gap-2">
<div className="flex gap-1.5">
<div className="w-3 h-3 rounded-full bg-red-400"></div>
<div className="w-3 h-3 rounded-full bg-yellow-400"></div>
<div className="w-3 h-3 rounded-full bg-green-400"></div>
</div>
<span className="text-xs text-slate-500 ml-2">breakpilot.app</span>
</div>
<div className="flex items-center gap-2">
<span className="text-xs font-medium text-slate-600 bg-slate-200 px-2 py-1 rounded">
{activeTab === 'hero' && 'Hero Section'}
{activeTab === 'features' && 'Features'}
{activeTab === 'faq' && 'FAQ'}
{activeTab === 'pricing' && 'Pricing'}
{activeTab === 'other' && 'Trust & Testimonial'}
</span>
<button
onClick={() => iframeRef.current?.contentWindow?.location.reload()}
className="p-1.5 text-slate-500 hover:text-slate-700 hover:bg-slate-200 rounded transition-colors"
title="Preview neu laden"
>
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
</svg>
</button>
</div>
</div>
{/* Preview Frame */}
<div className="relative h-[calc(100vh-340px)] bg-slate-100">
<iframe
ref={iframeRef}
src={`https://macmini:3000/?preview=true&section=${activeTab}#${activeTab}`}
className="w-full h-full border-0 scale-75 origin-top-left"
style={{
width: '133.33%',
height: '133.33%',
transform: 'scale(0.75)',
transformOrigin: 'top left',
}}
title="Website Preview"
sandbox="allow-same-origin allow-scripts"
/>
{/* Section Indicator */}
<div className="absolute bottom-4 left-4 right-4 bg-blue-600 text-white px-4 py-2 rounded-lg shadow-lg flex items-center gap-2 text-sm">
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<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>
<span>
Du bearbeitest: <strong>
{activeTab === 'hero' && 'Hero Section (Startbereich)'}
{activeTab === 'features' && 'Features (Funktionen)'}
{activeTab === 'faq' && 'FAQ (Haeufige Fragen)'}
{activeTab === 'pricing' && 'Pricing (Preise)'}
{activeTab === 'other' && 'Trust & Testimonial'}
</strong>
</span>
</div>
</div>
</div>
)}
</div>
</div>
)
}

View File

@@ -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' },
// === DSGVO (Violet #7c3aed) ===
{ id: 'admin-consent', name: 'Consent Verwaltung', description: 'Rechtliche Dokumente & Versionen', category: 'dsgvo', icon: '📄', url: '/dsgvo/consent' },
{ id: 'admin-dsr', name: 'Datenschutzanfragen', description: 'DSGVO Art. 15-21', category: 'dsgvo', icon: '🔒', url: '/dsgvo/dsr' },
{ id: 'admin-einwilligungen', name: 'Einwilligungen', description: 'Nutzer-Consent Uebersicht', category: 'dsgvo', icon: '✅', url: '/dsgvo/einwilligungen' },
{ id: 'admin-vvt', name: 'VVT', description: 'Verarbeitungsverzeichnis Art. 30', category: 'dsgvo', icon: '📋', url: '/dsgvo/vvt' },
{ id: 'admin-dsfa', name: 'DSFA', description: 'Datenschutz-Folgenabschaetzung', category: 'dsgvo', icon: '⚖️', url: '/dsgvo/dsfa' },
{ id: 'admin-tom', name: 'TOMs', description: 'Technische & Org. Massnahmen', category: 'dsgvo', icon: '🛡️', url: '/dsgvo/tom' },
{ id: 'admin-loeschfristen', name: 'Loeschfristen', description: 'Aufbewahrung & Deadlines', category: 'dsgvo', icon: '🗑️', url: '/dsgvo/loeschfristen' },
{ id: 'admin-advisory-board', name: 'Advisory Board', description: 'KI-Use-Case Pruefung', category: 'dsgvo', icon: '🧑‍⚖️', url: '/dsgvo/advisory-board' },
{ id: 'admin-escalations', name: 'Eskalations-Queue', description: 'DSB Review & Freigabe', category: 'dsgvo', icon: '🚨', url: '/dsgvo/escalations' },
// === COMPLIANCE (Purple #9333ea) ===
{ id: 'admin-compliance-hub', name: 'Compliance Hub', description: 'Zentrales GRC Dashboard', category: 'compliance', icon: '✅', url: '/compliance/hub' },
{ id: 'admin-audit-checklist', name: 'Audit Checkliste', description: '476 Anforderungen pruefen', category: 'compliance', icon: '📋', url: '/compliance/audit-checklist' },
{ id: 'admin-requirements', name: 'Requirements', description: '558+ aus 19 Verordnungen', category: 'compliance', icon: '📜', url: '/compliance/requirements' },
{ id: 'admin-controls', name: 'Controls', description: '474 Control-Mappings', category: 'compliance', icon: '🎛️', url: '/compliance/controls' },
{ id: 'admin-evidence', name: 'Evidence', description: 'Nachweise & Dokumentation', category: 'compliance', icon: '📎', url: '/compliance/evidence' },
{ id: 'admin-risks', name: 'Risiken', description: 'Risk Matrix & Register', category: 'compliance', icon: '⚠️', url: '/compliance/risks' },
{ id: 'admin-audit-report', name: 'Audit Report', description: 'PDF Audit-Berichte', category: 'compliance', icon: '📊', url: '/compliance/audit-report' },
{ id: 'admin-modules', name: 'Service Registry', description: '30+ Service-Module', category: 'compliance', icon: '🔧', url: '/compliance/modules' },
{ id: 'admin-dsms', name: 'DSMS', description: 'Datenschutz-Management-System', category: 'compliance', icon: '🏛️', url: '/compliance/dsms' },
{ id: 'admin-compliance-workflow', name: 'Workflow', description: 'Freigabe-Workflows', category: 'compliance', icon: '🔄', url: '/compliance/workflow' },
{ id: 'admin-source-policy', name: 'Quellen-Policy', description: 'Datenquellen & Compliance', category: 'compliance', icon: '📚', url: '/compliance/source-policy' },
{ id: 'admin-ai-act', name: 'EU-AI-Act', description: 'KI-Risikoklassifizierung', category: 'compliance', icon: '🤖', url: '/compliance/ai-act' },
{ id: 'admin-obligations', name: 'Pflichten', description: 'NIS2, DSGVO, AI Act', category: 'compliance', icon: '⚡', url: '/compliance/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>
)
}

View File

@@ -19,7 +19,8 @@ import {
Eye,
Download,
AlertTriangle,
Info
Info,
Container
} from 'lucide-react'
interface WorkflowStep {
@@ -88,6 +89,14 @@ export default function WorkflowPage() {
},
{
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',
@@ -158,8 +167,8 @@ export default function WorkflowPage() {
<span>Browser für Frontend-Tests</span>
</li>
<li className="flex items-center gap-2">
<CheckCircle2 className="h-4 w-4 text-green-500" />
<span>Tägliches Backup (automatisch)</span>
<AlertTriangle className="h-4 w-4 text-amber-500" />
<span>Backup manuell (MacBook nachts aus)</span>
</li>
</ul>
</div>
@@ -192,6 +201,10 @@ export default function WorkflowPage() {
<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>
@@ -314,17 +327,18 @@ export default function WorkflowPage() {
Backup & Sicherheit
</h2>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<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">Tägliches Backup</h3>
<h3 className="font-semibold text-green-900">Mac Mini (Auto)</h3>
</div>
<ul className="space-y-2 text-sm text-green-800">
<li> Läuft automatisch um 02:00 Uhr</li>
<li> Git Repository wird synchronisiert</li>
<li> PostgreSQL-Dump wird erstellt</li>
<li> Backups werden 7 Tage aufbewahrt</li>
<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">
@@ -333,10 +347,29 @@ export default function WorkflowPage() {
</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">Manuelles Backup</h3>
<h3 className="font-semibold text-blue-900">Backup Script</h3>
</div>
<p className="text-sm text-blue-800 mb-3">
Backup jederzeit manuell starten:
@@ -490,6 +523,118 @@ export default function WorkflowPage() {
</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">

View File

@@ -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)
}

View File

@@ -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>
)
}

View File

@@ -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>
)
}

View File

@@ -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>
)
}

View File

@@ -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>
)
}

View File

@@ -0,0 +1,76 @@
'use client'
import { Suspense } from 'react'
import { PagePurpose } from '@/components/common/PagePurpose'
import { getModuleByHref } from '@/lib/navigation'
import { CompanionDashboard } from '@/components/companion/CompanionDashboard'
import { GraduationCap } from 'lucide-react'
function LoadingFallback() {
return (
<div className="space-y-6">
{/* Header Skeleton */}
<div className="flex items-center justify-between">
<div className="h-12 w-80 bg-slate-200 rounded-xl animate-pulse" />
<div className="flex gap-2">
{[1, 2, 3, 4].map((i) => (
<div key={i} className="h-10 w-10 bg-slate-200 rounded-lg animate-pulse" />
))}
</div>
</div>
{/* Phase Timeline Skeleton */}
<div className="bg-white border border-slate-200 rounded-xl p-6">
<div className="h-4 w-24 bg-slate-200 rounded mb-4 animate-pulse" />
<div className="flex gap-4">
{[1, 2, 3, 4, 5].map((i) => (
<div key={i} className="flex items-center gap-2">
<div className="w-10 h-10 bg-slate-200 rounded-full animate-pulse" />
{i < 5 && <div className="w-8 h-1 bg-slate-200 animate-pulse" />}
</div>
))}
</div>
</div>
{/* Stats Skeleton */}
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
{[1, 2, 3, 4].map((i) => (
<div key={i} className="bg-white border border-slate-200 rounded-xl p-4">
<div className="h-4 w-16 bg-slate-200 rounded mb-2 animate-pulse" />
<div className="h-8 w-12 bg-slate-200 rounded animate-pulse" />
</div>
))}
</div>
{/* Content Skeleton */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
<div className="bg-white border border-slate-200 rounded-xl p-6 h-64 animate-pulse" />
<div className="bg-white border border-slate-200 rounded-xl p-6 h-64 animate-pulse" />
</div>
</div>
)
}
export default function CompanionPage() {
const moduleInfo = getModuleByHref('/education/companion')
return (
<div className="space-y-6">
{/* Page Purpose Header */}
{moduleInfo && (
<PagePurpose
title={moduleInfo.module.name}
purpose={moduleInfo.module.purpose}
audience={moduleInfo.module.audience}
collapsible={true}
defaultCollapsed={true}
/>
)}
{/* Main Companion Dashboard */}
<Suspense fallback={<LoadingFallback />}>
<CompanionDashboard />
</Suspense>
</div>
)
}

View File

@@ -5,9 +5,10 @@
* Bildungsquellen und Crawler-Verwaltung
*/
import { useState } from 'react'
import { useState, useCallback } from 'react'
import { PagePurpose } from '@/components/common/PagePurpose'
import { Search, Database, RefreshCw, ExternalLink, FileText, BookOpen } from 'lucide-react'
import { Search, Database, RefreshCw, ExternalLink, FileText, BookOpen, FolderOpen } from 'lucide-react'
import { DokumenteTab } from '@/components/education/DokumenteTab'
interface DataSource {
id: string
@@ -42,7 +43,12 @@ const DATA_SOURCES: DataSource[] = [
export default function EduSearchPage() {
const [searchQuery, setSearchQuery] = useState('')
const [activeTab, setActiveTab] = useState<'search' | 'sources' | 'crawler'>('search')
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">
@@ -95,6 +101,22 @@ export default function EduSearchPage() {
<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 ${
@@ -162,6 +184,11 @@ export default function EduSearchPage() {
</div>
)}
{/* Documents Tab */}
{activeTab === 'documents' && (
<DokumenteTab onDocumentCountChange={handleDocumentCountChange} />
)}
{/* Sources Tab */}
{activeTab === 'sources' && (
<div className="bg-white rounded-xl border border-slate-200 overflow-hidden">
@@ -281,9 +308,9 @@ export default function EduSearchPage() {
<div className="font-medium text-slate-900">Zeugnisse-Crawler</div>
<div className="text-sm text-slate-500">Zeugnis-Strukturen verwalten</div>
</a>
<a href="/education/training" className="block p-3 bg-white rounded-lg hover:shadow-md transition-shadow">
<div className="font-medium text-slate-900">Training</div>
<div className="text-sm text-slate-500">Schulungsmodule verwalten</div>
<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>

File diff suppressed because it is too large Load Diff

View File

@@ -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>
)
}

View File

@@ -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>
)
}

View File

@@ -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>
)
}

View File

@@ -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>
)
}

View File

@@ -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>
)
}

View File

@@ -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>
)
}

View File

@@ -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

View File

@@ -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',
}

View File

@@ -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>
)
}

View File

@@ -12,6 +12,7 @@
import { useState, useEffect, useCallback } from 'react'
import { PagePurpose } from '@/components/common/PagePurpose'
import { DevOpsPipelineSidebarResponsive } from '@/components/infrastructure/DevOpsPipelineSidebar'
// ============================================================================
// Types
@@ -364,6 +365,9 @@ export default function CICDPage() {
defaultCollapsed={true}
/>
{/* DevOps Pipeline Sidebar */}
<DevOpsPipelineSidebarResponsive currentTool="ci-cd" />
{/* Messages */}
{error && (
<div className="bg-red-50 border border-red-200 rounded-xl p-4 mb-6">

View File

@@ -16,6 +16,7 @@
import { useState, useEffect } from 'react'
import Link from 'next/link'
import { PagePurpose } from '@/components/common/PagePurpose'
import { DevOpsPipelineSidebarResponsive } from '@/components/infrastructure/DevOpsPipelineSidebar'
interface Component {
type: string
@@ -71,6 +72,7 @@ const INFRASTRUCTURE_COMPONENTS: Component[] = [
// ===== SECURITY =====
{ type: 'service', name: 'HashiCorp Vault', version: '1.15', category: 'security', port: '8200', description: 'Secrets Management', license: 'BUSL-1.1', sourceUrl: 'https://github.com/hashicorp/vault' },
{ type: 'service', name: 'Keycloak', version: '23.0', category: 'security', port: '8180', description: 'Identity Provider (SSO/OIDC)', license: 'Apache-2.0', sourceUrl: 'https://github.com/keycloak/keycloak' },
{ type: 'service', name: 'NetBird', version: '0.64.5', category: 'security', port: '-', description: 'Zero-Trust Mesh VPN (WireGuard-basiert)', license: 'BSD-3-Clause', sourceUrl: 'https://github.com/netbirdio/netbird' },
// ===== COMMUNICATION =====
{ type: 'service', name: 'Matrix Synapse', version: 'latest', category: 'communication', port: '8008', description: 'E2EE Messenger Server', license: 'AGPL-3.0', sourceUrl: 'https://github.com/element-hq/synapse' },
@@ -110,6 +112,7 @@ const INFRASTRUCTURE_COMPONENTS: Component[] = [
// ===== CI/CD & VERSION CONTROL =====
{ type: 'service', name: 'Woodpecker CI', version: '2.x', category: 'cicd', port: '8082', description: 'Self-hosted CI/CD Pipeline (Drone Fork)', license: 'Apache-2.0', sourceUrl: 'https://github.com/woodpecker-ci/woodpecker' },
{ type: 'service', name: 'Gitea', version: '1.21', category: 'cicd', port: '3003', description: 'Self-hosted Git Service', license: 'MIT', sourceUrl: 'https://github.com/go-gitea/gitea' },
{ type: 'service', name: 'Dokploy', version: '0.26.7', category: 'cicd', port: '3000', description: 'Self-hosted PaaS (Vercel/Heroku Alternative)', license: 'Apache-2.0', sourceUrl: 'https://github.com/Dokploy/dokploy' },
// ===== DEVELOPMENT =====
{ type: 'service', name: 'Mailpit', version: 'latest', category: 'development', port: '8025/1025', description: 'E-Mail Testing (SMTP Catch-All)', license: 'MIT', sourceUrl: 'https://github.com/axllent/mailpit' },
@@ -216,6 +219,11 @@ const NODE_PACKAGES: Component[] = [
{ type: 'library', name: 'Material Design Icons', version: 'latest', category: 'nodejs', description: 'Icon-System (Companion UI, Studio)', license: 'Apache-2.0', sourceUrl: 'https://github.com/google/material-design-icons' },
{ type: 'library', name: 'Recharts', version: '2.12', category: 'nodejs', description: 'React Charts (Compliance Dashboard)', license: 'MIT', sourceUrl: 'https://github.com/recharts/recharts' },
{ type: 'library', name: 'React Flow', version: '11.x', category: 'nodejs', description: 'Node-basierte Flow-Diagramme (Screen Flow)', license: 'MIT', sourceUrl: 'https://github.com/xyflow/xyflow' },
{ type: 'library', name: 'Playwright', version: '1.50', category: 'nodejs', description: 'E2E Testing Framework (SDK Tests)', license: 'Apache-2.0', sourceUrl: 'https://github.com/microsoft/playwright' },
{ type: 'library', name: 'Vitest', version: '4.x', category: 'nodejs', description: 'Unit Testing Framework', license: 'MIT', sourceUrl: 'https://github.com/vitest-dev/vitest' },
{ type: 'library', name: 'jsPDF', version: '4.x', category: 'nodejs', description: 'PDF Generation (SDK Export)', license: 'MIT', sourceUrl: 'https://github.com/parallax/jsPDF' },
{ type: 'library', name: 'JSZip', version: '3.x', category: 'nodejs', description: 'ZIP File Creation (SDK Export)', license: 'MIT/GPL-3.0', sourceUrl: 'https://github.com/Stuk/jszip' },
{ type: 'library', name: 'Lucide React', version: '0.468', category: 'nodejs', description: 'Icon Library', license: 'ISC', sourceUrl: 'https://github.com/lucide-icons/lucide' },
]
// Unity packages (Breakpilot Drive game engine)
@@ -422,6 +430,9 @@ export default function SBOMPage() {
defaultCollapsed={true}
/>
{/* DevOps Pipeline Sidebar */}
<DevOpsPipelineSidebarResponsive currentTool="sbom" />
{/* Wizard Link */}
<div className="mb-6 flex justify-end">
<Link

View File

@@ -9,6 +9,7 @@
import { useEffect, useState, useCallback } from 'react'
import { PagePurpose } from '@/components/common/PagePurpose'
import { DevOpsPipelineSidebarResponsive } from '@/components/infrastructure/DevOpsPipelineSidebar'
interface ToolStatus {
name: string
@@ -304,6 +305,9 @@ export default function SecurityDashboardPage() {
defaultCollapsed={true}
/>
{/* DevOps Pipeline Sidebar */}
<DevOpsPipelineSidebarResponsive currentTool="security" />
{/* Header with Status */}
<div className="bg-white rounded-xl border border-slate-200 p-6 mb-6">
<div className="flex justify-between items-center mb-6">

View File

@@ -3,18 +3,22 @@
/**
* Test Dashboard - Zentrales Test-Registry
*
* Aggregiert alle 195+ Tests aus allen Services:
* Aggregiert alle 280+ Tests aus allen Services:
* - Go Unit Tests (~57)
* - Python Tests (~50)
* - BQAS Golden (97)
* - BQAS RAG (~20)
* - TypeScript Jest (~8)
* - SDK Vitest Unit Tests (~43)
* - SDK Playwright E2E (~25)
* - E2E Playwright (~5)
*/
import React, { useState, useEffect, useCallback, useRef } from 'react'
import Link from 'next/link'
import { PagePurpose } from '@/components/common/PagePurpose'
import { DevOpsPipelineSidebarResponsive } from '@/components/infrastructure/DevOpsPipelineSidebar'
import type { LLMRoutingOption } from '@/types/infrastructure-modules'
import type {
ServiceTestInfo,
TestRegistryStats,
@@ -344,6 +348,7 @@ function FrameworkDistribution({ data }: { data: Record<string, number> }) {
go_test: 'Go Tests',
pytest: 'Python (pytest)',
jest: 'Jest (TS)',
vitest: 'Vitest (SDK)',
playwright: 'Playwright (E2E)',
bqas_golden: 'BQAS Golden',
bqas_rag: 'BQAS RAG',
@@ -354,6 +359,7 @@ function FrameworkDistribution({ data }: { data: Record<string, number> }) {
go_test: 'bg-cyan-500',
pytest: 'bg-yellow-500',
jest: 'bg-blue-500',
vitest: 'bg-orange-500',
playwright: 'bg-purple-500',
bqas_golden: 'bg-emerald-500',
bqas_rag: 'bg-teal-500',
@@ -454,15 +460,16 @@ function GuideTab() {
Was ist das Test Dashboard?
</h2>
<p className="text-slate-700 leading-relaxed">
Das <strong>Test Dashboard</strong> ist die zentrale Uebersicht fuer alle 195+ Tests im Breakpilot-System.
Das <strong>Test Dashboard</strong> ist die zentrale Uebersicht fuer alle 260+ Tests im Breakpilot-System.
Es aggregiert Tests aus verschiedenen Services (Go, Python, TypeScript) ohne diese physisch zu migrieren.
Tests bleiben an ihren konventionellen Orten, werden aber hier zentral ueberwacht und ausgefuehrt.
Seit 2026-02 inklusive AI Compliance SDK Unit Tests (Vitest) und E2E Tests (Playwright).
</p>
</div>
<div className="bg-white rounded-xl border border-slate-200 p-6">
<h3 className="text-lg font-semibold text-slate-900 mb-4">Test-Kategorien</h3>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
<div className="p-4 bg-cyan-50 rounded-lg border border-cyan-200">
<div className="flex items-center gap-2 mb-2">
<span className="text-xl">🐹</span>
@@ -508,41 +515,70 @@ function GuideTab() {
Website Unit Tests fuer React-Komponenten
</p>
</div>
<div className="p-4 bg-orange-50 rounded-lg border border-orange-200">
<div className="flex items-center gap-2 mb-2">
<span className="text-xl"></span>
<h4 className="font-medium text-orange-800">SDK Vitest (~43)</h4>
</div>
<p className="text-sm text-orange-700">
AI Compliance SDK Unit Tests: Types, Export, Components, Reducer
</p>
</div>
<div className="p-4 bg-purple-50 rounded-lg border border-purple-200">
<div className="flex items-center gap-2 mb-2">
<span className="text-xl">🎭</span>
<h4 className="font-medium text-purple-800">E2E Playwright (~5)</h4>
<h4 className="font-medium text-purple-800">SDK Playwright (~25)</h4>
</div>
<p className="text-sm text-purple-700">
SDK E2E Tests: Navigation, Workflow, Command Bar, Export
</p>
</div>
<div className="p-4 bg-slate-50 rounded-lg border border-slate-200">
<div className="flex items-center gap-2 mb-2">
<span className="text-xl">🌐</span>
<h4 className="font-medium text-slate-800">Website E2E (~5)</h4>
</div>
<p className="text-sm text-slate-700">
End-to-End Tests fuer kritische User Flows
</p>
</div>
<div className="p-4 bg-indigo-50 rounded-lg border border-indigo-200">
<div className="flex items-center gap-2 mb-2">
<span className="text-xl">🔗</span>
<h4 className="font-medium text-indigo-800">Integration Tests (~15)</h4>
</div>
<p className="text-sm text-indigo-700">
Docker Compose basierte E2E-Tests mit Backend, Consent-Service, DB
</p>
</div>
</div>
</div>
<div className="bg-white rounded-xl border border-slate-200 p-6">
<h3 className="text-lg font-semibold text-slate-900 mb-4">Architektur</h3>
<pre className="bg-slate-50 p-4 rounded-lg text-xs overflow-x-auto">
{`┌────────────────────────────────────────────────────────────┐
│ Admin-v2 Test Dashboard │
│ /infrastructure/tests │
├────────────────────────────────────────────────────────────┤
│ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐
│ │ Unit Tests │ │ Integration │ │ BQAS │
│ │ (Go, Py) │ │ Tests │ │ (LLM/RAG)
│ └──────────────┘ └──────────────┘ └──────────────┘
│ │
▼ ▼
│ ┌────────────────────────────────────────────────────┐
│ │ Test Registry API
│ │ /backend/api/tests/registry.py
│ └────────────────────────────────────────────────────┘
└────────────────────────────────────────────────────────────┘
{`┌────────────────────────────────────────────────────────────────────
Admin-v2 Test Dashboard
/infrastructure/tests
├────────────────────────────────────────────────────────────────────
│ ┌────────────┐ ┌────────────┐ ┌────────────┐ ┌─────────────
│ │ Unit Tests │ │ SDK Tests │ │ BQAS E2E Tests │
│ │ (Go, Py) │ │ (Vitest) │ │ (LLM/RAG) │ │ (Playwright)│
│ └────────────┘ └────────────┘ └────────────┘ └─────────────
│ │
│ ▼
│ ┌──────────────────────────────────────────────────────────────┐ │
│ │ Test Registry API
│ │ /backend/api/tests/registry.py
│ └──────────────────────────────────────────────────────────────┘ │
└────────────────────────────────────────────────────────────────────
Tests bleiben wo sie sind:
- /consent-service/internal/**/*_test.go
- /backend/tests/test_*.py
- /voice-service/tests/bqas/`}
- /voice-service/tests/bqas/
- /admin-v2/components/sdk/__tests__/*.test.ts (Vitest)
- /admin-v2/e2e/specs/*.spec.ts (Playwright)`}
</pre>
</div>
@@ -792,6 +828,8 @@ function BacklogTab({
const [filterStatus, setFilterStatus] = useState<string>('open')
const [filterService, setFilterService] = useState<string>('all')
const [filterPriority, setFilterPriority] = useState<string>('all')
const [llmAutoAnalysis, setLlmAutoAnalysis] = useState<boolean>(true)
const [llmRouting, setLlmRouting] = useState<LLMRoutingOption>('smart_routing')
// Nutze PostgreSQL-Backlog wenn verfuegbar, sonst Legacy
const items = usePostgres && backlogItems ? backlogItems : failedTests
@@ -881,6 +919,93 @@ function BacklogTab({
</div>
)}
{/* LLM Analysis Toggle */}
<div className="bg-gradient-to-r from-violet-50 to-purple-50 border border-violet-200 rounded-xl p-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="w-10 h-10 bg-violet-100 rounded-lg flex items-center justify-center">
<svg className="w-5 h-5 text-violet-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9.75 17L9 20l-1 1h8l-1-1-.75-3M3 13h18M5 17h14a2 2 0 002-2V5a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" />
</svg>
</div>
<div>
<h4 className="font-medium text-slate-800">Automatische LLM-Analyse</h4>
<p className="text-xs text-slate-500">KI-gestuetzte Fix-Vorschlaege fuer Backlog-Eintraege</p>
</div>
</div>
<label className="relative inline-flex items-center cursor-pointer">
<input
type="checkbox"
checked={llmAutoAnalysis}
onChange={(e) => setLlmAutoAnalysis(e.target.checked)}
className="sr-only peer"
/>
<div className="w-11 h-6 bg-slate-200 peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-violet-300 rounded-full peer peer-checked:after:translate-x-full rtl:peer-checked:after:-translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:start-[2px] after:bg-white after:border-slate-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-violet-600"></div>
</label>
</div>
{llmAutoAnalysis && (
<div className="mt-4 pt-4 border-t border-violet-200">
<p className="text-xs text-slate-600 mb-3">LLM-Routing Strategie:</p>
<div className="flex flex-wrap gap-2">
<label className={`flex items-center gap-2 px-3 py-2 rounded-lg border cursor-pointer transition-colors ${
llmRouting === 'local_only'
? 'bg-violet-100 border-violet-300 text-violet-800'
: 'bg-white border-slate-200 text-slate-600 hover:bg-slate-50'
}`}>
<input
type="radio"
name="llm-routing"
value="local_only"
checked={llmRouting === 'local_only'}
onChange={() => setLlmRouting('local_only')}
className="sr-only"
/>
<span className="text-sm font-medium">Nur lokales 32B LLM</span>
<span className="text-xs px-1.5 py-0.5 bg-emerald-100 text-emerald-700 rounded">DSGVO</span>
</label>
<label className={`flex items-center gap-2 px-3 py-2 rounded-lg border cursor-pointer transition-colors ${
llmRouting === 'claude_preferred'
? 'bg-violet-100 border-violet-300 text-violet-800'
: 'bg-white border-slate-200 text-slate-600 hover:bg-slate-50'
}`}>
<input
type="radio"
name="llm-routing"
value="claude_preferred"
checked={llmRouting === 'claude_preferred'}
onChange={() => setLlmRouting('claude_preferred')}
className="sr-only"
/>
<span className="text-sm font-medium">Claude bevorzugt</span>
<span className="text-xs px-1.5 py-0.5 bg-blue-100 text-blue-700 rounded">Qualitaet</span>
</label>
<label className={`flex items-center gap-2 px-3 py-2 rounded-lg border cursor-pointer transition-colors ${
llmRouting === 'smart_routing'
? 'bg-violet-100 border-violet-300 text-violet-800'
: 'bg-white border-slate-200 text-slate-600 hover:bg-slate-50'
}`}>
<input
type="radio"
name="llm-routing"
value="smart_routing"
checked={llmRouting === 'smart_routing'}
onChange={() => setLlmRouting('smart_routing')}
className="sr-only"
/>
<span className="text-sm font-medium">Smart Routing</span>
<span className="text-xs px-1.5 py-0.5 bg-amber-100 text-amber-700 rounded">Empfohlen</span>
</label>
</div>
<p className="text-xs text-slate-500 mt-2">
{llmRouting === 'local_only' && 'Alle Analysen werden mit Qwen2.5-32B lokal durchgefuehrt. Keine Daten verlassen den Server.'}
{llmRouting === 'claude_preferred' && 'Verwendet Claude fuer beste Fix-Qualitaet. Nur Code-Snippets werden uebertragen.'}
{llmRouting === 'smart_routing' && 'Privacy Classifier entscheidet automatisch: Sensitive Daten → lokal, Code → Claude.'}
</p>
</div>
)}
</div>
{/* Filter */}
<div className="flex flex-wrap gap-4 items-center">
<div>
@@ -1066,18 +1191,21 @@ export default function TestDashboardPage() {
{ service: 'klausur-service', display_name: 'Klausur Service', port: 8086, language: 'python', total_tests: 8, passed_tests: 8, failed_tests: 0, skipped_tests: 0, pass_rate: 100, coverage_percent: 71.2, last_run: new Date().toISOString(), status: 'passed' },
{ service: 'billing-service', display_name: 'Billing Service', port: 8082, language: 'go', total_tests: 5, passed_tests: 5, failed_tests: 0, skipped_tests: 0, pass_rate: 100, coverage_percent: 78.5, last_run: new Date().toISOString(), status: 'passed' },
{ service: 'school-service', display_name: 'School Service', port: 8084, language: 'go', total_tests: 6, passed_tests: 6, failed_tests: 0, skipped_tests: 0, pass_rate: 100, coverage_percent: 81.4, last_run: new Date().toISOString(), status: 'passed' },
{ service: 'sdk-unit', display_name: 'SDK Unit Tests (Vitest)', port: undefined, language: 'typescript', total_tests: 43, passed_tests: 43, failed_tests: 0, skipped_tests: 0, pass_rate: 100, coverage_percent: 85.2, last_run: new Date().toISOString(), status: 'passed' },
{ service: 'sdk-e2e', display_name: 'SDK E2E Tests (Playwright)', port: undefined, language: 'typescript', total_tests: 25, passed_tests: 25, failed_tests: 0, skipped_tests: 0, pass_rate: 100, coverage_percent: undefined, last_run: new Date().toISOString(), status: 'passed' },
{ service: 'integration-tests', display_name: 'Integration Tests', port: undefined, language: 'python', total_tests: 15, passed_tests: 15, failed_tests: 0, skipped_tests: 0, pass_rate: 100, coverage_percent: undefined, last_run: new Date().toISOString(), status: 'passed' },
]
const DEMO_STATS: TestRegistryStats = {
total_tests: 195,
total_passed: 180,
total_tests: 278,
total_passed: 263,
total_failed: 15,
total_skipped: 0,
overall_pass_rate: 92.3,
average_coverage: 76.8,
services_count: 8,
by_category: { unit: 75, bqas: 117, e2e: 5 },
by_framework: { go_test: 57, pytest: 53, bqas_golden: 97, bqas_rag: 20, jest: 8, playwright: 5 },
overall_pass_rate: 94.6,
average_coverage: 78.5,
services_count: 11,
by_category: { unit: 118, bqas: 117, e2e: 30, integration: 15 },
by_framework: { go_test: 57, pytest: 68, bqas_golden: 97, bqas_rag: 20, jest: 8, vitest: 43, playwright: 30 },
}
// Fetch data
@@ -1460,21 +1588,25 @@ export default function TestDashboardPage() {
<PagePurpose
title="Test Dashboard"
purpose="Zentrales Dashboard fuer alle 195+ Tests. Aggregiert Unit Tests (Go, Python), Integration Tests, E2E (Playwright) und BQAS Quality Tests aus allen Services ohne physische Migration."
purpose="Zentrales Dashboard fuer alle 260+ Tests. Aggregiert Unit Tests (Go, Python), SDK Tests (Vitest), E2E Tests (Playwright) und BQAS Quality Tests aus allen Services ohne physische Migration."
audience={['Entwickler', 'QA', 'DevOps']}
architecture={{
services: ['Python Backend (Port 8000)', 'Voice Service (Port 8091)'],
databases: ['PostgreSQL'],
services: ['Python Backend (Port 8000)', 'Voice Service (Port 8091)', 'SDK Backend (Port 8085)'],
databases: ['PostgreSQL', 'Qdrant'],
}}
relatedPages={[
{ name: 'BQAS Dashboard', href: '/ai/test-quality', description: 'Detaillierte LLM-Qualitaetsmetriken' },
{ name: 'CI/CD', href: '/infrastructure/ci-cd', description: 'Pipelines und Deployments' },
{ name: 'Security', href: '/infrastructure/security', description: 'DevSecOps Dashboard' },
{ name: 'Developer Portal', href: '/developers', description: 'SDK & API Dokumentation' },
]}
collapsible={true}
defaultCollapsed={true}
/>
{/* DevOps Pipeline Sidebar */}
<DevOpsPipelineSidebarResponsive currentTool="tests" />
{error && (
<div className="p-4 bg-red-50 border border-red-200 rounded-lg text-red-700 flex items-center gap-3">
<svg className="w-5 h-5 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">

View File

@@ -1,6 +1,40 @@
'use client'
import { useMemo, useState } from 'react'
import { useMemo } from 'react'
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card'
import {
Accordion,
AccordionContent,
AccordionItem,
AccordionTrigger,
} from '@/components/ui/accordion'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { ScrollArea } from '@/components/ui/scroll-area'
import { Separator } from '@/components/ui/separator'
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from '@/components/ui/tooltip'
import {
Database,
FileText,
List,
Table,
AlertTriangle,
Shield,
Clock,
Users,
Globe,
} from 'lucide-react'
import {
DataPoint,
DataPointCategory,
@@ -22,70 +56,79 @@ const PLACEHOLDERS = [
{
placeholder: '[DATENPUNKTE_TABLE]',
label: { de: 'Tabelle', en: 'Table' },
description: { de: 'Markdown-Tabelle mit allen Datenpunkten', en: 'Markdown table with all data points' },
description: { de: 'Fügt eine Markdown-Tabelle mit allen Datenpunkten ein', en: 'Inserts a markdown table with all data points' },
icon: Table,
},
{
placeholder: '[DATENPUNKTE_LIST]',
label: { de: 'Liste', en: 'List' },
description: { de: 'Kommaseparierte Liste der Namen', en: 'Comma-separated list of names' },
description: { de: 'Kommaseparierte Liste der Datenpunkt-Namen', en: 'Comma-separated list of data point names' },
icon: List,
},
{
placeholder: '[VERARBEITUNGSZWECKE]',
label: { de: 'Zwecke', en: 'Purposes' },
description: { de: 'Alle Verarbeitungszwecke', en: 'All processing purposes' },
description: { de: 'Alle Verarbeitungszwecke (dedupliziert)', en: 'All processing purposes (deduplicated)' },
icon: FileText,
},
{
placeholder: '[RECHTSGRUNDLAGEN]',
label: { de: 'Rechtsgrundlagen', en: 'Legal Bases' },
description: { de: 'DSGVO-Artikel', en: 'GDPR articles' },
description: { de: 'Verwendete DSGVO-Artikel', en: 'Used GDPR articles' },
icon: Shield,
},
{
placeholder: '[SPEICHERFRISTEN]',
label: { de: 'Speicherfristen', en: 'Retention' },
description: { de: 'Fristen nach Kategorie', en: 'Periods by category' },
description: { de: 'Fristen gruppiert nach Kategorie', en: 'Periods grouped by category' },
icon: Clock,
},
{
placeholder: '[EMPFAENGER]',
label: { de: 'Empfänger', en: 'Recipients' },
description: { de: 'Liste aller Drittparteien', en: 'List of third parties' },
description: { de: 'Liste aller Drittparteien', en: 'List of all third parties' },
icon: Users,
},
{
placeholder: '[BESONDERE_KATEGORIEN]',
label: { de: 'Art. 9', en: 'Art. 9' },
description: { de: 'Abschnitt für sensible Daten', en: 'Section for sensitive data' },
label: { de: 'Art. 9 Abschnitt', en: 'Art. 9 Section' },
description: { de: 'DSGVO-konformer Abschnitt für sensible Daten', en: 'GDPR-compliant section for sensitive data' },
icon: AlertTriangle,
},
{
placeholder: '[DRITTLAND_TRANSFERS]',
label: { de: 'Drittländer', en: 'Third Countries' },
description: { de: 'Datenübermittlung außerhalb EU', en: 'Data transfers outside EU' },
description: { de: 'Abschnitt zu Datenübermittlung außerhalb EU', en: 'Section about data transfers outside EU' },
icon: Globe,
},
]
/**
* Risiko-Badge Farben
* Risiko-Badge Varianten mapping
*/
function getRiskBadgeColor(riskLevel: RiskLevel): string {
function getRiskBadgeVariant(riskLevel: RiskLevel): 'default' | 'secondary' | 'destructive' | 'outline' {
switch (riskLevel) {
case 'HIGH':
return 'bg-red-100 text-red-700 border-red-200'
return 'destructive'
case 'MEDIUM':
return 'bg-yellow-100 text-yellow-700 border-yellow-200'
return 'secondary'
case 'LOW':
default:
return 'bg-green-100 text-green-700 border-green-200'
return 'outline'
}
}
/**
* DataPointsPreview Komponente
*
* Zeigt eine Vorschau der ausgewählten Einwilligungen-Datenpunkte im Dokumentengenerator.
* Ermöglicht das schnelle Einfügen von Platzhaltern.
*/
export function DataPointsPreview({
dataPoints,
onInsertPlaceholder,
language = 'de',
}: DataPointsPreviewProps) {
const [expandedCategories, setExpandedCategories] = useState<string[]>([])
// Gruppiere Datenpunkte nach Kategorie
const byCategory = useMemo(() => {
return dataPoints.reduce((acc, dp) => {
@@ -101,15 +144,21 @@ export function DataPointsPreview({
const stats = useMemo(() => {
const riskCounts: Record<RiskLevel, number> = { LOW: 0, MEDIUM: 0, HIGH: 0 }
let specialCategoryCount = 0
let explicitConsentCount = 0
const recipients = new Set<string>()
dataPoints.forEach(dp => {
riskCounts[dp.riskLevel]++
if (dp.isSpecialCategory) specialCategoryCount++
if (dp.requiresExplicitConsent) explicitConsentCount++
dp.thirdPartyRecipients?.forEach(r => recipients.add(r))
})
return {
riskCounts,
specialCategoryCount,
explicitConsentCount,
recipientCount: recipients.size,
categoryCount: Object.keys(byCategory).length,
}
}, [dataPoints, byCategory])
@@ -123,173 +172,195 @@ export function DataPointsPreview({
})
}, [byCategory])
const toggleCategory = (category: string) => {
setExpandedCategories(prev =>
prev.includes(category)
? prev.filter(c => c !== category)
: [...prev, category]
)
}
if (dataPoints.length === 0) {
return (
<div className="bg-white rounded-xl border border-gray-200 p-6">
<h4 className="font-semibold text-gray-900 flex items-center gap-2 mb-3">
<svg className="w-5 h-5 text-purple-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>
{language === 'de' ? 'Einwilligungen' : 'Consents'}
</h4>
<p className="text-sm text-gray-500">
{language === 'de'
? 'Keine Datenpunkte ausgewählt. Wählen Sie Datenpunkte im Einwilligungs-Schritt aus.'
: 'No data points selected. Select data points in the consent step.'}
</p>
</div>
<Card className="h-full">
<CardHeader className="pb-3">
<CardTitle className="text-base flex items-center gap-2">
<Database className="h-4 w-4" />
{language === 'de' ? 'Einwilligungen' : 'Consents'}
</CardTitle>
</CardHeader>
<CardContent>
<p className="text-sm text-muted-foreground">
{language === 'de'
? 'Keine Datenpunkte ausgewählt. Wählen Sie Datenpunkte im Einwilligungs-Schritt aus, um sie hier zu sehen.'
: 'No data points selected. Select data points in the consent step to see them here.'}
</p>
</CardContent>
</Card>
)
}
return (
<div className="bg-white rounded-xl border border-gray-200 p-6 h-full flex flex-col">
{/* Header */}
<div className="mb-4">
<h4 className="font-semibold text-gray-900 flex items-center gap-2">
<svg className="w-5 h-5 text-purple-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>
<Card className="h-full flex flex-col">
<CardHeader className="pb-3">
<CardTitle className="text-base flex items-center gap-2">
<Database className="h-4 w-4" />
{language === 'de' ? 'Einwilligungen' : 'Consents'}
</h4>
<p className="text-sm text-gray-500 mt-1">
</CardTitle>
<CardDescription>
{dataPoints.length} {language === 'de' ? 'Datenpunkte aus' : 'data points from'}{' '}
{stats.categoryCount} {language === 'de' ? 'Kategorien' : 'categories'}
</p>
</div>
</CardDescription>
</CardHeader>
{/* Statistik-Badges */}
<div className="flex flex-wrap gap-2 mb-4">
{stats.riskCounts.HIGH > 0 && (
<span className="px-2 py-1 text-xs rounded-full bg-red-100 text-red-700">
{stats.riskCounts.HIGH} {language === 'de' ? 'Hoch' : 'High'}
</span>
)}
{stats.riskCounts.MEDIUM > 0 && (
<span className="px-2 py-1 text-xs rounded-full bg-yellow-100 text-yellow-700">
{stats.riskCounts.MEDIUM} {language === 'de' ? 'Mittel' : 'Medium'}
</span>
)}
{stats.riskCounts.LOW > 0 && (
<span className="px-2 py-1 text-xs rounded-full bg-green-100 text-green-700">
{stats.riskCounts.LOW} {language === 'de' ? 'Niedrig' : 'Low'}
</span>
)}
{stats.specialCategoryCount > 0 && (
<span className="px-2 py-1 text-xs rounded-full bg-orange-100 text-orange-700 flex items-center gap-1">
<svg className="w-3 h-3" 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>
{stats.specialCategoryCount} Art. 9
</span>
)}
</div>
<CardContent className="flex-1 flex flex-col gap-4 overflow-hidden">
{/* Statistik-Badges */}
<div className="flex flex-wrap gap-2">
{stats.riskCounts.HIGH > 0 && (
<Badge variant="destructive">
{stats.riskCounts.HIGH} {language === 'de' ? 'Hoch' : 'High'}
</Badge>
)}
{stats.riskCounts.MEDIUM > 0 && (
<Badge variant="secondary">
{stats.riskCounts.MEDIUM} {language === 'de' ? 'Mittel' : 'Medium'}
</Badge>
)}
{stats.riskCounts.LOW > 0 && (
<Badge variant="outline">
{stats.riskCounts.LOW} {language === 'de' ? 'Niedrig' : 'Low'}
</Badge>
)}
{stats.specialCategoryCount > 0 && (
<Badge variant="destructive" className="bg-orange-500 hover:bg-orange-600">
<AlertTriangle className="h-3 w-3 mr-1" />
{stats.specialCategoryCount} Art. 9
</Badge>
)}
</div>
<div className="border-t border-gray-200 my-3"></div>
<Separator />
{/* Datenpunkte nach Kategorie */}
<div className="flex-1 overflow-y-auto space-y-2 max-h-64">
{sortedCategories.map(([category, points]) => {
const metadata = CATEGORY_METADATA[category as DataPointCategory]
if (!metadata) return null
const isExpanded = expandedCategories.includes(category)
{/* Datenpunkte nach Kategorie */}
<ScrollArea className="flex-1 -mr-4 pr-4">
<Accordion type="multiple" className="w-full">
{sortedCategories.map(([category, points]) => {
const metadata = CATEGORY_METADATA[category as DataPointCategory]
if (!metadata) return null
return (
<div key={category} className="border border-gray-100 rounded-lg">
<button
onClick={() => toggleCategory(category)}
className="w-full flex items-center justify-between p-2 text-sm hover:bg-gray-50 rounded-lg"
>
<div className="flex items-center gap-2">
<span className="font-mono text-xs text-gray-400">{metadata.code}</span>
<span className="font-medium text-gray-900">
{language === 'de' ? metadata.name.de : metadata.name.en}
</span>
</div>
<div className="flex items-center gap-2">
<span className="px-1.5 py-0.5 text-xs rounded bg-gray-100 text-gray-600">
{points.length}
</span>
<svg
className={`w-4 h-4 text-gray-400 transition-transform ${isExpanded ? '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>
</div>
</button>
{isExpanded && (
<ul className="px-2 pb-2 space-y-1">
{points.map(dp => (
<li
key={dp.id}
className="flex items-center justify-between text-sm py-1 pl-6"
>
<span className="truncate max-w-[160px] text-gray-700">
{language === 'de' ? dp.name.de : dp.name.en}
return (
<AccordionItem key={category} value={category}>
<AccordionTrigger className="text-sm hover:no-underline">
<div className="flex items-center gap-2">
<span className="font-mono text-xs text-muted-foreground">
{metadata.code}
</span>
<div className="flex items-center gap-1">
{dp.isSpecialCategory && (
<svg className="w-3 h-3 text-orange-500" 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={`px-1.5 py-0.5 text-[10px] rounded border ${getRiskBadgeColor(dp.riskLevel)}`}>
{RISK_LEVEL_STYLING[dp.riskLevel].label[language]}
</span>
</div>
</li>
))}
</ul>
)}
</div>
)
})}
</div>
<span>
{language === 'de' ? metadata.name.de : metadata.name.en}
</span>
<Badge variant="secondary" className="ml-auto mr-2">
{points.length}
</Badge>
</div>
</AccordionTrigger>
<AccordionContent>
<ul className="space-y-1 pl-6">
{points.map(dp => (
<li
key={dp.id}
className="flex items-center justify-between text-sm py-1"
>
<span className="truncate max-w-[180px]">
{language === 'de' ? dp.name.de : dp.name.en}
</span>
<div className="flex items-center gap-1">
{dp.isSpecialCategory && (
<TooltipProvider>
<Tooltip>
<TooltipTrigger>
<AlertTriangle className="h-3 w-3 text-orange-500" />
</TooltipTrigger>
<TooltipContent>
{language === 'de'
? 'Besondere Kategorie (Art. 9 DSGVO)'
: 'Special Category (Art. 9 GDPR)'}
</TooltipContent>
</Tooltip>
</TooltipProvider>
)}
<Badge
variant={getRiskBadgeVariant(dp.riskLevel)}
className="text-xs px-1.5 py-0"
>
{RISK_LEVEL_STYLING[dp.riskLevel].label[language]}
</Badge>
</div>
</li>
))}
</ul>
</AccordionContent>
</AccordionItem>
)
})}
</Accordion>
</ScrollArea>
<div className="border-t border-gray-200 my-3"></div>
<Separator />
{/* Schnell-Einfügen Buttons */}
<div>
<p className="text-xs font-medium text-gray-500 mb-2">
{language === 'de' ? 'Platzhalter einfügen:' : 'Insert placeholder:'}
</p>
<div className="flex flex-wrap gap-1.5">
{PLACEHOLDERS.slice(0, 4).map(({ placeholder, label }) => (
<button
key={placeholder}
onClick={() => onInsertPlaceholder(placeholder)}
className="px-2 py-1 text-xs bg-purple-50 text-purple-700 rounded hover:bg-purple-100 transition-colors"
title={placeholder}
>
{language === 'de' ? label.de : label.en}
</button>
))}
{/* Schnell-Einfügen Buttons */}
<div className="space-y-2">
<p className="text-xs font-medium text-muted-foreground">
{language === 'de' ? 'Platzhalter einfügen:' : 'Insert placeholder:'}
</p>
<div className="flex flex-wrap gap-1.5">
<TooltipProvider>
{PLACEHOLDERS.slice(0, 4).map(({ placeholder, label, description, icon: Icon }) => (
<Tooltip key={placeholder}>
<TooltipTrigger asChild>
<Button
size="sm"
variant="outline"
className="h-7 text-xs px-2"
onClick={() => onInsertPlaceholder(placeholder)}
>
<Icon className="h-3 w-3 mr-1" />
{language === 'de' ? label.de : label.en}
</Button>
</TooltipTrigger>
<TooltipContent side="bottom">
<p className="text-xs">
{language === 'de' ? description.de : description.en}
</p>
<p className="text-xs text-muted-foreground font-mono mt-1">
{placeholder}
</p>
</TooltipContent>
</Tooltip>
))}
</TooltipProvider>
</div>
<div className="flex flex-wrap gap-1.5">
<TooltipProvider>
{PLACEHOLDERS.slice(4).map(({ placeholder, label, description, icon: Icon }) => (
<Tooltip key={placeholder}>
<TooltipTrigger asChild>
<Button
size="sm"
variant="outline"
className="h-7 text-xs px-2"
onClick={() => onInsertPlaceholder(placeholder)}
>
<Icon className="h-3 w-3 mr-1" />
{language === 'de' ? label.de : label.en}
</Button>
</TooltipTrigger>
<TooltipContent side="bottom">
<p className="text-xs">
{language === 'de' ? description.de : description.en}
</p>
<p className="text-xs text-muted-foreground font-mono mt-1">
{placeholder}
</p>
</TooltipContent>
</Tooltip>
))}
</TooltipProvider>
</div>
</div>
<div className="flex flex-wrap gap-1.5 mt-1.5">
{PLACEHOLDERS.slice(4).map(({ placeholder, label }) => (
<button
key={placeholder}
onClick={() => onInsertPlaceholder(placeholder)}
className="px-2 py-1 text-xs bg-purple-50 text-purple-700 rounded hover:bg-purple-100 transition-colors"
title={placeholder}
>
{language === 'de' ? label.de : label.en}
</button>
))}
</div>
</div>
</div>
</CardContent>
</Card>
)
}

View File

@@ -1,6 +1,22 @@
'use client'
import { useMemo, useState } from 'react'
import { useMemo } from 'react'
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from '@/components/ui/collapsible'
import {
AlertCircle,
AlertTriangle,
Info,
ChevronDown,
Lightbulb,
Plus,
} from 'lucide-react'
import { DataPoint } from '@/lib/sdk/einwilligungen/types'
import {
validateDocument,
@@ -14,6 +30,28 @@ interface DocumentValidationProps {
onInsertPlaceholder?: (placeholder: string) => void
}
/**
* Icon für den Warnungstyp
*/
function getWarningIcon(type: ValidationWarning['type']) {
switch (type) {
case 'error':
return AlertCircle
case 'warning':
return AlertTriangle
case 'info':
default:
return Info
}
}
/**
* Alert-Variante für den Warnungstyp
*/
function getAlertVariant(type: ValidationWarning['type']): 'default' | 'destructive' {
return type === 'error' ? 'destructive' : 'default'
}
/**
* Placeholder-Vorschlag aus der Warnung extrahieren
*/
@@ -24,6 +62,9 @@ function extractPlaceholderSuggestion(warning: ValidationWarning): string | null
/**
* DocumentValidation Komponente
*
* Zeigt Validierungswarnungen basierend auf ausgewählten Datenpunkten und
* dem generierten Dokumentinhalt.
*/
export function DocumentValidation({
dataPoints,
@@ -31,8 +72,6 @@ export function DocumentValidation({
language = 'de',
onInsertPlaceholder,
}: DocumentValidationProps) {
const [expandedWarnings, setExpandedWarnings] = useState<string[]>([])
// Führe Validierung durch
const warnings = useMemo(() => {
if (dataPoints.length === 0 || !documentContent) {
@@ -46,33 +85,21 @@ export function DocumentValidation({
const warningCount = warnings.filter(w => w.type === 'warning').length
const infoCount = warnings.filter(w => w.type === 'info').length
const toggleWarning = (code: string) => {
setExpandedWarnings(prev =>
prev.includes(code) ? prev.filter(c => c !== code) : [...prev, code]
)
}
if (warnings.length === 0) {
// Keine Warnungen - zeige Erfolgsmeldung wenn Datenpunkte vorhanden
if (dataPoints.length > 0 && documentContent.length > 100) {
return (
<div className="bg-green-50 border border-green-200 rounded-xl p-4">
<div className="flex items-start gap-3">
<svg className="w-5 h-5 text-green-600 mt-0.5" 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>
<div>
<h4 className="font-medium text-green-800">
{language === 'de' ? 'Dokument valide' : 'Document valid'}
</h4>
<p className="text-sm text-green-700 mt-1">
{language === 'de'
? 'Alle notwendigen Abschnitte für die ausgewählten Datenpunkte sind vorhanden.'
: 'All necessary sections for the selected data points are present.'}
</p>
</div>
</div>
</div>
<Alert className="bg-green-50 border-green-200 dark:bg-green-950 dark:border-green-900">
<Info className="h-4 w-4 text-green-600 dark:text-green-400" />
<AlertTitle className="text-green-800 dark:text-green-200">
{language === 'de' ? 'Dokument valide' : 'Document valid'}
</AlertTitle>
<AlertDescription className="text-green-700 dark:text-green-300">
{language === 'de'
? 'Alle notwendigen Abschnitte für die ausgewählten Datenpunkte sind vorhanden.'
: 'All necessary sections for the selected data points are present.'}
</AlertDescription>
</Alert>
)
}
return null
@@ -82,126 +109,91 @@ export function DocumentValidation({
<div className="space-y-3">
{/* Zusammenfassung */}
<div className="flex items-center gap-2 text-sm">
<span className="font-medium text-gray-700">
<span className="font-medium">
{language === 'de' ? 'Validierung:' : 'Validation:'}
</span>
{errorCount > 0 && (
<span className="px-2 py-0.5 text-xs rounded-full bg-red-100 text-red-700">
{errorCount} {language === 'de' ? 'Fehler' : 'Error'}{errorCount > 1 && 's'}
</span>
<Badge variant="destructive">
{errorCount} {language === 'de' ? 'Fehler' : 'Error'}
{errorCount > 1 && (language === 'de' ? '' : 's')}
</Badge>
)}
{warningCount > 0 && (
<span className="px-2 py-0.5 text-xs rounded-full bg-yellow-100 text-yellow-700">
{warningCount} {language === 'de' ? 'Warnung' : 'Warning'}{warningCount > 1 && (language === 'de' ? 'en' : 's')}
</span>
<Badge variant="secondary" className="bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200">
{warningCount} {language === 'de' ? 'Warnung' : 'Warning'}
{warningCount > 1 && (language === 'de' ? 'en' : 's')}
</Badge>
)}
{infoCount > 0 && (
<span className="px-2 py-0.5 text-xs rounded-full bg-blue-100 text-blue-700">
{infoCount} {language === 'de' ? 'Hinweis' : 'Info'}{infoCount > 1 && (language === 'de' ? 'e' : 's')}
</span>
<Badge variant="outline">
{infoCount} {language === 'de' ? 'Hinweis' : 'Info'}
{infoCount > 1 && (language === 'de' ? 'e' : 's')}
</Badge>
)}
</div>
{/* Warnungen */}
{warnings.map((warning, index) => {
const Icon = getWarningIcon(warning.type)
const placeholder = extractPlaceholderSuggestion(warning)
const isExpanded = expandedWarnings.includes(warning.code)
const isError = warning.type === 'error'
return (
<div
key={`${warning.code}-${index}`}
className={`rounded-xl border p-4 ${
isError
? 'bg-red-50 border-red-200'
: 'bg-yellow-50 border-yellow-200'
}`}
>
<div className="flex items-start gap-3">
{/* Icon */}
<svg
className={`w-5 h-5 mt-0.5 ${isError ? 'text-red-600' : 'text-yellow-600'}`}
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
{isError ? (
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
) : (
<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>
<div className="flex-1">
{/* Message */}
<p className={`font-medium ${isError ? 'text-red-800' : 'text-yellow-800'}`}>
{warning.message}
</p>
{/* Suggestion */}
<div className="flex items-start gap-2 mt-2">
<svg className="w-4 h-4 mt-0.5 text-gray-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9.663 17h4.673M12 3v1m6.364 1.636l-.707.707M21 12h-1M4 12H3m3.343-5.657l-.707-.707m2.828 9.9a5 5 0 117.072 0l-.548.547A3.374 3.374 0 0014 18.469V19a2 2 0 11-4 0v-.531c0-.895-.356-1.754-.988-2.386l-.548-.547z" />
</svg>
<span className="text-sm text-gray-600">{warning.suggestion}</span>
</div>
{/* Quick-Fix Button */}
{placeholder && onInsertPlaceholder && (
<button
onClick={() => onInsertPlaceholder(placeholder)}
className="mt-3 inline-flex items-center gap-1.5 px-3 py-1.5 text-sm bg-white border border-gray-300 rounded-lg hover:bg-gray-50 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="M12 4v16m8-8H4" />
</svg>
{language === 'de' ? 'Platzhalter einfügen' : 'Insert placeholder'}
<code className="ml-1 text-xs bg-gray-100 px-1.5 py-0.5 rounded">
{placeholder}
</code>
</button>
)}
{/* Betroffene Datenpunkte */}
{warning.affectedDataPoints && warning.affectedDataPoints.length > 0 && (
<div className="mt-3">
<button
onClick={() => toggleWarning(warning.code)}
className="flex items-center gap-1 text-xs text-gray-500 hover:text-gray-700"
>
<svg
className={`w-3 h-3 transition-transform ${isExpanded ? '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>
{warning.affectedDataPoints.length}{' '}
{language === 'de' ? 'betroffene Datenpunkte' : 'affected data points'}
</button>
{isExpanded && (
<ul className="mt-2 text-xs space-y-0.5 pl-4">
{warning.affectedDataPoints.slice(0, 5).map(dp => (
<li key={dp.id} className="list-disc text-gray-600">
{language === 'de' ? dp.name.de : dp.name.en}
</li>
))}
{warning.affectedDataPoints.length > 5 && (
<li className="list-none text-gray-400">
... {language === 'de' ? 'und' : 'and'}{' '}
{warning.affectedDataPoints.length - 5}{' '}
{language === 'de' ? 'weitere' : 'more'}
</li>
)}
</ul>
)}
</div>
)}
<Alert key={`${warning.code}-${index}`} variant={getAlertVariant(warning.type)}>
<Icon className="h-4 w-4" />
<AlertTitle className="flex items-center justify-between">
<span>{warning.message}</span>
</AlertTitle>
<AlertDescription className="mt-2 space-y-2">
{/* Vorschlag */}
<div className="flex items-start gap-2">
<Lightbulb className="h-4 w-4 mt-0.5 flex-shrink-0" />
<span className="text-sm">{warning.suggestion}</span>
</div>
</div>
</div>
{/* Quick-Fix Button */}
{placeholder && onInsertPlaceholder && (
<Button
size="sm"
variant="outline"
className="mt-2"
onClick={() => onInsertPlaceholder(placeholder)}
>
<Plus className="h-3 w-3 mr-1" />
{language === 'de' ? 'Platzhalter einfügen' : 'Insert placeholder'}
<code className="ml-1 text-xs bg-muted px-1 rounded">
{placeholder}
</code>
</Button>
)}
{/* Betroffene Datenpunkte */}
{warning.affectedDataPoints && warning.affectedDataPoints.length > 0 && (
<Collapsible>
<CollapsibleTrigger className="flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground">
<ChevronDown className="h-3 w-3" />
{warning.affectedDataPoints.length}{' '}
{language === 'de' ? 'betroffene Datenpunkte' : 'affected data points'}
</CollapsibleTrigger>
<CollapsibleContent className="mt-2">
<ul className="text-xs space-y-0.5 pl-4">
{warning.affectedDataPoints.slice(0, 5).map(dp => (
<li key={dp.id} className="list-disc">
{language === 'de' ? dp.name.de : dp.name.en}
</li>
))}
{warning.affectedDataPoints.length > 5 && (
<li className="list-none text-muted-foreground">
... {language === 'de' ? 'und' : 'and'}{' '}
{warning.affectedDataPoints.length - 5}{' '}
{language === 'de' ? 'weitere' : 'more'}
</li>
)}
</ul>
</CollapsibleContent>
</Collapsible>
)}
</AlertDescription>
</Alert>
)
})}
</div>

File diff suppressed because it is too large Load Diff

View File

@@ -1,179 +1,177 @@
'use client'
import React, { useState, useCallback } from 'react'
import React, { useState, useEffect, useCallback } from 'react'
import { useRouter } from 'next/navigation'
import Link from 'next/link'
import { useSDK } from '@/lib/sdk'
import { StepHeader, STEP_EXPLANATIONS } from '@/components/sdk/StepHeader'
import { DocumentUploadSection, type UploadedDocument } from '@/components/sdk'
import { DSFACard } from '@/components/sdk/dsfa'
import {
DSFA,
DSFAStatus,
DSFA_STATUS_LABELS,
DSFA_RISK_LEVEL_LABELS,
} from '@/lib/sdk/dsfa/types'
import {
listDSFAs,
deleteDSFA,
exportDSFAAsJSON,
getDSFAStats,
createDSFAFromAssessment,
getDSFAByAssessment,
} from '@/lib/sdk/dsfa/api'
// =============================================================================
// TYPES
// UCCA TRIGGER WARNING COMPONENT
// =============================================================================
interface DSFA {
id: string
title: string
description: string
status: 'draft' | 'in-review' | 'approved' | 'needs-update'
createdAt: Date
updatedAt: Date
approvedBy: string | null
riskLevel: 'low' | 'medium' | 'high' | 'critical'
processingActivity: string
dataCategories: string[]
recipients: string[]
measures: string[]
interface UCCATriggerWarningProps {
assessmentId: string
triggeredRules: string[]
existingDsfaId?: string
onCreateDSFA: () => void
}
// =============================================================================
// MOCK DATA
// =============================================================================
const mockDSFAs: DSFA[] = [
{
id: 'dsfa-1',
title: 'DSFA - Bewerber-Management-System',
description: 'Datenschutz-Folgenabschaetzung fuer das KI-gestuetzte Bewerber-Screening',
status: 'in-review',
createdAt: new Date('2024-01-10'),
updatedAt: new Date('2024-01-20'),
approvedBy: null,
riskLevel: 'high',
processingActivity: 'Automatisierte Bewertung von Bewerbungsunterlagen',
dataCategories: ['Kontaktdaten', 'Beruflicher Werdegang', 'Qualifikationen'],
recipients: ['HR-Abteilung', 'Fachabteilungen'],
measures: ['Verschluesselung', 'Zugriffskontrolle', 'Menschliche Pruefung'],
},
{
id: 'dsfa-2',
title: 'DSFA - Video-Ueberwachung Buero',
description: 'Datenschutz-Folgenabschaetzung fuer die Videoueberwachung im Buerogebaeude',
status: 'approved',
createdAt: new Date('2023-11-01'),
updatedAt: new Date('2023-12-15'),
approvedBy: 'DSB Mueller',
riskLevel: 'medium',
processingActivity: 'Videoueberwachung zu Sicherheitszwecken',
dataCategories: ['Bilddaten', 'Bewegungsdaten'],
recipients: ['Sicherheitsdienst'],
measures: ['Loeschfristen', 'Zugriffsbeschraenkung', 'Hinweisschilder'],
},
{
id: 'dsfa-3',
title: 'DSFA - Kundenanalyse',
description: 'Datenschutz-Folgenabschaetzung fuer Big-Data-Kundenanalysen',
status: 'draft',
createdAt: new Date('2024-01-22'),
updatedAt: new Date('2024-01-22'),
approvedBy: null,
riskLevel: 'high',
processingActivity: 'Analyse von Kundenverhalten fuer Marketing',
dataCategories: ['Kaufhistorie', 'Nutzungsverhalten', 'Praeferenzen'],
recipients: ['Marketing', 'Vertrieb'],
measures: [],
},
]
// =============================================================================
// COMPONENTS
// =============================================================================
function DSFACard({ dsfa }: { dsfa: DSFA }) {
const statusColors = {
draft: 'bg-gray-100 text-gray-600 border-gray-200',
'in-review': 'bg-yellow-100 text-yellow-700 border-yellow-200',
approved: 'bg-green-100 text-green-700 border-green-200',
'needs-update': 'bg-orange-100 text-orange-700 border-orange-200',
}
const statusLabels = {
draft: 'Entwurf',
'in-review': 'In Pruefung',
approved: 'Genehmigt',
'needs-update': 'Aktualisierung erforderlich',
}
const riskColors = {
low: 'bg-green-100 text-green-700',
medium: 'bg-yellow-100 text-yellow-700',
high: 'bg-orange-100 text-orange-700',
critical: 'bg-red-100 text-red-700',
function UCCATriggerWarning({
assessmentId,
triggeredRules,
existingDsfaId,
onCreateDSFA,
}: UCCATriggerWarningProps) {
if (existingDsfaId) {
return (
<div className="bg-blue-50 border border-blue-200 rounded-xl p-4 flex items-start gap-4">
<div className="w-10 h-10 bg-blue-100 rounded-full flex items-center justify-center flex-shrink-0">
<svg className="w-5 h-5 text-blue-600" 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>
</div>
<div className="flex-1">
<h4 className="font-medium text-blue-800">DSFA bereits erstellt</h4>
<p className="text-sm text-blue-600 mt-1">
Fuer dieses Assessment wurde bereits eine DSFA angelegt.
</p>
<Link
href={`/sdk/dsfa/${existingDsfaId}`}
className="inline-flex items-center gap-1 mt-2 text-sm text-blue-700 hover:text-blue-800 font-medium"
>
DSFA oeffnen
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
</svg>
</Link>
</div>
</div>
)
}
return (
<div className={`bg-white rounded-xl border-2 p-6 ${
dsfa.status === 'needs-update' ? 'border-orange-200' :
dsfa.status === 'approved' ? 'border-green-200' : 'border-gray-200'
}`}>
<div className="flex items-start justify-between">
<div className="flex-1">
<div className="flex items-center gap-2 mb-2">
<span className={`px-2 py-1 text-xs rounded-full ${statusColors[dsfa.status]}`}>
{statusLabels[dsfa.status]}
<div className="bg-orange-50 border border-orange-200 rounded-xl p-4 flex items-start gap-4">
<div className="w-10 h-10 bg-orange-100 rounded-full flex items-center justify-center flex-shrink-0">
<svg className="w-5 h-5 text-orange-600" 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>
</div>
<div className="flex-1">
<h4 className="font-medium text-orange-800">DSFA erforderlich</h4>
<p className="text-sm text-orange-600 mt-1">
Das UCCA-Assessment hat folgende Trigger ausgeloest:
</p>
<div className="flex flex-wrap gap-1 mt-2">
{triggeredRules.map(rule => (
<span key={rule} className="px-2 py-0.5 text-xs bg-orange-100 text-orange-700 rounded">
{rule}
</span>
<span className={`px-2 py-1 text-xs rounded-full ${riskColors[dsfa.riskLevel]}`}>
Risiko: {dsfa.riskLevel === 'low' ? 'Niedrig' :
dsfa.riskLevel === 'medium' ? 'Mittel' :
dsfa.riskLevel === 'high' ? 'Hoch' : 'Kritisch'}
</span>
</div>
<h3 className="text-lg font-semibold text-gray-900">{dsfa.title}</h3>
<p className="text-sm text-gray-500 mt-1">{dsfa.description}</p>
</div>
</div>
<div className="mt-4 text-sm text-gray-600">
<p><span className="text-gray-500">Verarbeitungstaetigkeit:</span> {dsfa.processingActivity}</p>
</div>
<div className="mt-3 flex flex-wrap gap-1">
{dsfa.dataCategories.map(cat => (
<span key={cat} className="px-2 py-0.5 text-xs bg-blue-50 text-blue-600 rounded">
{cat}
</span>
))}
</div>
{dsfa.measures.length > 0 && (
<div className="mt-3">
<span className="text-sm text-gray-500">Massnahmen:</span>
<div className="flex flex-wrap gap-1 mt-1">
{dsfa.measures.map(m => (
<span key={m} className="px-2 py-0.5 text-xs bg-green-50 text-green-600 rounded">
{m}
</span>
))}
</div>
</div>
)}
<div className="mt-4 pt-4 border-t border-gray-100 flex items-center justify-between text-sm">
<div className="text-gray-500">
<span>Erstellt: {dsfa.createdAt.toLocaleDateString('de-DE')}</span>
{dsfa.approvedBy && (
<span className="ml-4">Genehmigt von: {dsfa.approvedBy}</span>
)}
</div>
<div className="flex items-center gap-2">
<button className="px-3 py-1 text-purple-600 hover:bg-purple-50 rounded-lg transition-colors">
Bearbeiten
</button>
<button className="px-3 py-1 text-gray-600 hover:bg-gray-100 rounded-lg transition-colors">
Exportieren
</button>
))}
</div>
<button
onClick={onCreateDSFA}
className="inline-flex items-center gap-1 mt-3 px-4 py-2 bg-orange-600 text-white rounded-lg hover:bg-orange-700 transition-colors text-sm font-medium"
>
DSFA aus Assessment erstellen
<svg className="w-4 h-4" 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>
)
}
function GeneratorWizard({ onClose }: { onClose: () => void }) {
// =============================================================================
// GENERATOR WIZARD COMPONENT
// =============================================================================
function GeneratorWizard({ onClose, onCreated }: { onClose: () => void; onCreated: (dsfa: DSFA) => void }) {
const [step, setStep] = useState(1)
const [formData, setFormData] = useState({
name: '',
description: '',
processingPurpose: '',
dataCategories: [] as string[],
legalBasis: '',
})
const [isSubmitting, setIsSubmitting] = useState(false)
const DATA_CATEGORIES = [
'Kontaktdaten',
'Identifikationsdaten',
'Finanzdaten',
'Gesundheitsdaten',
'Standortdaten',
'Nutzungsdaten',
'Biometrische Daten',
'Daten Minderjaehriger',
]
const LEGAL_BASES = [
{ value: 'consent', label: 'Einwilligung (Art. 6 Abs. 1 lit. a)' },
{ value: 'contract', label: 'Vertrag (Art. 6 Abs. 1 lit. b)' },
{ value: 'legal_obligation', label: 'Rechtliche Verpflichtung (Art. 6 Abs. 1 lit. c)' },
{ value: 'legitimate_interest', label: 'Berechtigtes Interesse (Art. 6 Abs. 1 lit. f)' },
]
const handleCategoryToggle = (cat: string) => {
setFormData(prev => ({
...prev,
dataCategories: prev.dataCategories.includes(cat)
? prev.dataCategories.filter(c => c !== cat)
: [...prev.dataCategories, cat],
}))
}
const handleSubmit = async () => {
setIsSubmitting(true)
try {
// For standalone DSFA, we use the regular create endpoint
const response = await fetch('/api/sdk/v1/dsgvo/dsfas', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
name: formData.name,
description: formData.description,
processing_purpose: formData.processingPurpose,
data_categories: formData.dataCategories,
legal_basis: formData.legalBasis,
status: 'draft',
}),
})
if (response.ok) {
const dsfa = await response.json()
onCreated(dsfa)
onClose()
}
} catch (error) {
console.error('Failed to create DSFA:', error)
} finally {
setIsSubmitting(false)
}
}
return (
<div className="bg-white rounded-xl border border-gray-200 p-6">
<div className="flex items-center justify-between mb-6">
<h3 className="text-lg font-semibold text-gray-900">Neue DSFA erstellen</h3>
<h3 className="text-lg font-semibold text-gray-900">Neue Standalone-DSFA erstellen</h3>
<button onClick={onClose} className="text-gray-400 hover:text-gray-600">
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
@@ -183,7 +181,7 @@ function GeneratorWizard({ onClose }: { onClose: () => void }) {
{/* Progress Steps */}
<div className="flex items-center gap-2 mb-6">
{[1, 2, 3, 4].map(s => (
{[1, 2, 3].map(s => (
<React.Fragment key={s}>
<div className={`w-8 h-8 rounded-full flex items-center justify-center text-sm font-medium ${
s < step ? 'bg-green-500 text-white' :
@@ -195,7 +193,7 @@ function GeneratorWizard({ onClose }: { onClose: () => void }) {
</svg>
) : s}
</div>
{s < 4 && <div className={`flex-1 h-1 ${s < step ? 'bg-green-500' : 'bg-gray-200'}`} />}
{s < 3 && <div className={`flex-1 h-1 ${s < step ? 'bg-green-500' : 'bg-gray-200'}`} />}
</React.Fragment>
))}
</div>
@@ -205,9 +203,11 @@ function GeneratorWizard({ onClose }: { onClose: () => void }) {
{step === 1 && (
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Titel der DSFA</label>
<label className="block text-sm font-medium text-gray-700 mb-1">Titel der DSFA *</label>
<input
type="text"
value={formData.name}
onChange={(e) => setFormData(prev => ({ ...prev, name: e.target.value }))}
placeholder="z.B. DSFA - Mitarbeiter-Monitoring"
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500"
/>
@@ -215,21 +215,38 @@ function GeneratorWizard({ onClose }: { onClose: () => void }) {
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Beschreibung der Verarbeitung</label>
<textarea
value={formData.description}
onChange={(e) => setFormData(prev => ({ ...prev, description: e.target.value }))}
rows={3}
placeholder="Beschreiben Sie die geplante Datenverarbeitung..."
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Verarbeitungszweck</label>
<input
type="text"
value={formData.processingPurpose}
onChange={(e) => setFormData(prev => ({ ...prev, processingPurpose: e.target.value }))}
placeholder="z.B. Automatisierte Bewerberauswahl"
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500"
/>
</div>
</div>
)}
{step === 2 && (
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">Datenkategorien</label>
<label className="block text-sm font-medium text-gray-700 mb-2">Datenkategorien *</label>
<div className="grid grid-cols-2 gap-2">
{['Kontaktdaten', 'Identifikationsdaten', 'Finanzdaten', 'Gesundheitsdaten', 'Standortdaten', 'Nutzungsdaten'].map(cat => (
{DATA_CATEGORIES.map(cat => (
<label key={cat} className="flex items-center gap-2 p-2 border rounded-lg hover:bg-gray-50">
<input type="checkbox" className="w-4 h-4 text-purple-600" />
<input
type="checkbox"
checked={formData.dataCategories.includes(cat)}
onChange={() => handleCategoryToggle(cat)}
className="w-4 h-4 text-purple-600"
/>
<span className="text-sm">{cat}</span>
</label>
))}
@@ -240,28 +257,19 @@ function GeneratorWizard({ onClose }: { onClose: () => void }) {
{step === 3 && (
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">Risikobewertung</label>
<p className="text-sm text-gray-500 mb-4">Bewerten Sie die Risiken fuer die Rechte und Freiheiten der Betroffenen.</p>
<label className="block text-sm font-medium text-gray-700 mb-2">Rechtsgrundlage *</label>
<div className="space-y-2">
{['Niedrig', 'Mittel', 'Hoch', 'Kritisch'].map(level => (
<label key={level} className="flex items-center gap-3 p-3 border rounded-lg hover:bg-gray-50 cursor-pointer">
<input type="radio" name="risk" className="w-4 h-4 text-purple-600" />
<span className="text-sm font-medium">{level}</span>
</label>
))}
</div>
</div>
</div>
)}
{step === 4 && (
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">Schutzmassnahmen</label>
<div className="grid grid-cols-2 gap-2">
{['Verschluesselung', 'Pseudonymisierung', 'Zugriffskontrolle', 'Loeschkonzept', 'Schulungen', 'Menschliche Pruefung'].map(m => (
<label key={m} className="flex items-center gap-2 p-2 border rounded-lg hover:bg-gray-50">
<input type="checkbox" className="w-4 h-4 text-purple-600" />
<span className="text-sm">{m}</span>
{LEGAL_BASES.map(basis => (
<label key={basis.value} className="flex items-center gap-3 p-3 border rounded-lg hover:bg-gray-50 cursor-pointer">
<input
type="radio"
name="legalBasis"
value={basis.value}
checked={formData.legalBasis === basis.value}
onChange={(e) => setFormData(prev => ({ ...prev, legalBasis: e.target.value }))}
className="w-4 h-4 text-purple-600"
/>
<span className="text-sm font-medium">{basis.label}</span>
</label>
))}
</div>
@@ -279,10 +287,11 @@ function GeneratorWizard({ onClose }: { onClose: () => void }) {
{step === 1 ? 'Abbrechen' : 'Zurueck'}
</button>
<button
onClick={() => step < 4 ? setStep(step + 1) : onClose()}
className="px-6 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700 transition-colors"
onClick={() => step < 3 ? setStep(step + 1) : handleSubmit()}
disabled={(step === 1 && !formData.name) || (step === 2 && formData.dataCategories.length === 0) || (step === 3 && !formData.legalBasis) || isSubmitting}
className="px-6 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700 transition-colors disabled:bg-gray-300 disabled:cursor-not-allowed"
>
{step === 4 ? 'DSFA erstellen' : 'Weiter'}
{isSubmitting ? 'Wird erstellt...' : step === 3 ? 'DSFA erstellen' : 'Weiter'}
</button>
</div>
</div>
@@ -296,28 +305,115 @@ function GeneratorWizard({ onClose }: { onClose: () => void }) {
export default function DSFAPage() {
const router = useRouter()
const { state } = useSDK()
const [dsfas] = useState<DSFA[]>(mockDSFAs)
const [dsfas, setDsfas] = useState<DSFA[]>([])
const [showGenerator, setShowGenerator] = useState(false)
const [filter, setFilter] = useState<string>('all')
const [isLoading, setIsLoading] = useState(true)
const [stats, setStats] = useState({
total: 0,
draft: 0,
in_review: 0,
approved: 0,
})
// Handle uploaded document
const handleDocumentProcessed = useCallback((doc: UploadedDocument) => {
console.log('[DSFA Page] Document processed:', doc)
}, [])
// UCCA trigger info (would come from SDK state)
const [uccaTrigger, setUccaTrigger] = useState<{
assessmentId: string
triggeredRules: string[]
existingDsfaId?: string
} | null>(null)
// Open document in workflow editor
const handleOpenInEditor = useCallback((doc: UploadedDocument) => {
router.push(`/compliance/workflow?documentType=dsfa&documentId=${doc.id}&mode=change`)
}, [router])
// Load DSFAs
const loadDSFAs = useCallback(async () => {
setIsLoading(true)
try {
const [dsfaList, statsData] = await Promise.all([
listDSFAs(filter === 'all' ? undefined : filter),
getDSFAStats(),
])
setDsfas(dsfaList)
setStats({
total: statsData.total,
draft: statsData.status_stats.draft || 0,
in_review: statsData.status_stats.in_review || 0,
approved: statsData.status_stats.approved || 0,
})
} catch (error) {
console.error('Failed to load DSFAs:', error)
// Set empty state on error
setDsfas([])
} finally {
setIsLoading(false)
}
}, [filter])
useEffect(() => {
loadDSFAs()
}, [loadDSFAs])
// Check for UCCA trigger from SDK state
// TODO: Enable when UCCA integration is complete
// useEffect(() => {
// if (state?.uccaAssessment?.dsfa_recommended) {
// const assessmentId = state.uccaAssessment.id
// const triggeredRules = state.uccaAssessment.triggered_rules
// ?.filter((r: { severity: string }) => r.severity === 'BLOCK' || r.severity === 'WARN')
// ?.map((r: { code: string }) => r.code) || []
//
// // Check if DSFA already exists
// getDSFAByAssessment(assessmentId).then(existingDsfa => {
// setUccaTrigger({
// assessmentId,
// triggeredRules,
// existingDsfaId: existingDsfa?.id,
// })
// })
// }
// }, [state?.uccaAssessment])
// Handle delete
const handleDelete = async (id: string) => {
if (confirm('Moechten Sie diese DSFA wirklich loeschen?')) {
try {
await deleteDSFA(id)
await loadDSFAs()
} catch (error) {
console.error('Failed to delete DSFA:', error)
}
}
}
// Handle export
const handleExport = async (id: string) => {
try {
const blob = await exportDSFAAsJSON(id)
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = `dsfa_${id.slice(0, 8)}.json`
a.click()
URL.revokeObjectURL(url)
} catch (error) {
console.error('Failed to export DSFA:', error)
}
}
// Handle create from assessment
const handleCreateFromAssessment = async () => {
if (!uccaTrigger?.assessmentId) return
try {
const response = await createDSFAFromAssessment(uccaTrigger.assessmentId)
router.push(`/sdk/dsfa/${response.dsfa.id}`)
} catch (error) {
console.error('Failed to create DSFA from assessment:', error)
}
}
const filteredDSFAs = filter === 'all'
? dsfas
: dsfas.filter(d => d.status === filter)
const draftCount = dsfas.filter(d => d.status === 'draft').length
const inReviewCount = dsfas.filter(d => d.status === 'in-review').length
const approvedCount = dsfas.filter(d => d.status === 'approved').length
const stepInfo = STEP_EXPLANATIONS['dsfa']
return (
@@ -330,55 +426,67 @@ export default function DSFAPage() {
explanation={stepInfo.explanation}
tips={stepInfo.tips}
>
{!showGenerator && (
<button
onClick={() => setShowGenerator(true)}
className="flex items-center gap-2 px-4 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-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="M12 6v6m0 0v6m0-6h6m-6 0H6" />
</svg>
Neue DSFA
</button>
)}
<div className="flex items-center gap-2">
{!showGenerator && (
<>
<button
onClick={() => setShowGenerator(true)}
className="flex items-center gap-2 px-4 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-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="M12 6v6m0 0v6m0-6h6m-6 0H6" />
</svg>
Standalone DSFA
</button>
</>
)}
</div>
</StepHeader>
{/* UCCA Trigger Warning */}
{uccaTrigger && (
<UCCATriggerWarning
assessmentId={uccaTrigger.assessmentId}
triggeredRules={uccaTrigger.triggeredRules}
existingDsfaId={uccaTrigger.existingDsfaId}
onCreateDSFA={handleCreateFromAssessment}
/>
)}
{/* Generator */}
{showGenerator && (
<GeneratorWizard onClose={() => setShowGenerator(false)} />
<GeneratorWizard
onClose={() => setShowGenerator(false)}
onCreated={(dsfa) => {
router.push(`/sdk/dsfa/${dsfa.id}`)
}}
/>
)}
{/* Document Upload Section */}
<DocumentUploadSection
documentType="dsfa"
onDocumentProcessed={handleDocumentProcessed}
onOpenInEditor={handleOpenInEditor}
/>
{/* Stats */}
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
<div className="bg-white rounded-xl border border-gray-200 p-6">
<div className="text-sm text-gray-500">Gesamt</div>
<div className="text-3xl font-bold text-gray-900">{dsfas.length}</div>
<div className="text-3xl font-bold text-gray-900">{stats.total}</div>
</div>
<div className="bg-white rounded-xl border border-gray-200 p-6">
<div className="text-sm text-gray-500">Entwuerfe</div>
<div className="text-3xl font-bold text-gray-500">{draftCount}</div>
<div className="text-3xl font-bold text-gray-500">{stats.draft}</div>
</div>
<div className="bg-white rounded-xl border border-yellow-200 p-6">
<div className="text-sm text-yellow-600">In Pruefung</div>
<div className="text-3xl font-bold text-yellow-600">{inReviewCount}</div>
<div className="text-3xl font-bold text-yellow-600">{stats.in_review}</div>
</div>
<div className="bg-white rounded-xl border border-green-200 p-6">
<div className="text-sm text-green-600">Genehmigt</div>
<div className="text-3xl font-bold text-green-600">{approvedCount}</div>
<div className="text-3xl font-bold text-green-600">{stats.approved}</div>
</div>
</div>
{/* Filter */}
<div className="flex items-center gap-2">
<span className="text-sm text-gray-500">Filter:</span>
{['all', 'draft', 'in-review', 'approved', 'needs-update'].map(f => (
{['all', 'draft', 'in_review', 'approved', 'needs_update'].map(f => (
<button
key={f}
onClick={() => setFilter(f)}
@@ -388,22 +496,31 @@ export default function DSFAPage() {
: 'bg-gray-100 text-gray-600 hover:bg-gray-200'
}`}
>
{f === 'all' ? 'Alle' :
f === 'draft' ? 'Entwuerfe' :
f === 'in-review' ? 'In Pruefung' :
f === 'approved' ? 'Genehmigt' : 'Update erforderlich'}
{f === 'all' ? 'Alle' : DSFA_STATUS_LABELS[f as DSFAStatus] || f}
</button>
))}
</div>
{/* DSFA List */}
<div className="space-y-4">
{filteredDSFAs.map(dsfa => (
<DSFACard key={dsfa.id} dsfa={dsfa} />
))}
</div>
{isLoading ? (
<div className="flex items-center justify-center py-12">
<div className="w-8 h-8 border-4 border-purple-600 border-t-transparent rounded-full animate-spin" />
</div>
) : (
<div className="space-y-4">
{filteredDSFAs.map(dsfa => (
<DSFACard
key={dsfa.id}
dsfa={dsfa}
onDelete={handleDelete}
onExport={handleExport}
/>
))}
</div>
)}
{filteredDSFAs.length === 0 && !showGenerator && (
{/* Empty State */}
{!isLoading && filteredDSFAs.length === 0 && !showGenerator && (
<div className="bg-white rounded-xl border border-gray-200 p-12 text-center">
<div className="w-16 h-16 mx-auto bg-purple-100 rounded-full flex items-center justify-center mb-4">
<svg className="w-8 h-8 text-purple-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
@@ -411,13 +528,19 @@ export default function DSFAPage() {
</svg>
</div>
<h3 className="text-lg font-semibold text-gray-900">Keine DSFAs gefunden</h3>
<p className="mt-2 text-gray-500">Erstellen Sie eine neue Datenschutz-Folgenabschaetzung.</p>
<button
onClick={() => setShowGenerator(true)}
className="mt-4 px-6 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700 transition-colors"
>
Erste DSFA erstellen
</button>
<p className="mt-2 text-gray-500">
{filter !== 'all'
? `Keine DSFAs mit Status "${DSFA_STATUS_LABELS[filter as DSFAStatus]}".`
: 'Erstellen Sie eine neue Datenschutz-Folgenabschaetzung.'}
</p>
{filter === 'all' && (
<button
onClick={() => setShowGenerator(true)}
className="mt-4 px-6 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700 transition-colors"
>
Erste DSFA erstellen
</button>
)}
</div>
)}
</div>

View File

@@ -2,9 +2,7 @@
import React from 'react'
import Link from 'next/link'
import { useSDK, SDK_PACKAGES, getStepsForPackage } from '@/lib/sdk'
import { CustomerTypeSelector } from '@/components/sdk/CustomerTypeSelector'
import type { CustomerType, SDKPackageId } from '@/lib/sdk/types'
import { useSDK, getStepsForPhase } from '@/lib/sdk'
// =============================================================================
// DASHBOARD CARDS
@@ -37,65 +35,49 @@ function StatCard({
)
}
function PackageCard({
pkg,
function PhaseCard({
phase,
title,
description,
completion,
stepsCount,
isLocked,
steps,
href,
}: {
pkg: (typeof SDK_PACKAGES)[number]
phase: number
title: string
description: string
completion: number
stepsCount: number
isLocked: boolean
steps: number
href: string
}) {
const steps = getStepsForPackage(pkg.id)
const firstStep = steps[0]
const href = firstStep?.url || '/sdk'
const content = (
<div
className={`block bg-white rounded-xl border-2 p-6 transition-all ${
isLocked
? 'border-gray-100 opacity-60 cursor-not-allowed'
: completion === 100
? 'border-green-200 hover:border-green-300 hover:shadow-lg'
: 'border-gray-200 hover:border-purple-300 hover:shadow-lg'
}`}
return (
<Link
href={href}
className="block bg-white rounded-xl border border-gray-200 p-6 hover:border-purple-300 hover:shadow-lg transition-all"
>
<div className="flex items-start gap-4">
<div
className={`w-14 h-14 rounded-xl flex items-center justify-center text-2xl ${
isLocked
? 'bg-gray-100 text-gray-400'
: completion === 100
className={`w-12 h-12 rounded-xl flex items-center justify-center text-xl font-bold ${
completion === 100
? 'bg-green-100 text-green-600'
: 'bg-purple-100 text-purple-600'
}`}
>
{isLocked ? (
<svg className="w-6 h-6" 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>
) : completion === 100 ? (
{completion === 100 ? (
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg>
) : (
pkg.icon
phase
)}
</div>
<div className="flex-1">
<div className="flex items-center gap-2">
<span className="text-sm text-gray-500">{pkg.order}.</span>
<h3 className="text-lg font-semibold text-gray-900">{pkg.name}</h3>
</div>
<p className="mt-1 text-sm text-gray-500">{pkg.description}</p>
<h3 className="text-lg font-semibold text-gray-900">{title}</h3>
<p className="mt-1 text-sm text-gray-500">{description}</p>
<div className="mt-4">
<div className="flex items-center justify-between text-sm mb-1">
<span className="text-gray-500">{stepsCount} Schritte</span>
<span className={`font-medium ${completion === 100 ? 'text-green-600' : 'text-purple-600'}`}>
{completion}%
</span>
<span className="text-gray-500">{steps} Schritte</span>
<span className="font-medium text-purple-600">{completion}%</span>
</div>
<div className="h-2 bg-gray-100 rounded-full overflow-hidden">
<div
@@ -106,23 +88,8 @@ function PackageCard({
/>
</div>
</div>
{!isLocked && (
<p className="mt-3 text-xs text-gray-400">
Ergebnis: {pkg.result}
</p>
)}
</div>
</div>
</div>
)
if (isLocked) {
return content
}
return (
<Link href={href}>
{content}
</Link>
)
}
@@ -162,63 +129,24 @@ function QuickActionCard({
// =============================================================================
export default function SDKDashboard() {
const { state, packageCompletion, completionPercentage, setCustomerType } = useSDK()
const { state, phase1Completion, phase2Completion, completionPercentage } = useSDK()
// Calculate total steps
const totalSteps = SDK_PACKAGES.reduce((sum, pkg) => {
const steps = getStepsForPackage(pkg.id)
// Filter import step for new customers
return sum + steps.filter(s => !(s.id === 'import' && state.customerType === 'new')).length
}, 0)
const phase1Steps = getStepsForPhase(1)
const phase2Steps = getStepsForPhase(2)
// Calculate stats
const completedCheckpoints = Object.values(state.checkpoints).filter(cp => cp.passed).length
const totalRisks = state.risks.length
const criticalRisks = state.risks.filter(r => r.severity === 'CRITICAL' || r.severity === 'HIGH').length
const isPackageLocked = (packageId: SDKPackageId): boolean => {
if (state.preferences?.allowParallelWork) return false
const pkg = SDK_PACKAGES.find(p => p.id === packageId)
if (!pkg || pkg.order === 1) return false
// Check if previous package is complete
const prevPkg = SDK_PACKAGES.find(p => p.order === pkg.order - 1)
if (!prevPkg) return false
return packageCompletion[prevPkg.id] < 100
}
// Show customer type selector if not set
if (!state.customerType) {
return (
<div className="min-h-[calc(100vh-200px)] flex items-center justify-center py-12">
<CustomerTypeSelector
onSelect={(type: CustomerType) => {
setCustomerType(type)
}}
/>
</div>
)
}
return (
<div className="space-y-8">
{/* Header */}
<div className="flex items-start justify-between">
<div>
<h1 className="text-2xl font-bold text-gray-900">AI Compliance SDK</h1>
<p className="mt-1 text-gray-500">
{state.customerType === 'new'
? 'Neukunden-Modus: Erstellen Sie alle Compliance-Dokumente von Grund auf.'
: 'Bestandskunden-Modus: Erweitern Sie bestehende Dokumente.'}
</p>
</div>
<button
onClick={() => setCustomerType(state.customerType === 'new' ? 'existing' : 'new')}
className="text-sm text-purple-600 hover:text-purple-700 underline"
>
{state.customerType === 'new' ? 'Zu Bestandskunden wechseln' : 'Zu Neukunden wechseln'}
</button>
<div>
<h1 className="text-2xl font-bold text-gray-900">AI Compliance SDK</h1>
<p className="mt-1 text-gray-500">
Willkommen zum Compliance Assessment. Starten Sie mit Phase 1 oder setzen Sie Ihre Arbeit fort.
</p>
</div>
{/* Stats Grid */}
@@ -226,7 +154,7 @@ export default function SDKDashboard() {
<StatCard
title="Gesamtfortschritt"
value={`${completionPercentage}%`}
subtitle={`${state.completedSteps.length} von ${totalSteps} Schritten`}
subtitle={`${state.completedSteps.length} von ${phase1Steps.length + phase2Steps.length} Schritten`}
icon={
<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="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" />
@@ -247,7 +175,7 @@ export default function SDKDashboard() {
/>
<StatCard
title="Checkpoints"
value={`${completedCheckpoints}/${totalSteps}`}
value={`${completedCheckpoints}/${phase1Steps.length + phase2Steps.length}`}
subtitle="Validiert"
icon={
<svg className="w-6 h-6 text-green-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
@@ -269,85 +197,26 @@ export default function SDKDashboard() {
/>
</div>
{/* Bestandskunden: Gap Analysis Banner */}
{state.customerType === 'existing' && state.importedDocuments.length === 0 && (
<div className="bg-gradient-to-r from-indigo-50 to-purple-50 border border-indigo-200 rounded-xl p-6">
<div className="flex items-start gap-4">
<div className="w-12 h-12 bg-indigo-100 rounded-xl flex items-center justify-center flex-shrink-0">
<span className="text-2xl">📄</span>
</div>
<div className="flex-1">
<h3 className="text-lg font-semibold text-gray-900">Bestehende Dokumente importieren</h3>
<p className="mt-1 text-gray-600">
Laden Sie Ihre vorhandenen Compliance-Dokumente hoch. Unsere KI analysiert sie und zeigt Ihnen, welche Erweiterungen fuer KI-Compliance erforderlich sind.
</p>
<Link
href="/sdk/import"
className="inline-flex items-center gap-2 mt-4 px-4 py-2 bg-indigo-600 text-white rounded-lg hover:bg-indigo-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="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-8l-4-4m0 0L8 8m4-4v12" />
</svg>
Dokumente hochladen
</Link>
</div>
</div>
</div>
)}
{/* Gap Analysis Results */}
{state.gapAnalysis && (
<div className="bg-white border border-gray-200 rounded-xl p-6">
<div className="flex items-center gap-3 mb-4">
<div className="w-10 h-10 bg-orange-100 rounded-lg flex items-center justify-center">
<span className="text-xl">📊</span>
</div>
<div>
<h3 className="font-semibold text-gray-900">Gap-Analyse Ergebnis</h3>
<p className="text-sm text-gray-500">
{state.gapAnalysis.totalGaps} Luecken gefunden
</p>
</div>
</div>
<div className="grid grid-cols-4 gap-4">
<div className="text-center p-3 bg-red-50 rounded-lg">
<div className="text-2xl font-bold text-red-600">{state.gapAnalysis.criticalGaps}</div>
<div className="text-xs text-red-600">Kritisch</div>
</div>
<div className="text-center p-3 bg-orange-50 rounded-lg">
<div className="text-2xl font-bold text-orange-600">{state.gapAnalysis.highGaps}</div>
<div className="text-xs text-orange-600">Hoch</div>
</div>
<div className="text-center p-3 bg-yellow-50 rounded-lg">
<div className="text-2xl font-bold text-yellow-600">{state.gapAnalysis.mediumGaps}</div>
<div className="text-xs text-yellow-600">Mittel</div>
</div>
<div className="text-center p-3 bg-green-50 rounded-lg">
<div className="text-2xl font-bold text-green-600">{state.gapAnalysis.lowGaps}</div>
<div className="text-xs text-green-600">Niedrig</div>
</div>
</div>
</div>
)}
{/* 5 Packages */}
{/* Phases */}
<div>
<h2 className="text-lg font-semibold text-gray-900 mb-4">Compliance-Pakete</h2>
<div className="grid grid-cols-1 lg:grid-cols-2 xl:grid-cols-3 gap-6">
{SDK_PACKAGES.map(pkg => {
const steps = getStepsForPackage(pkg.id)
const visibleSteps = steps.filter(s => !(s.id === 'import' && state.customerType === 'new'))
return (
<PackageCard
key={pkg.id}
pkg={pkg}
completion={packageCompletion[pkg.id]}
stepsCount={visibleSteps.length}
isLocked={isPackageLocked(pkg.id)}
/>
)
})}
<h2 className="text-lg font-semibold text-gray-900 mb-4">Phasen</h2>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<PhaseCard
phase={1}
title="Compliance Assessment"
description="Use Case erfassen, Screening durchführen, Risiken bewerten"
completion={phase1Completion}
steps={phase1Steps.length}
href="/sdk/advisory-board"
/>
<PhaseCard
phase={2}
title="Dokumentengenerierung"
description="DSFA, TOMs, VVT, Cookie Banner und mehr generieren"
completion={phase2Completion}
steps={phase2Steps.length}
href="/sdk/ai-act"
/>
</div>
</div>
@@ -379,7 +248,7 @@ export default function SDKDashboard() {
/>
<QuickActionCard
title="DSFA generieren"
description="Datenschutz-Folgenabschaetzung erstellen"
description="Datenschutz-Folgenabschätzung erstellen"
icon={
<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="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" />
@@ -405,7 +274,7 @@ export default function SDKDashboard() {
{/* Recent Activity */}
{state.commandBarHistory.length > 0 && (
<div>
<h2 className="text-lg font-semibold text-gray-900 mb-4">Letzte Aktivitaeten</h2>
<h2 className="text-lg font-semibold text-gray-900 mb-4">Letzte Aktivitäten</h2>
<div className="bg-white rounded-xl border border-gray-200 divide-y divide-gray-100">
{state.commandBarHistory.slice(0, 5).map(entry => (
<div key={entry.id} className="flex items-center gap-4 px-4 py-3">

View File

@@ -0,0 +1,129 @@
import { NextRequest, NextResponse } from 'next/server'
/**
* POST /api/admin/companion/feedback
* Submit feedback (bug report, feature request, general feedback)
* Proxy to backend /api/feedback
*/
export async function POST(request: NextRequest) {
try {
const body = await request.json()
// Validate required fields
if (!body.type || !body.title || !body.description) {
return NextResponse.json(
{
success: false,
error: 'Missing required fields: type, title, description',
},
{ status: 400 }
)
}
// Validate feedback type
const validTypes = ['bug', 'feature', 'feedback']
if (!validTypes.includes(body.type)) {
return NextResponse.json(
{
success: false,
error: 'Invalid feedback type. Must be: bug, feature, or feedback',
},
{ status: 400 }
)
}
// TODO: Replace with actual backend call
// const backendUrl = process.env.BACKEND_URL || 'http://localhost:8000'
// const response = await fetch(`${backendUrl}/api/feedback`, {
// method: 'POST',
// headers: {
// 'Content-Type': 'application/json',
// // Add auth headers
// },
// body: JSON.stringify({
// type: body.type,
// title: body.title,
// description: body.description,
// screenshot: body.screenshot,
// sessionId: body.sessionId,
// metadata: {
// ...body.metadata,
// source: 'companion',
// timestamp: new Date().toISOString(),
// userAgent: request.headers.get('user-agent'),
// },
// }),
// })
//
// if (!response.ok) {
// throw new Error(`Backend responded with ${response.status}`)
// }
//
// const data = await response.json()
// return NextResponse.json(data)
// Mock response - just acknowledge the submission
const feedbackId = `fb-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`
console.log('Feedback received:', {
id: feedbackId,
type: body.type,
title: body.title,
description: body.description.substring(0, 100) + '...',
hasScreenshot: !!body.screenshot,
sessionId: body.sessionId,
})
return NextResponse.json({
success: true,
message: 'Feedback submitted successfully',
data: {
feedbackId,
submittedAt: new Date().toISOString(),
},
})
} catch (error) {
console.error('Submit feedback error:', error)
return NextResponse.json(
{
success: false,
error: error instanceof Error ? error.message : 'Unknown error',
},
{ status: 500 }
)
}
}
/**
* GET /api/admin/companion/feedback
* Get feedback history (admin only)
*/
export async function GET(request: NextRequest) {
try {
const { searchParams } = new URL(request.url)
const type = searchParams.get('type')
const limit = parseInt(searchParams.get('limit') || '10')
// TODO: Replace with actual backend call
// Mock response - empty list for now
return NextResponse.json({
success: true,
data: {
feedback: [],
total: 0,
page: 1,
limit,
},
})
} catch (error) {
console.error('Get feedback error:', error)
return NextResponse.json(
{
success: false,
error: error instanceof Error ? error.message : 'Unknown error',
},
{ status: 500 }
)
}
}

View File

@@ -0,0 +1,194 @@
import { NextRequest, NextResponse } from 'next/server'
/**
* POST /api/admin/companion/lesson
* Start a new lesson session
* Proxy to backend /api/classroom/sessions
*/
export async function POST(request: NextRequest) {
try {
const body = await request.json()
const backendUrl = process.env.BACKEND_URL || 'http://localhost:8000'
// TODO: Replace with actual backend call
// const response = await fetch(`${backendUrl}/api/classroom/sessions`, {
// method: 'POST',
// headers: {
// 'Content-Type': 'application/json',
// },
// body: JSON.stringify(body),
// })
//
// if (!response.ok) {
// throw new Error(`Backend responded with ${response.status}`)
// }
//
// const data = await response.json()
// return NextResponse.json(data)
// Mock response - create a new session
const sessionId = `session-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`
const mockSession = {
success: true,
data: {
sessionId,
classId: body.classId,
className: body.className || body.classId,
subject: body.subject,
topic: body.topic,
startTime: new Date().toISOString(),
phases: [
{ phase: 'einstieg', duration: 8, status: 'active', actualTime: 0 },
{ phase: 'erarbeitung', duration: 20, status: 'planned', actualTime: 0 },
{ phase: 'sicherung', duration: 10, status: 'planned', actualTime: 0 },
{ phase: 'transfer', duration: 7, status: 'planned', actualTime: 0 },
{ phase: 'reflexion', duration: 5, status: 'planned', actualTime: 0 },
],
totalPlannedDuration: 50,
currentPhaseIndex: 0,
elapsedTime: 0,
isPaused: false,
pauseDuration: 0,
overtimeMinutes: 0,
status: 'in_progress',
homeworkList: [],
materials: [],
},
}
return NextResponse.json(mockSession)
} catch (error) {
console.error('Start lesson error:', error)
return NextResponse.json(
{
success: false,
error: error instanceof Error ? error.message : 'Unknown error',
},
{ status: 500 }
)
}
}
/**
* GET /api/admin/companion/lesson
* Get current lesson session or list of recent sessions
*/
export async function GET(request: NextRequest) {
try {
const { searchParams } = new URL(request.url)
const sessionId = searchParams.get('sessionId')
// TODO: Replace with actual backend call
// const backendUrl = process.env.BACKEND_URL || 'http://localhost:8000'
// const url = sessionId
// ? `${backendUrl}/api/classroom/sessions/${sessionId}`
// : `${backendUrl}/api/classroom/sessions`
//
// const response = await fetch(url)
// const data = await response.json()
// return NextResponse.json(data)
// Mock response
if (sessionId) {
return NextResponse.json({
success: true,
data: null, // No active session stored on server in mock
})
}
return NextResponse.json({
success: true,
data: {
sessions: [], // Empty list for now
},
})
} catch (error) {
console.error('Get lesson error:', error)
return NextResponse.json(
{
success: false,
error: error instanceof Error ? error.message : 'Unknown error',
},
{ status: 500 }
)
}
}
/**
* PATCH /api/admin/companion/lesson
* Update lesson session (timer state, phase changes, etc.)
*/
export async function PATCH(request: NextRequest) {
try {
const body = await request.json()
const { sessionId, ...updates } = body
if (!sessionId) {
return NextResponse.json(
{ success: false, error: 'Session ID required' },
{ status: 400 }
)
}
// TODO: Replace with actual backend call
// const backendUrl = process.env.BACKEND_URL || 'http://localhost:8000'
// const response = await fetch(`${backendUrl}/api/classroom/sessions/${sessionId}`, {
// method: 'PATCH',
// headers: { 'Content-Type': 'application/json' },
// body: JSON.stringify(updates),
// })
//
// const data = await response.json()
// return NextResponse.json(data)
// Mock response - just acknowledge the update
return NextResponse.json({
success: true,
message: 'Session updated',
})
} catch (error) {
console.error('Update lesson error:', error)
return NextResponse.json(
{
success: false,
error: error instanceof Error ? error.message : 'Unknown error',
},
{ status: 500 }
)
}
}
/**
* DELETE /api/admin/companion/lesson
* End/delete a lesson session
*/
export async function DELETE(request: NextRequest) {
try {
const { searchParams } = new URL(request.url)
const sessionId = searchParams.get('sessionId')
if (!sessionId) {
return NextResponse.json(
{ success: false, error: 'Session ID required' },
{ status: 400 }
)
}
// TODO: Replace with actual backend call
return NextResponse.json({
success: true,
message: 'Session ended',
})
} catch (error) {
console.error('End lesson error:', error)
return NextResponse.json(
{
success: false,
error: error instanceof Error ? error.message : 'Unknown error',
},
{ status: 500 }
)
}
}

View File

@@ -0,0 +1,102 @@
import { NextResponse } from 'next/server'
/**
* GET /api/admin/companion
* Proxy to backend /api/state/dashboard for companion dashboard data
*/
export async function GET() {
try {
const backendUrl = process.env.BACKEND_URL || 'http://localhost:8000'
// TODO: Replace with actual backend call when endpoint is available
// const response = await fetch(`${backendUrl}/api/state/dashboard`, {
// method: 'GET',
// headers: {
// 'Content-Type': 'application/json',
// },
// })
//
// if (!response.ok) {
// throw new Error(`Backend responded with ${response.status}`)
// }
//
// const data = await response.json()
// return NextResponse.json(data)
// Mock response for development
const mockData = {
success: true,
data: {
context: {
currentPhase: 'erarbeitung',
phaseDisplayName: 'Erarbeitung',
},
stats: {
classesCount: 4,
studentsCount: 96,
learningUnitsCreated: 23,
gradesEntered: 156,
},
phases: [
{ id: 'einstieg', shortName: 'E', displayName: 'Einstieg', duration: 8, status: 'completed', color: '#4A90E2' },
{ id: 'erarbeitung', shortName: 'A', displayName: 'Erarbeitung', duration: 20, status: 'active', color: '#F5A623' },
{ id: 'sicherung', shortName: 'S', displayName: 'Sicherung', duration: 10, status: 'planned', color: '#7ED321' },
{ id: 'transfer', shortName: 'T', displayName: 'Transfer', duration: 7, status: 'planned', color: '#9013FE' },
{ id: 'reflexion', shortName: 'R', displayName: 'Reflexion', duration: 5, status: 'planned', color: '#6B7280' },
],
progress: {
percentage: 65,
completed: 13,
total: 20,
},
suggestions: [
{
id: '1',
title: 'Klausuren korrigieren',
description: 'Deutsch LK - 12 unkorrigierte Arbeiten warten',
priority: 'urgent',
icon: 'ClipboardCheck',
actionTarget: '/ai/klausur-korrektur',
estimatedTime: 120,
},
{
id: '2',
title: 'Elternsprechtag vorbereiten',
description: 'Notenuebersicht fuer 8b erstellen',
priority: 'high',
icon: 'Users',
actionTarget: '/education/grades',
estimatedTime: 30,
},
],
upcomingEvents: [
{
id: 'e1',
title: 'Mathe-Test 9b',
date: new Date(Date.now() + 2 * 24 * 60 * 60 * 1000).toISOString(),
type: 'exam',
inDays: 2,
},
{
id: 'e2',
title: 'Elternsprechtag',
date: new Date(Date.now() + 5 * 24 * 60 * 60 * 1000).toISOString(),
type: 'parent_meeting',
inDays: 5,
},
],
},
}
return NextResponse.json(mockData)
} catch (error) {
console.error('Companion dashboard error:', error)
return NextResponse.json(
{
success: false,
error: error instanceof Error ? error.message : 'Unknown error',
},
{ status: 500 }
)
}
}

View File

@@ -0,0 +1,137 @@
import { NextRequest, NextResponse } from 'next/server'
const DEFAULT_SETTINGS = {
defaultPhaseDurations: {
einstieg: 8,
erarbeitung: 20,
sicherung: 10,
transfer: 7,
reflexion: 5,
},
preferredLessonLength: 45,
autoAdvancePhases: true,
soundNotifications: true,
showKeyboardShortcuts: true,
highContrastMode: false,
onboardingCompleted: false,
}
/**
* GET /api/admin/companion/settings
* Get teacher settings
* Proxy to backend /api/teacher/settings
*/
export async function GET() {
try {
// TODO: Replace with actual backend call
// const backendUrl = process.env.BACKEND_URL || 'http://localhost:8000'
// const response = await fetch(`${backendUrl}/api/teacher/settings`, {
// method: 'GET',
// headers: {
// 'Content-Type': 'application/json',
// // Add auth headers
// },
// })
//
// if (!response.ok) {
// throw new Error(`Backend responded with ${response.status}`)
// }
//
// const data = await response.json()
// return NextResponse.json(data)
// Mock response - return default settings
return NextResponse.json({
success: true,
data: DEFAULT_SETTINGS,
})
} catch (error) {
console.error('Get settings error:', error)
return NextResponse.json(
{
success: false,
error: error instanceof Error ? error.message : 'Unknown error',
},
{ status: 500 }
)
}
}
/**
* PUT /api/admin/companion/settings
* Update teacher settings
*/
export async function PUT(request: NextRequest) {
try {
const body = await request.json()
// Validate the settings structure
if (!body || typeof body !== 'object') {
return NextResponse.json(
{ success: false, error: 'Invalid settings data' },
{ status: 400 }
)
}
// TODO: Replace with actual backend call
// const backendUrl = process.env.BACKEND_URL || 'http://localhost:8000'
// const response = await fetch(`${backendUrl}/api/teacher/settings`, {
// method: 'PUT',
// headers: {
// 'Content-Type': 'application/json',
// // Add auth headers
// },
// body: JSON.stringify(body),
// })
//
// if (!response.ok) {
// throw new Error(`Backend responded with ${response.status}`)
// }
//
// const data = await response.json()
// return NextResponse.json(data)
// Mock response - just acknowledge the save
return NextResponse.json({
success: true,
message: 'Settings saved',
data: body,
})
} catch (error) {
console.error('Save settings error:', error)
return NextResponse.json(
{
success: false,
error: error instanceof Error ? error.message : 'Unknown error',
},
{ status: 500 }
)
}
}
/**
* PATCH /api/admin/companion/settings
* Partially update teacher settings
*/
export async function PATCH(request: NextRequest) {
try {
const body = await request.json()
// TODO: Replace with actual backend call
return NextResponse.json({
success: true,
message: 'Settings updated',
data: body,
})
} catch (error) {
console.error('Update settings error:', error)
return NextResponse.json(
{
success: false,
error: error instanceof Error ? error.message : 'Unknown error',
},
{ status: 500 }
)
}
}

View File

@@ -0,0 +1,215 @@
import { NextRequest, NextResponse } from 'next/server'
// Backend URL - klausur-service
const KLAUSUR_SERVICE_URL = process.env.KLAUSUR_SERVICE_URL || 'http://localhost:8086'
// Helper to proxy requests to backend
async function proxyRequest(
endpoint: string,
method: string = 'GET',
body?: any
): Promise<Response> {
const url = `${KLAUSUR_SERVICE_URL}${endpoint}`
const options: RequestInit = {
method,
headers: {
'Content-Type': 'application/json',
},
}
if (body) {
options.body = JSON.stringify(body)
}
return fetch(url, options)
}
export async function GET(request: NextRequest) {
const searchParams = request.nextUrl.searchParams
const action = searchParams.get('action')
const jobId = searchParams.get('job_id')
const versionId = searchParams.get('version_id')
try {
let response: Response
switch (action) {
case 'jobs':
response = await proxyRequest('/api/v1/admin/training/jobs')
break
case 'job':
if (!jobId) {
return NextResponse.json({ error: 'job_id required' }, { status: 400 })
}
response = await proxyRequest(`/api/v1/admin/training/jobs/${jobId}`)
break
case 'models':
response = await proxyRequest('/api/v1/admin/training/models')
break
case 'model':
if (!versionId) {
return NextResponse.json({ error: 'version_id required' }, { status: 400 })
}
response = await proxyRequest(`/api/v1/admin/training/models/${versionId}`)
break
case 'dataset-stats':
response = await proxyRequest('/api/v1/admin/training/dataset/stats')
break
case 'status':
response = await proxyRequest('/api/v1/admin/training/status')
break
default:
return NextResponse.json(
{
error: 'Unknown action',
validActions: ['jobs', 'job', 'models', 'model', 'dataset-stats', 'status']
},
{ status: 400 }
)
}
if (!response.ok) {
const errorText = await response.text()
console.error(`Backend error: ${response.status} - ${errorText}`)
return NextResponse.json(
{ error: 'Backend error', detail: errorText },
{ status: response.status }
)
}
const data = await response.json()
return NextResponse.json(data)
} catch (error) {
console.error('Training API proxy error:', error)
return NextResponse.json(
{ error: 'Failed to connect to backend', detail: String(error) },
{ status: 503 }
)
}
}
export async function POST(request: NextRequest) {
const searchParams = request.nextUrl.searchParams
const action = searchParams.get('action')
const jobId = searchParams.get('job_id')
const versionId = searchParams.get('version_id')
try {
let response: Response
let body: any = null
// Parse body if present
try {
const text = await request.text()
if (text) {
body = JSON.parse(text)
}
} catch {
// No body or invalid JSON
}
switch (action) {
case 'create-job':
if (!body) {
return NextResponse.json({ error: 'Body required for job creation' }, { status: 400 })
}
response = await proxyRequest('/api/v1/admin/training/jobs', 'POST', body)
break
case 'pause':
if (!jobId) {
return NextResponse.json({ error: 'job_id required' }, { status: 400 })
}
response = await proxyRequest(`/api/v1/admin/training/jobs/${jobId}/pause`, 'POST')
break
case 'resume':
if (!jobId) {
return NextResponse.json({ error: 'job_id required' }, { status: 400 })
}
response = await proxyRequest(`/api/v1/admin/training/jobs/${jobId}/resume`, 'POST')
break
case 'cancel':
if (!jobId) {
return NextResponse.json({ error: 'job_id required' }, { status: 400 })
}
response = await proxyRequest(`/api/v1/admin/training/jobs/${jobId}/cancel`, 'POST')
break
case 'activate-model':
if (!versionId) {
return NextResponse.json({ error: 'version_id required' }, { status: 400 })
}
response = await proxyRequest(`/api/v1/admin/training/models/${versionId}/activate`, 'POST')
break
default:
return NextResponse.json(
{
error: 'Unknown action',
validActions: ['create-job', 'pause', 'resume', 'cancel', 'activate-model']
},
{ status: 400 }
)
}
if (!response.ok) {
const errorData = await response.json().catch(() => ({ detail: 'Unknown error' }))
return NextResponse.json(errorData, { status: response.status })
}
const data = await response.json()
return NextResponse.json(data)
} catch (error) {
console.error('Training API proxy error:', error)
return NextResponse.json(
{ error: 'Failed to connect to backend', detail: String(error) },
{ status: 503 }
)
}
}
export async function DELETE(request: NextRequest) {
const searchParams = request.nextUrl.searchParams
const jobId = searchParams.get('job_id')
const versionId = searchParams.get('version_id')
try {
let response: Response
if (jobId) {
response = await proxyRequest(`/api/v1/admin/training/jobs/${jobId}`, 'DELETE')
} else if (versionId) {
response = await proxyRequest(`/api/v1/admin/training/models/${versionId}`, 'DELETE')
} else {
return NextResponse.json(
{ error: 'Either job_id or version_id required' },
{ status: 400 }
)
}
if (!response.ok) {
const errorData = await response.json().catch(() => ({ detail: 'Unknown error' }))
return NextResponse.json(errorData, { status: response.status })
}
const data = await response.json()
return NextResponse.json(data)
} catch (error) {
console.error('Training API proxy error:', error)
return NextResponse.json(
{ error: 'Failed to connect to backend', detail: String(error) },
{ status: 503 }
)
}
}

View File

@@ -0,0 +1,68 @@
/**
* Content API Route
*
* GET: Load current website content
* POST: Save changed content (Admin only)
*/
import { NextRequest, NextResponse } from 'next/server'
import { getContent, saveContent } from '@/lib/content'
import type { WebsiteContent } from '@/lib/content-types'
// GET - Load content
export async function GET() {
try {
const content = getContent()
return NextResponse.json(content)
} catch (error) {
console.error('Error loading content:', error)
return NextResponse.json(
{ error: 'Failed to load content' },
{ status: 500 }
)
}
}
// POST - Save content
export async function POST(request: NextRequest) {
try {
// Simple admin check via header or query
// In production: JWT/Session-based auth
const adminKey = request.headers.get('x-admin-key')
const expectedKey = process.env.ADMIN_API_KEY || 'breakpilot-admin-2024'
if (adminKey !== expectedKey) {
return NextResponse.json(
{ error: 'Unauthorized' },
{ status: 401 }
)
}
const content: WebsiteContent = await request.json()
// Validation
if (!content.hero || !content.features || !content.faq || !content.pricing) {
return NextResponse.json(
{ error: 'Invalid content structure' },
{ status: 400 }
)
}
const result = saveContent(content)
if (result.success) {
return NextResponse.json({ success: true, message: 'Content saved' })
} else {
return NextResponse.json(
{ error: result.error || 'Failed to save content' },
{ status: 500 }
)
}
} catch (error) {
console.error('Error saving content:', error)
return NextResponse.json(
{ error: error instanceof Error ? error.message : 'Failed to save content' },
{ status: 500 }
)
}
}

View File

@@ -0,0 +1,246 @@
import { NextRequest, NextResponse } from 'next/server'
/**
* Abitur-Archiv API Route
* Extends abitur-docs with theme search and enhanced filtering
*/
const BACKEND_URL = process.env.BACKEND_URL || 'http://localhost:8000'
export async function GET(request: NextRequest) {
try {
const { searchParams } = new URL(request.url)
// Check for theme/semantic search
const thema = searchParams.get('thema')
if (thema) {
// Use semantic search endpoint
return await handleSemanticSearch(thema, searchParams)
}
// Forward all query params to backend abitur-docs
const queryString = searchParams.toString()
const url = `${BACKEND_URL}/api/abitur-docs/${queryString ? `?${queryString}` : ''}`
const response = await fetch(url, {
method: 'GET',
headers: {
'Content-Type': 'application/json',
},
})
if (!response.ok) {
// Return mock data for development if backend is not available
if (response.status === 404 || response.status === 502) {
return NextResponse.json(getMockDocuments(searchParams))
}
throw new Error(`Backend responded with ${response.status}`)
}
const data = await response.json()
// If backend returns empty, use mock data for demo
if (data.documents && Array.isArray(data.documents) && data.documents.length === 0 && data.total === 0) {
return NextResponse.json(getMockDocuments(searchParams))
}
// Enhance response with theme information
return NextResponse.json({
...data,
themes: extractThemes(data.documents || [])
})
} catch (error) {
console.error('Abitur-Archiv error:', error)
return NextResponse.json(getMockDocuments(new URL(request.url).searchParams))
}
}
async function handleSemanticSearch(thema: string, searchParams: URLSearchParams) {
try {
// Try to call RAG search endpoint
const url = `${BACKEND_URL}/api/rag/search`
const response = await fetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
query: thema,
collection: 'abitur_documents',
limit: parseInt(searchParams.get('limit') || '20'),
filters: {
fach: searchParams.get('fach') || undefined,
jahr: searchParams.get('jahr') ? parseInt(searchParams.get('jahr')!) : undefined,
bundesland: searchParams.get('bundesland') || undefined,
niveau: searchParams.get('niveau') || undefined,
typ: searchParams.get('typ') || undefined,
}
}),
})
if (response.ok) {
const data = await response.json()
return NextResponse.json({
documents: data.results || [],
total: data.total || 0,
page: 1,
limit: parseInt(searchParams.get('limit') || '20'),
total_pages: 1,
search_query: thema
})
}
} catch (error) {
console.log('RAG search not available, falling back to mock')
}
// Fallback to filtered mock data
return NextResponse.json(getMockDocumentsWithTheme(thema, searchParams))
}
function getMockDocuments(searchParams: URLSearchParams) {
const page = parseInt(searchParams.get('page') || '1')
const limit = parseInt(searchParams.get('limit') || '20')
const fach = searchParams.get('fach')
const jahr = searchParams.get('jahr')
const bundesland = searchParams.get('bundesland')
const niveau = searchParams.get('niveau')
const typ = searchParams.get('typ')
// Generate mock documents
const allDocs = generateMockDocs()
// Apply filters
let filtered = allDocs
if (fach) filtered = filtered.filter(d => d.fach === fach)
if (jahr) filtered = filtered.filter(d => d.jahr === parseInt(jahr))
if (bundesland) filtered = filtered.filter(d => d.bundesland === bundesland)
if (niveau) filtered = filtered.filter(d => d.niveau === niveau)
if (typ) filtered = filtered.filter(d => d.typ === typ)
// Paginate
const start = (page - 1) * limit
const docs = filtered.slice(start, start + limit)
return {
documents: docs,
total: filtered.length,
page,
limit,
total_pages: Math.ceil(filtered.length / limit),
themes: extractThemes(docs)
}
}
function getMockDocumentsWithTheme(thema: string, searchParams: URLSearchParams) {
const limit = parseInt(searchParams.get('limit') || '20')
const allDocs = generateMockDocs()
// Simple theme matching (in production this would be semantic search)
const themaLower = thema.toLowerCase()
let filtered = allDocs
// Match theme to aufgabentyp keywords
if (themaLower.includes('gedicht')) {
filtered = filtered.filter(d => d.themes?.includes('gedichtanalyse'))
} else if (themaLower.includes('drama')) {
filtered = filtered.filter(d => d.themes?.includes('dramenanalyse'))
} else if (themaLower.includes('prosa') || themaLower.includes('roman')) {
filtered = filtered.filter(d => d.themes?.includes('prosaanalyse'))
} else if (themaLower.includes('eroerterung')) {
filtered = filtered.filter(d => d.themes?.includes('eroerterung'))
} else if (themaLower.includes('text') || themaLower.includes('analyse')) {
filtered = filtered.filter(d => d.themes?.includes('textanalyse'))
}
// Apply additional filters
const fach = searchParams.get('fach')
const jahr = searchParams.get('jahr')
if (fach) filtered = filtered.filter(d => d.fach === fach)
if (jahr) filtered = filtered.filter(d => d.jahr === parseInt(jahr))
return {
documents: filtered.slice(0, limit),
total: filtered.length,
page: 1,
limit,
total_pages: Math.ceil(filtered.length / limit),
search_query: thema,
themes: extractThemes(filtered)
}
}
function generateMockDocs() {
const faecher = ['deutsch', 'englisch']
const jahre = [2021, 2022, 2023, 2024, 2025]
const niveaus: Array<'eA' | 'gA'> = ['eA', 'gA']
const typen: Array<'aufgabe' | 'erwartungshorizont'> = ['aufgabe', 'erwartungshorizont']
const aufgabentypen = [
{ nummer: 'I', themes: ['textanalyse', 'sachtext'] },
{ nummer: 'II', themes: ['gedichtanalyse', 'lyrik'] },
{ nummer: 'III', themes: ['prosaanalyse', 'epik'] },
]
const docs = []
let id = 1
for (const jahr of jahre) {
for (const fach of faecher) {
for (const niveau of niveaus) {
for (const aufgabe of aufgabentypen) {
for (const typ of typen) {
const suffix = typ === 'erwartungshorizont' ? '_EWH' : ''
const dateiname = `${jahr}_${capitalize(fach)}_${niveau}_Aufgabe_${aufgabe.nummer}${suffix}.pdf`
docs.push({
id: `doc-${id++}`,
dateiname,
original_dateiname: dateiname,
bundesland: 'niedersachsen',
fach,
jahr,
niveau,
typ,
aufgaben_nummer: aufgabe.nummer,
themes: aufgabe.themes,
status: 'indexed' as const,
confidence: 0.92 + Math.random() * 0.08,
file_path: `/api/education/abitur-archiv/file/${dateiname}`,
file_size: Math.floor(Math.random() * 500000) + 100000,
indexed: true,
vector_ids: [`vec-${id}-1`, `vec-${id}-2`],
created_at: new Date(Date.now() - Math.random() * 365 * 24 * 60 * 60 * 1000).toISOString(),
updated_at: new Date().toISOString(),
})
}
}
}
}
}
return docs
}
function extractThemes(documents: any[]) {
const themeCounts = new Map<string, number>()
for (const doc of documents) {
const themes = doc.themes || []
for (const theme of themes) {
themeCounts.set(theme, (themeCounts.get(theme) || 0) + 1)
}
}
return Array.from(themeCounts.entries())
.map(([label, count]) => ({
label: capitalize(label),
count,
aufgabentyp: label,
}))
.sort((a, b) => b.count - a.count)
.slice(0, 10)
}
function capitalize(str: string): string {
return str.charAt(0).toUpperCase() + str.slice(1)
}

View File

@@ -0,0 +1,105 @@
import { NextRequest, NextResponse } from 'next/server'
/**
* Theme Suggestions API for Abitur-Archiv
* Returns autocomplete suggestions for semantic search
*/
const BACKEND_URL = process.env.BACKEND_URL || 'http://localhost:8000'
export async function GET(request: NextRequest) {
try {
const { searchParams } = new URL(request.url)
const query = searchParams.get('q') || ''
if (query.length < 2) {
return NextResponse.json({ suggestions: [], query })
}
// Try to get suggestions from backend
try {
const url = `${BACKEND_URL}/api/abitur-archiv/suggest?q=${encodeURIComponent(query)}`
const response = await fetch(url, {
method: 'GET',
headers: { 'Content-Type': 'application/json' },
})
if (response.ok) {
const data = await response.json()
return NextResponse.json(data)
}
} catch (error) {
console.log('Backend suggest not available, using static suggestions')
}
// Fallback to static suggestions
return NextResponse.json({
suggestions: getStaticSuggestions(query),
query
})
} catch (error) {
console.error('Suggest error:', error)
return NextResponse.json({ suggestions: [], query: '' })
}
}
function getStaticSuggestions(query: string) {
const allSuggestions = [
// Textanalyse
{ label: 'Textanalyse', count: 45, aufgabentyp: 'textanalyse', kategorie: 'Analyse' },
{ label: 'Textanalyse Sachtext', count: 28, aufgabentyp: 'textanalyse_pragmatisch', kategorie: 'Analyse' },
{ label: 'Textanalyse Rede', count: 12, aufgabentyp: 'textanalyse_rede', kategorie: 'Analyse' },
{ label: 'Textanalyse Kommentar', count: 8, aufgabentyp: 'textanalyse_kommentar', kategorie: 'Analyse' },
// Gedichtanalyse
{ label: 'Gedichtanalyse', count: 38, aufgabentyp: 'gedichtanalyse', kategorie: 'Lyrik' },
{ label: 'Gedichtanalyse Romantik', count: 15, aufgabentyp: 'gedichtanalyse', zeitraum: 'Romantik', kategorie: 'Lyrik' },
{ label: 'Gedichtanalyse Expressionismus', count: 12, aufgabentyp: 'gedichtanalyse', zeitraum: 'Expressionismus', kategorie: 'Lyrik' },
{ label: 'Gedichtanalyse Barock', count: 8, aufgabentyp: 'gedichtanalyse', zeitraum: 'Barock', kategorie: 'Lyrik' },
{ label: 'Gedichtanalyse Klassik', count: 10, aufgabentyp: 'gedichtanalyse', zeitraum: 'Klassik', kategorie: 'Lyrik' },
{ label: 'Gedichtanalyse Moderne', count: 14, aufgabentyp: 'gedichtanalyse', zeitraum: 'Moderne', kategorie: 'Lyrik' },
{ label: 'Gedichtvergleich', count: 18, aufgabentyp: 'gedichtvergleich', kategorie: 'Lyrik' },
// Dramenanalyse
{ label: 'Dramenanalyse', count: 28, aufgabentyp: 'dramenanalyse', kategorie: 'Drama' },
{ label: 'Dramenanalyse Faust', count: 14, aufgabentyp: 'dramenanalyse', kategorie: 'Drama' },
{ label: 'Dramenanalyse Woyzeck', count: 8, aufgabentyp: 'dramenanalyse', kategorie: 'Drama' },
{ label: 'Episches Theater Brecht', count: 10, aufgabentyp: 'dramenanalyse', kategorie: 'Drama' },
{ label: 'Szenenanalyse', count: 22, aufgabentyp: 'szenenanalyse', kategorie: 'Drama' },
// Prosaanalyse
{ label: 'Prosaanalyse', count: 25, aufgabentyp: 'prosaanalyse', kategorie: 'Epik' },
{ label: 'Romananalyse', count: 18, aufgabentyp: 'prosaanalyse', kategorie: 'Epik' },
{ label: 'Kurzgeschichte', count: 20, aufgabentyp: 'prosaanalyse', kategorie: 'Epik' },
{ label: 'Novelle', count: 12, aufgabentyp: 'prosaanalyse', kategorie: 'Epik' },
{ label: 'Erzaehlung', count: 15, aufgabentyp: 'prosaanalyse', kategorie: 'Epik' },
// Eroerterung
{ label: 'Eroerterung', count: 32, aufgabentyp: 'eroerterung', kategorie: 'Argumentation' },
{ label: 'Eroerterung textgebunden', count: 18, aufgabentyp: 'eroerterung_textgebunden', kategorie: 'Argumentation' },
{ label: 'Eroerterung materialgestuetzt', count: 14, aufgabentyp: 'eroerterung_materialgestuetzt', kategorie: 'Argumentation' },
{ label: 'Stellungnahme', count: 10, aufgabentyp: 'stellungnahme', kategorie: 'Argumentation' },
// Sprachreflexion
{ label: 'Sprachreflexion', count: 15, aufgabentyp: 'sprachreflexion', kategorie: 'Sprache' },
{ label: 'Sprachwandel', count: 8, aufgabentyp: 'sprachreflexion', kategorie: 'Sprache' },
{ label: 'Sprachkritik', count: 6, aufgabentyp: 'sprachreflexion', kategorie: 'Sprache' },
{ label: 'Kommunikation', count: 10, aufgabentyp: 'kommunikation', kategorie: 'Sprache' },
// Vergleich
{ label: 'Vergleichende Analyse', count: 20, aufgabentyp: 'vergleich', kategorie: 'Vergleich' },
{ label: 'Epochenvergleich', count: 12, aufgabentyp: 'epochenvergleich', kategorie: 'Vergleich' },
]
const queryLower = query.toLowerCase()
// Filter suggestions based on query
return allSuggestions
.filter(s =>
s.label.toLowerCase().includes(queryLower) ||
s.aufgabentyp.toLowerCase().includes(queryLower) ||
(s.zeitraum && s.zeitraum.toLowerCase().includes(queryLower)) ||
s.kategorie.toLowerCase().includes(queryLower)
)
.slice(0, 8)
}

View File

@@ -0,0 +1,139 @@
import { NextRequest, NextResponse } from 'next/server'
/**
* Proxy to backend /api/abitur-docs
* Lists and manages Abitur documents (NiBiS, etc.)
*/
const BACKEND_URL = process.env.BACKEND_URL || 'http://localhost:8000'
export async function GET(request: NextRequest) {
try {
const { searchParams } = new URL(request.url)
// Forward all query params to backend
const queryString = searchParams.toString()
const url = `${BACKEND_URL}/api/abitur-docs/${queryString ? `?${queryString}` : ''}`
const response = await fetch(url, {
method: 'GET',
headers: {
'Content-Type': 'application/json',
},
})
if (!response.ok) {
// Return mock data for development if backend is not available
if (response.status === 404 || response.status === 502) {
return NextResponse.json(getMockDocuments(searchParams))
}
throw new Error(`Backend responded with ${response.status}`)
}
const data = await response.json()
// If backend returns empty array, use mock data for demo purposes
// (Backend uses in-memory storage which is lost on restart)
if (Array.isArray(data) && data.length === 0) {
console.log('Backend returned empty array, using mock data')
return NextResponse.json(getMockDocuments(searchParams))
}
// Handle paginated response with empty documents
if (data.documents && Array.isArray(data.documents) && data.documents.length === 0 && data.total === 0) {
console.log('Backend returned empty documents, using mock data')
return NextResponse.json(getMockDocuments(searchParams))
}
return NextResponse.json(data)
} catch (error) {
console.error('Abitur docs list error:', error)
// Return mock data for development
return NextResponse.json(getMockDocuments(new URL(request.url).searchParams))
}
}
function getMockDocuments(searchParams: URLSearchParams) {
const page = parseInt(searchParams.get('page') || '1')
const limit = parseInt(searchParams.get('limit') || '20')
const fach = searchParams.get('fach')
const jahr = searchParams.get('jahr')
const bundesland = searchParams.get('bundesland')
// Generate mock documents
const allDocs = generateMockDocs()
// Apply filters
let filtered = allDocs
if (fach) {
filtered = filtered.filter(d => d.fach === fach)
}
if (jahr) {
filtered = filtered.filter(d => d.jahr === parseInt(jahr))
}
if (bundesland) {
filtered = filtered.filter(d => d.bundesland === bundesland)
}
// Paginate
const start = (page - 1) * limit
const docs = filtered.slice(start, start + limit)
return {
documents: docs,
total: filtered.length,
page,
limit,
total_pages: Math.ceil(filtered.length / limit),
}
}
function generateMockDocs() {
const faecher = ['deutsch', 'mathematik', 'englisch', 'biologie', 'physik', 'chemie', 'geschichte']
const jahre = [2024, 2025]
const niveaus = ['eA', 'gA']
const typen = ['aufgabe', 'erwartungshorizont']
const nummern = ['I', 'II', 'III']
const docs = []
let id = 1
for (const jahr of jahre) {
for (const fach of faecher) {
for (const niveau of niveaus) {
for (const nummer of nummern) {
for (const typ of typen) {
const suffix = typ === 'erwartungshorizont' ? '_EWH' : ''
const dateiname = `${jahr}_${capitalize(fach)}_${niveau}_${nummer}${suffix}.pdf`
docs.push({
id: `doc-${id++}`,
dateiname,
original_dateiname: dateiname,
bundesland: 'niedersachsen',
fach,
jahr,
niveau,
typ,
aufgaben_nummer: nummer,
status: 'indexed',
confidence: 0.95,
file_path: `/tmp/abitur-docs/${dateiname}`,
file_size: Math.floor(Math.random() * 500000) + 100000,
indexed: true,
vector_ids: [`vec-${id}-1`, `vec-${id}-2`],
created_at: new Date(Date.now() - Math.random() * 30 * 24 * 60 * 60 * 1000).toISOString(),
updated_at: new Date().toISOString(),
})
}
}
}
}
}
return docs
}
function capitalize(str: string): string {
return str.charAt(0).toUpperCase() + str.slice(1)
}

View File

@@ -0,0 +1,395 @@
import { NextRequest, NextResponse } from 'next/server'
import type { ExtractedError, ErrorCategory, LogExtractionResponse } from '@/types/infrastructure-modules'
// Woodpecker API configuration
const WOODPECKER_URL = process.env.WOODPECKER_URL || 'http://woodpecker-server:8000'
const WOODPECKER_TOKEN = process.env.WOODPECKER_TOKEN || ''
// =============================================================================
// Error Pattern Matching
// =============================================================================
interface ErrorPattern {
pattern: RegExp
category: ErrorCategory
extractMessage?: (match: RegExpMatchArray, line: string) => string
}
/**
* Patterns fuer verschiedene Fehlertypen in CI/CD Logs
*/
const ERROR_PATTERNS: ErrorPattern[] = [
// Test Failures
{
pattern: /^(FAIL|FAILED|ERROR):?\s+(.+)$/i,
category: 'test_failure',
extractMessage: (match, line) => match[2] || line,
},
{
pattern: /^---\s+FAIL:\s+(.+)\s+\([\d.]+s\)$/,
category: 'test_failure',
extractMessage: (match) => `Test failed: ${match[1]}`,
},
{
pattern: /pytest.*FAILED\s+(.+)$/,
category: 'test_failure',
extractMessage: (match) => `pytest: ${match[1]}`,
},
{
pattern: /AssertionError:\s+(.+)$/,
category: 'test_failure',
extractMessage: (match) => `Assertion failed: ${match[1]}`,
},
{
pattern: /FAIL\s+[\w\/]+\s+\[build failed\]/,
category: 'build_error',
},
// Build Errors
{
pattern: /^(error|Error)\[[\w-]+\]:\s+(.+)$/,
category: 'build_error',
extractMessage: (match) => match[2],
},
{
pattern: /cannot find (module|package)\s+["'](.+)["']/i,
category: 'build_error',
extractMessage: (match) => `Missing ${match[1]}: ${match[2]}`,
},
{
pattern: /undefined:\s+(.+)$/,
category: 'build_error',
extractMessage: (match) => `Undefined: ${match[1]}`,
},
{
pattern: /compilation failed/i,
category: 'build_error',
},
{
pattern: /npm ERR!\s+(.+)$/,
category: 'build_error',
extractMessage: (match) => `npm error: ${match[1]}`,
},
{
pattern: /go:\s+(.+):\s+(.+)$/,
category: 'build_error',
extractMessage: (match) => `Go: ${match[1]}: ${match[2]}`,
},
// Security Warnings
{
pattern: /\[CRITICAL\]\s+(.+)$/i,
category: 'security_warning',
extractMessage: (match) => `Critical: ${match[1]}`,
},
{
pattern: /\[HIGH\]\s+(.+)$/i,
category: 'security_warning',
extractMessage: (match) => `High severity: ${match[1]}`,
},
{
pattern: /CVE-\d{4}-\d+/,
category: 'security_warning',
extractMessage: (match, line) => line.trim(),
},
{
pattern: /vulnerability found/i,
category: 'security_warning',
},
{
pattern: /secret.*detected/i,
category: 'security_warning',
},
{
pattern: /gitleaks.*found/i,
category: 'security_warning',
},
{
pattern: /semgrep.*finding/i,
category: 'security_warning',
},
// License Violations
{
pattern: /license.*violation/i,
category: 'license_violation',
},
{
pattern: /incompatible license/i,
category: 'license_violation',
},
{
pattern: /AGPL|GPL-3|SSPL/,
category: 'license_violation',
extractMessage: (match, line) => `Potentially problematic license found: ${match[0]}`,
},
// Dependency Issues
{
pattern: /dependency.*not found/i,
category: 'dependency_issue',
},
{
pattern: /outdated.*dependency/i,
category: 'dependency_issue',
},
{
pattern: /version conflict/i,
category: 'dependency_issue',
},
]
/**
* Patterns to extract file paths from error lines
*/
const FILE_PATH_PATTERNS = [
/([\/\w.-]+\.(go|py|ts|tsx|js|jsx|rs)):(\d+)/,
/File "([^"]+)", line (\d+)/,
/at ([\/\w.-]+):(\d+):\d+/,
]
/**
* Patterns to extract service names from log lines or paths
*/
const SERVICE_PATTERNS = [
/service[s]?\/([a-z-]+)/i,
/\/([a-z-]+-service)\//i,
/^([a-z-]+):\s/,
]
// =============================================================================
// Log Parsing Functions
// =============================================================================
interface LogLine {
pos: number
out: string
time: number
}
function extractFilePath(line: string): { path?: string; lineNumber?: number } {
for (const pattern of FILE_PATH_PATTERNS) {
const match = line.match(pattern)
if (match) {
return {
path: match[1],
lineNumber: parseInt(match[2] || match[3], 10) || undefined,
}
}
}
return {}
}
function extractService(line: string, filePath?: string): string | undefined {
// First try to extract from file path
if (filePath) {
for (const pattern of SERVICE_PATTERNS) {
const match = filePath.match(pattern)
if (match) return match[1]
}
}
// Then try from the line itself
for (const pattern of SERVICE_PATTERNS) {
const match = line.match(pattern)
if (match) return match[1]
}
return undefined
}
function parseLogLines(logs: LogLine[], stepName: string): ExtractedError[] {
const errors: ExtractedError[] = []
const seenMessages = new Set<string>()
for (const logLine of logs) {
const line = logLine.out.trim()
if (!line) continue
for (const errorPattern of ERROR_PATTERNS) {
const match = line.match(errorPattern.pattern)
if (match) {
const message = errorPattern.extractMessage
? errorPattern.extractMessage(match, line)
: line
// Deduplicate similar errors
const messageKey = `${errorPattern.category}:${message.substring(0, 100)}`
if (seenMessages.has(messageKey)) continue
seenMessages.add(messageKey)
const fileInfo = extractFilePath(line)
const service = extractService(line, fileInfo.path)
errors.push({
step: stepName,
line: logLine.pos,
message,
category: errorPattern.category,
file_path: fileInfo.path,
service,
})
break // Only match first pattern per line
}
}
}
return errors
}
// =============================================================================
// API Handler
// =============================================================================
/**
* POST /api/infrastructure/logs/extract
*
* Extrahiert Fehler aus Woodpecker Pipeline Logs.
*
* Request Body:
* - pipeline_number: number (required)
* - repo_id?: string (default: '1')
*
* Response:
* - errors: ExtractedError[]
* - pipeline_number: number
* - extracted_at: string
* - lines_parsed: number
*/
export async function POST(request: NextRequest) {
try {
const body = await request.json()
const { pipeline_number, repo_id = '1' } = body
if (!pipeline_number) {
return NextResponse.json(
{ error: 'pipeline_number ist erforderlich' },
{ status: 400 }
)
}
// 1. Fetch pipeline details to get step IDs
const pipelineResponse = await fetch(
`${WOODPECKER_URL}/api/repos/${repo_id}/pipelines/${pipeline_number}`,
{
headers: {
'Authorization': `Bearer ${WOODPECKER_TOKEN}`,
'Content-Type': 'application/json',
},
cache: 'no-store',
}
)
if (!pipelineResponse.ok) {
return NextResponse.json(
{ error: `Pipeline ${pipeline_number} nicht gefunden` },
{ status: 404 }
)
}
const pipeline = await pipelineResponse.json()
// 2. Extract step IDs from workflows
const failedSteps: { id: number; name: string }[] = []
if (pipeline.workflows) {
for (const workflow of pipeline.workflows) {
if (workflow.children) {
for (const child of workflow.children) {
if (child.state === 'failure' || child.state === 'error') {
failedSteps.push({
id: child.id,
name: child.name,
})
}
}
}
}
}
// 3. Fetch logs for each failed step
const allErrors: ExtractedError[] = []
let totalLinesParsed = 0
for (const step of failedSteps) {
try {
const logsResponse = await fetch(
`${WOODPECKER_URL}/api/repos/${repo_id}/pipelines/${pipeline_number}/logs/${step.id}`,
{
headers: {
'Authorization': `Bearer ${WOODPECKER_TOKEN}`,
'Content-Type': 'application/json',
},
}
)
if (logsResponse.ok) {
const logs: LogLine[] = await logsResponse.json()
totalLinesParsed += logs.length
const stepErrors = parseLogLines(logs, step.name)
allErrors.push(...stepErrors)
}
} catch (logError) {
console.error(`Failed to fetch logs for step ${step.name}:`, logError)
}
}
// 4. Sort errors by severity (security > license > build > test > dependency)
const categoryPriority: Record<ErrorCategory, number> = {
'security_warning': 1,
'license_violation': 2,
'build_error': 3,
'test_failure': 4,
'dependency_issue': 5,
}
allErrors.sort((a, b) => categoryPriority[a.category] - categoryPriority[b.category])
const response: LogExtractionResponse = {
errors: allErrors,
pipeline_number,
extracted_at: new Date().toISOString(),
lines_parsed: totalLinesParsed,
}
return NextResponse.json(response)
} catch (error) {
console.error('Log extraction error:', error)
return NextResponse.json(
{ error: 'Fehler bei der Log-Extraktion' },
{ status: 500 }
)
}
}
/**
* GET /api/infrastructure/logs/extract?pipeline_number=123
*
* Convenience method - calls POST internally
*/
export async function GET(request: NextRequest) {
const searchParams = request.nextUrl.searchParams
const pipeline_number = searchParams.get('pipeline_number')
const repo_id = searchParams.get('repo_id') || '1'
if (!pipeline_number) {
return NextResponse.json(
{ error: 'pipeline_number Query-Parameter ist erforderlich' },
{ status: 400 }
)
}
// Create a mock request with JSON body
const mockRequest = new NextRequest(request.url, {
method: 'POST',
body: JSON.stringify({ pipeline_number: parseInt(pipeline_number, 10), repo_id }),
headers: {
'Content-Type': 'application/json',
},
})
return POST(mockRequest)
}

View File

@@ -9,18 +9,10 @@ import { NextRequest, NextResponse } from 'next/server'
// Checkpoint definitions
const CHECKPOINTS = {
'CP-PROF': {
id: 'CP-PROF',
step: 'company-profile',
name: 'Unternehmensprofil Checkpoint',
type: 'REQUIRED',
blocksProgress: true,
requiresReview: 'NONE',
},
'CP-UC': {
id: 'CP-UC',
step: 'use-case-assessment',
name: 'Anwendungsfall Checkpoint',
step: 'use-case-workshop',
name: 'Use Case Checkpoint',
type: 'REQUIRED',
blocksProgress: true,
requiresReview: 'NONE',

View File

@@ -10,15 +10,14 @@ import { NextRequest, NextResponse } from 'next/server'
const SDK_STEPS = [
// Phase 1
{ id: 'company-profile', phase: 1, order: 1, name: 'Unternehmensprofil', url: '/sdk/company-profile' },
{ id: 'use-case-assessment', phase: 1, order: 2, name: 'Anwendungsfall-Erfassung', url: '/sdk/advisory-board' },
{ id: 'screening', phase: 1, order: 3, name: 'System Screening', url: '/sdk/screening' },
{ id: 'modules', phase: 1, order: 4, name: 'Compliance Modules', url: '/sdk/modules' },
{ id: 'requirements', phase: 1, order: 5, name: 'Requirements', url: '/sdk/requirements' },
{ id: 'controls', phase: 1, order: 6, name: 'Controls', url: '/sdk/controls' },
{ id: 'evidence', phase: 1, order: 7, name: 'Evidence', url: '/sdk/evidence' },
{ id: 'audit-checklist', phase: 1, order: 8, name: 'Audit Checklist', url: '/sdk/audit-checklist' },
{ id: 'risks', phase: 1, order: 9, name: 'Risk Matrix', url: '/sdk/risks' },
{ id: 'use-case-workshop', phase: 1, order: 1, name: 'Use Case Workshop', url: '/sdk/advisory-board' },
{ id: 'screening', phase: 1, order: 2, name: 'System Screening', url: '/sdk/screening' },
{ id: 'modules', phase: 1, order: 3, name: 'Compliance Modules', url: '/sdk/modules' },
{ id: 'requirements', phase: 1, order: 4, name: 'Requirements', url: '/sdk/requirements' },
{ id: 'controls', phase: 1, order: 5, name: 'Controls', url: '/sdk/controls' },
{ id: 'evidence', phase: 1, order: 6, name: 'Evidence', url: '/sdk/evidence' },
{ id: 'audit-checklist', phase: 1, order: 7, name: 'Audit Checklist', url: '/sdk/audit-checklist' },
{ id: 'risks', phase: 1, order: 8, name: 'Risk Matrix', url: '/sdk/risks' },
// Phase 2
{ id: 'ai-act', phase: 2, order: 1, name: 'AI Act Klassifizierung', url: '/sdk/ai-act' },
{ id: 'obligations', phase: 2, order: 2, name: 'Pflichtenübersicht', url: '/sdk/obligations' },
@@ -56,7 +55,7 @@ function getPreviousStep(currentStepId: string) {
export async function GET(request: NextRequest) {
try {
const { searchParams } = new URL(request.url)
const currentStepId = searchParams.get('currentStep') || 'company-profile'
const currentStepId = searchParams.get('currentStep') || 'use-case-workshop'
const currentStep = SDK_STEPS.find(s => s.id === currentStepId)
const nextStep = getNextStep(currentStepId)

View File

@@ -0,0 +1,273 @@
import { NextRequest, NextResponse } from 'next/server'
import type { WoodpeckerWebhookPayload, ExtractedError, BacklogSource } from '@/types/infrastructure-modules'
// =============================================================================
// Configuration
// =============================================================================
// Webhook secret for verification (optional but recommended)
const WEBHOOK_SECRET = process.env.WOODPECKER_WEBHOOK_SECRET || ''
// Internal API URL for log extraction
const LOG_EXTRACT_URL = process.env.NEXT_PUBLIC_APP_URL
? `${process.env.NEXT_PUBLIC_APP_URL}/api/infrastructure/log-extract/extract`
: 'http://localhost:3002/api/infrastructure/log-extract/extract'
// Test service API URL for backlog insertion
const TEST_SERVICE_URL = process.env.TEST_SERVICE_URL || 'http://localhost:8086'
// =============================================================================
// Helper Functions
// =============================================================================
/**
* Verify webhook signature (if secret is configured)
*/
function verifySignature(request: NextRequest, body: string): boolean {
if (!WEBHOOK_SECRET) return true // Skip verification if no secret configured
const signature = request.headers.get('X-Woodpecker-Signature')
if (!signature) return false
// Simple HMAC verification (Woodpecker uses SHA256)
const crypto = require('crypto')
const expectedSignature = crypto
.createHmac('sha256', WEBHOOK_SECRET)
.update(body)
.digest('hex')
return signature === `sha256=${expectedSignature}`
}
/**
* Map error category to backlog priority
*/
function categoryToPriority(category: string): 'critical' | 'high' | 'medium' | 'low' {
switch (category) {
case 'security_warning':
return 'critical'
case 'build_error':
return 'high'
case 'license_violation':
return 'high'
case 'test_failure':
return 'medium'
case 'dependency_issue':
return 'low'
default:
return 'medium'
}
}
/**
* Map error category to error_type for backlog
*/
function categoryToErrorType(category: string): string {
switch (category) {
case 'security_warning':
return 'security'
case 'build_error':
return 'build'
case 'license_violation':
return 'license'
case 'test_failure':
return 'test'
case 'dependency_issue':
return 'dependency'
default:
return 'unknown'
}
}
/**
* Insert extracted errors into backlog
*/
async function insertIntoBacklog(
errors: ExtractedError[],
pipelineNumber: number,
source: BacklogSource
): Promise<{ inserted: number; failed: number }> {
let inserted = 0
let failed = 0
for (const error of errors) {
try {
// Create backlog item
const backlogItem = {
test_name: error.message.substring(0, 200), // Truncate long messages
test_file: error.file_path || null,
service: error.service || 'unknown',
framework: `ci_cd_pipeline_${pipelineNumber}`,
error_message: error.message,
error_type: categoryToErrorType(error.category),
status: 'open',
priority: categoryToPriority(error.category),
fix_suggestion: error.suggested_fix || null,
notes: `Auto-generated from pipeline #${pipelineNumber}, step: ${error.step}, line: ${error.line}`,
source, // Custom field to track origin
}
// Try to insert into test service backlog
const response = await fetch(`${TEST_SERVICE_URL}/api/v1/backlog`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(backlogItem),
})
if (response.ok) {
inserted++
} else {
console.warn(`Failed to insert backlog item: ${response.status}`)
failed++
}
} catch (insertError) {
console.error('Backlog insertion error:', insertError)
failed++
}
}
return { inserted, failed }
}
// =============================================================================
// API Handler
// =============================================================================
/**
* POST /api/webhooks/woodpecker
*
* Webhook endpoint fuer Woodpecker CI/CD Events.
*
* Bei Pipeline-Failure:
* 1. Extrahiert Logs mit /api/infrastructure/logs/extract
* 2. Parsed Fehler nach Kategorie
* 3. Traegt automatisch in Backlog ein
*
* Request Body (Woodpecker Webhook Format):
* - event: 'pipeline_success' | 'pipeline_failure' | 'pipeline_started'
* - repo_id: number
* - pipeline_number: number
* - branch?: string
* - commit?: string
* - author?: string
* - message?: string
*/
export async function POST(request: NextRequest) {
try {
const bodyText = await request.text()
// Verify webhook signature
if (!verifySignature(request, bodyText)) {
return NextResponse.json(
{ error: 'Invalid webhook signature' },
{ status: 401 }
)
}
const payload: WoodpeckerWebhookPayload = JSON.parse(bodyText)
// Log all events for debugging
console.log(`Woodpecker webhook: ${payload.event} for pipeline #${payload.pipeline_number}`)
// Only process pipeline_failure events
if (payload.event !== 'pipeline_failure') {
return NextResponse.json({
status: 'ignored',
message: `Event ${payload.event} wird nicht verarbeitet`,
pipeline_number: payload.pipeline_number,
})
}
// 1. Extract logs from failed pipeline
console.log(`Extracting logs for failed pipeline #${payload.pipeline_number}`)
const extractResponse = await fetch(LOG_EXTRACT_URL, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
pipeline_number: payload.pipeline_number,
repo_id: String(payload.repo_id),
}),
})
if (!extractResponse.ok) {
const errorText = await extractResponse.text()
console.error('Log extraction failed:', errorText)
return NextResponse.json({
status: 'error',
message: 'Log-Extraktion fehlgeschlagen',
pipeline_number: payload.pipeline_number,
}, { status: 500 })
}
const extractionResult = await extractResponse.json()
const errors: ExtractedError[] = extractionResult.errors || []
console.log(`Extracted ${errors.length} errors from pipeline #${payload.pipeline_number}`)
// 2. Insert errors into backlog
if (errors.length > 0) {
const backlogResult = await insertIntoBacklog(
errors,
payload.pipeline_number,
'ci_cd'
)
console.log(`Backlog: ${backlogResult.inserted} inserted, ${backlogResult.failed} failed`)
return NextResponse.json({
status: 'processed',
pipeline_number: payload.pipeline_number,
branch: payload.branch,
commit: payload.commit,
errors_found: errors.length,
backlog_inserted: backlogResult.inserted,
backlog_failed: backlogResult.failed,
categories: {
test_failure: errors.filter(e => e.category === 'test_failure').length,
build_error: errors.filter(e => e.category === 'build_error').length,
security_warning: errors.filter(e => e.category === 'security_warning').length,
license_violation: errors.filter(e => e.category === 'license_violation').length,
dependency_issue: errors.filter(e => e.category === 'dependency_issue').length,
},
})
}
return NextResponse.json({
status: 'processed',
pipeline_number: payload.pipeline_number,
message: 'Keine Fehler extrahiert',
errors_found: 0,
})
} catch (error) {
console.error('Webhook processing error:', error)
return NextResponse.json(
{ error: 'Webhook-Verarbeitung fehlgeschlagen' },
{ status: 500 }
)
}
}
/**
* GET /api/webhooks/woodpecker
*
* Health check endpoint
*/
export async function GET() {
return NextResponse.json({
status: 'ready',
endpoint: '/api/webhooks/woodpecker',
events: ['pipeline_failure'],
description: 'Woodpecker CI/CD Webhook Handler',
configured: {
webhook_secret: WEBHOOK_SECRET ? 'yes' : 'no',
log_extract_url: LOG_EXTRACT_URL,
test_service_url: TEST_SERVICE_URL,
},
})
}

View File

@@ -0,0 +1,405 @@
'use client'
/**
* AI Module Sidebar
*
* Kompakte Sidebar-Komponente für Cross-Navigation zwischen AI-Modulen.
* Zeigt den Datenfluss und ermöglicht schnelle Navigation.
*
* Features:
* - Desktop: Fixierte Sidebar rechts
* - Mobile: Floating Action Button mit Slide-In Drawer
*/
import Link from 'next/link'
import { useState, useEffect } from 'react'
import { AI_MODULES, DATA_FLOW_CONNECTIONS, type AIModuleLink } from '@/types/ai-modules'
export interface AIModuleSidebarProps {
/** ID des aktuell aktiven Moduls */
currentModule: 'magic-help' | 'ocr-labeling' | 'rag-pipeline' | 'rag'
/** Optional: Kompakter Modus (nur Icons) */
compact?: boolean
/** Optional: Zusätzliche CSS-Klassen */
className?: string
}
export interface AIModuleSidebarResponsiveProps extends AIModuleSidebarProps {
/** Position des FAB auf Mobile */
fabPosition?: 'bottom-right' | 'bottom-left'
}
// Icons für die Module
const ModuleIcon = ({ id }: { id: string }) => {
switch (id) {
case 'magic-help':
return (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2}
d="M5 3v4M3 5h4M6 17v4m-2-2h4m5-16l2.286 6.857L21 12l-5.714 2.143L13 21l-2.286-6.857L5 12l5.714-2.143L13 3z" />
</svg>
)
case 'ocr-labeling':
return (
<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>
)
case 'rag-pipeline':
return (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
</svg>
)
case 'rag':
return (
<svg className="w-5 h-5" 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>
)
default:
return null
}
}
// Pfeil-Icon für Datenfluss
const ArrowIcon = () => (
<svg className="w-4 h-4 text-slate-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M17 8l4 4m0 0l-4 4m4-4H3" />
</svg>
)
export function AIModuleSidebar({
currentModule,
compact = false,
className = '',
}: AIModuleSidebarProps) {
const [isExpanded, setIsExpanded] = useState(!compact)
return (
<div className={`bg-white dark:bg-gray-800 rounded-xl shadow-sm border border-slate-200 dark:border-gray-700 overflow-hidden ${className}`}>
{/* Header */}
<div
className="px-4 py-3 bg-gradient-to-r from-teal-50 to-cyan-50 dark:from-teal-900/20 dark:to-cyan-900/20 border-b border-slate-200 dark:border-gray-700 cursor-pointer"
onClick={() => setIsExpanded(!isExpanded)}
>
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<span className="text-teal-600 dark:text-teal-400">
<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>
</span>
<span className="font-semibold text-slate-700 dark:text-slate-200 text-sm">
KI-Daten-Pipeline
</span>
</div>
<svg
className={`w-4 h-4 text-slate-400 transition-transform ${isExpanded ? '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>
</div>
</div>
{/* Content */}
{isExpanded && (
<div className="p-3 space-y-3">
{/* Module Links */}
<div className="space-y-1">
{AI_MODULES.map((module) => (
<Link
key={module.id}
href={module.href}
className={`flex items-center gap-3 px-3 py-2 rounded-lg transition-colors ${
currentModule === module.id
? 'bg-teal-100 dark:bg-teal-900/30 text-teal-700 dark:text-teal-300 font-medium'
: 'text-slate-600 dark:text-slate-400 hover:bg-slate-100 dark:hover:bg-gray-700'
}`}
>
<ModuleIcon id={module.id} />
<div className="flex-1 min-w-0">
<div className="text-sm font-medium truncate">{module.name}</div>
<div className="text-xs text-slate-500 dark:text-slate-500 truncate">
{module.description}
</div>
</div>
{currentModule === module.id && (
<span className="flex-shrink-0 w-2 h-2 bg-teal-500 rounded-full" />
)}
</Link>
))}
</div>
{/* Datenfluss-Visualisierung */}
<div className="pt-2 border-t border-slate-200 dark:border-gray-700">
<div className="text-xs font-medium text-slate-500 dark:text-slate-400 mb-2 px-1">
Datenfluss
</div>
<div className="flex items-center justify-between px-2 py-2 bg-slate-50 dark:bg-gray-900 rounded-lg">
<div className="flex items-center gap-1.5 text-xs">
<span title="Magic Help"></span>
<span className="text-slate-400" title="Bidirektional"></span>
<span title="OCR-Labeling">🏷</span>
<ArrowIcon />
<span title="RAG Pipeline">🔄</span>
<ArrowIcon />
<span title="Daten & RAG">🔍</span>
</div>
</div>
</div>
{/* Quick Info zum aktuellen Modul */}
<div className="pt-2 border-t border-slate-200 dark:border-gray-700">
<div className="text-xs text-slate-500 dark:text-slate-400 px-1">
{currentModule === 'magic-help' && (
<span>Testen und verbessern Sie die TrOCR-Handschrifterkennung</span>
)}
{currentModule === 'ocr-labeling' && (
<span>Erstellen Sie Ground Truth Daten für das OCR-Training</span>
)}
{currentModule === 'rag-pipeline' && (
<span>Indexieren Sie Dokumente für die semantische Suche</span>
)}
{currentModule === 'rag' && (
<span>Verwalten und durchsuchen Sie indexierte Dokumente</span>
)}
</div>
</div>
</div>
)}
</div>
)
}
/**
* Kompakte Inline-Version für mobile Ansichten
*/
export function AIModuleNav({ currentModule }: { currentModule: string }) {
return (
<div className="flex items-center gap-1 p-1 bg-slate-100 dark:bg-gray-800 rounded-lg">
{AI_MODULES.map((module) => (
<Link
key={module.id}
href={module.href}
className={`px-3 py-1.5 text-sm rounded-md transition-colors ${
currentModule === module.id
? 'bg-white dark:bg-gray-700 text-teal-600 dark:text-teal-400 font-medium shadow-sm'
: 'text-slate-600 dark:text-slate-400 hover:text-slate-900 dark:hover:text-slate-200'
}`}
title={module.description}
>
{module.name}
</Link>
))}
</div>
)
}
/**
* Responsive Sidebar mit Mobile FAB + Drawer
*
* Desktop (xl+): Fixierte Sidebar rechts
* Mobile/Tablet: Floating Action Button unten rechts, öffnet Drawer
*/
export function AIModuleSidebarResponsive({
currentModule,
compact = false,
className = '',
fabPosition = 'bottom-right',
}: AIModuleSidebarResponsiveProps) {
const [isMobileOpen, setIsMobileOpen] = useState(false)
// Close drawer on route change or escape key
useEffect(() => {
const handleEscape = (e: KeyboardEvent) => {
if (e.key === 'Escape') setIsMobileOpen(false)
}
window.addEventListener('keydown', handleEscape)
return () => window.removeEventListener('keydown', handleEscape)
}, [])
// Prevent body scroll when drawer is open
useEffect(() => {
if (isMobileOpen) {
document.body.style.overflow = 'hidden'
} else {
document.body.style.overflow = ''
}
return () => {
document.body.style.overflow = ''
}
}, [isMobileOpen])
const fabPositionClasses = fabPosition === 'bottom-right'
? 'right-4 bottom-20'
: 'left-4 bottom-20'
return (
<>
{/* Desktop: Fixed Sidebar */}
<div className={`hidden xl:block fixed right-6 top-24 w-64 z-10 ${className}`}>
<AIModuleSidebar currentModule={currentModule} compact={compact} />
</div>
{/* Mobile/Tablet: FAB */}
<button
onClick={() => setIsMobileOpen(true)}
className={`xl:hidden fixed ${fabPositionClasses} z-40 w-14 h-14 bg-gradient-to-r from-teal-500 to-cyan-500 text-white rounded-full shadow-lg hover:shadow-xl transition-all flex items-center justify-center group`}
aria-label="KI-Daten-Pipeline Navigation öffnen"
>
<svg
className="w-6 h-6 group-hover:scale-110 transition-transform"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 10V3L4 14h7v7l9-11h-7z" />
</svg>
{/* Pulse indicator */}
<span className="absolute -top-1 -right-1 w-3 h-3 bg-orange-400 rounded-full animate-pulse" />
</button>
{/* Mobile/Tablet: Drawer Overlay */}
{isMobileOpen && (
<div className="xl:hidden fixed inset-0 z-50">
{/* Backdrop */}
<div
className="absolute inset-0 bg-black/50 backdrop-blur-sm transition-opacity"
onClick={() => setIsMobileOpen(false)}
/>
{/* Drawer */}
<div className="absolute right-0 top-0 bottom-0 w-80 max-w-[85vw] bg-white dark:bg-gray-900 shadow-2xl transform transition-transform animate-slide-in-right">
{/* Drawer Header */}
<div className="flex items-center justify-between px-4 py-4 border-b border-slate-200 dark:border-gray-700 bg-gradient-to-r from-teal-50 to-cyan-50 dark:from-teal-900/20 dark:to-cyan-900/20">
<div className="flex items-center gap-2">
<span className="text-teal-600 dark:text-teal-400">
<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>
</span>
<span className="font-semibold text-slate-700 dark:text-slate-200">
KI-Daten-Pipeline
</span>
</div>
<button
onClick={() => setIsMobileOpen(false)}
className="p-2 text-slate-400 hover:text-slate-600 dark:hover:text-slate-300 rounded-lg hover:bg-slate-100 dark:hover:bg-gray-800 transition-colors"
aria-label="Schließen"
>
<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>
{/* Drawer Content */}
<div className="p-4 space-y-4 overflow-y-auto max-h-[calc(100vh-80px)]">
{/* Module Links */}
<div className="space-y-2">
{AI_MODULES.map((module) => (
<Link
key={module.id}
href={module.href}
onClick={() => setIsMobileOpen(false)}
className={`flex items-center gap-3 px-4 py-3 rounded-xl transition-all ${
currentModule === module.id
? 'bg-teal-100 dark:bg-teal-900/30 text-teal-700 dark:text-teal-300 font-medium shadow-sm'
: 'text-slate-600 dark:text-slate-400 hover:bg-slate-100 dark:hover:bg-gray-800'
}`}
>
<ModuleIcon id={module.id} />
<div className="flex-1">
<div className="text-base font-medium">{module.name}</div>
<div className="text-sm text-slate-500 dark:text-slate-500">
{module.description}
</div>
</div>
{currentModule === module.id && (
<span className="flex-shrink-0 w-2.5 h-2.5 bg-teal-500 rounded-full" />
)}
</Link>
))}
</div>
{/* Datenfluss-Visualisierung */}
<div className="pt-4 border-t border-slate-200 dark:border-gray-700">
<div className="text-sm font-medium text-slate-500 dark:text-slate-400 mb-3">
Datenfluss
</div>
<div className="flex items-center justify-center gap-2 p-4 bg-slate-50 dark:bg-gray-800 rounded-xl">
<div className="flex flex-col items-center">
<span className="text-2xl"></span>
<span className="text-xs text-slate-500 mt-1">Magic</span>
</div>
<span className="text-slate-400 text-lg" title="Bidirektional"></span>
<div className="flex flex-col items-center">
<span className="text-2xl">🏷</span>
<span className="text-xs text-slate-500 mt-1">OCR</span>
</div>
<ArrowIcon />
<div className="flex flex-col items-center">
<span className="text-2xl">🔄</span>
<span className="text-xs text-slate-500 mt-1">Pipeline</span>
</div>
<ArrowIcon />
<div className="flex flex-col items-center">
<span className="text-2xl">🔍</span>
<span className="text-xs text-slate-500 mt-1">RAG</span>
</div>
</div>
</div>
{/* Quick Info */}
<div className="pt-4 border-t border-slate-200 dark:border-gray-700">
<div className="text-sm text-slate-600 dark:text-slate-400 p-3 bg-slate-50 dark:bg-gray-800 rounded-xl">
{currentModule === 'magic-help' && (
<>
<strong className="text-slate-700 dark:text-slate-300">Aktuell:</strong> TrOCR-Handschrifterkennung testen und verbessern
</>
)}
{currentModule === 'ocr-labeling' && (
<>
<strong className="text-slate-700 dark:text-slate-300">Aktuell:</strong> Ground Truth Daten für OCR-Training erstellen
</>
)}
{currentModule === 'rag-pipeline' && (
<>
<strong className="text-slate-700 dark:text-slate-300">Aktuell:</strong> Dokumente für semantische Suche indexieren
</>
)}
{currentModule === 'rag' && (
<>
<strong className="text-slate-700 dark:text-slate-300">Aktuell:</strong> Indexierte Dokumente verwalten und durchsuchen
</>
)}
</div>
</div>
</div>
</div>
</div>
)}
{/* CSS for slide-in animation */}
<style jsx>{`
@keyframes slide-in-right {
from {
transform: translateX(100%);
}
to {
transform: translateX(0);
}
}
.animate-slide-in-right {
animation: slide-in-right 0.2s ease-out;
}
`}</style>
</>
)
}
export default AIModuleSidebar

View File

@@ -0,0 +1,406 @@
'use client'
/**
* AI Tools Sidebar
*
* Kompakte Sidebar-Komponente für Cross-Navigation zwischen KI-Werkzeugen.
* Zeigt Shared Resources und ermöglicht schnelle Navigation.
*
* Features:
* - Desktop: Fixierte Sidebar rechts
* - Mobile: Floating Action Button mit Slide-In Drawer
*/
import Link from 'next/link'
import { useState, useEffect } from 'react'
export type AIToolId = 'llm-compare' | 'test-quality' | 'gpu'
export interface AIToolModule {
id: AIToolId
name: string
href: string
description: string
icon: string
}
export const AI_TOOLS_MODULES: AIToolModule[] = [
{
id: 'llm-compare',
name: 'LLM Vergleich',
href: '/ai/llm-compare',
description: 'KI-Provider vergleichen',
icon: '⚖️',
},
{
id: 'test-quality',
name: 'Test Quality (BQAS)',
href: '/ai/test-quality',
description: 'Golden Suite & Tests',
icon: '🧪',
},
{
id: 'gpu',
name: 'GPU Infrastruktur',
href: '/ai/gpu',
description: 'vast.ai Management',
icon: '🖥️',
},
]
export interface AIToolsSidebarProps {
/** ID des aktuell aktiven Tools */
currentTool: AIToolId
/** Optional: Kompakter Modus (nur Icons) */
compact?: boolean
/** Optional: Zusätzliche CSS-Klassen */
className?: string
}
export interface AIToolsSidebarResponsiveProps extends AIToolsSidebarProps {
/** Position des FAB auf Mobile */
fabPosition?: 'bottom-right' | 'bottom-left'
}
// Icons für die Tools
const ToolIcon = ({ id }: { id: string }) => {
switch (id) {
case 'llm-compare':
return (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2}
d="M3 6l3 1m0 0l-3 9a5.002 5.002 0 006.001 0M6 7l3 9M6 7l6-2m6 2l3-1m-3 1l-3 9a5.002 5.002 0 006.001 0M18 7l3 9m-3-9l-6-2m0-2v2m0 16V5m0 16H9m3 0h3" />
</svg>
)
case 'test-quality':
return (
<svg className="w-5 h-5" 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>
)
case 'gpu':
return (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2}
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>
)
default:
return null
}
}
// Werkzeug-Icon für Header
const WrenchIcon = () => (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2}
d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" />
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
)
export function AIToolsSidebar({
currentTool,
compact = false,
className = '',
}: AIToolsSidebarProps) {
const [isExpanded, setIsExpanded] = useState(!compact)
return (
<div className={`bg-white dark:bg-gray-800 rounded-xl shadow-sm border border-slate-200 dark:border-gray-700 overflow-hidden ${className}`}>
{/* Header */}
<div
className="px-4 py-3 bg-gradient-to-r from-violet-50 to-purple-50 dark:from-violet-900/20 dark:to-purple-900/20 border-b border-slate-200 dark:border-gray-700 cursor-pointer"
onClick={() => setIsExpanded(!isExpanded)}
>
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<span className="text-violet-600 dark:text-violet-400">
<WrenchIcon />
</span>
<span className="font-semibold text-slate-700 dark:text-slate-200 text-sm">
KI-Werkzeuge
</span>
</div>
<svg
className={`w-4 h-4 text-slate-400 transition-transform ${isExpanded ? '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>
</div>
</div>
{/* Content */}
{isExpanded && (
<div className="p-3 space-y-3">
{/* Tool Links */}
<div className="space-y-1">
{AI_TOOLS_MODULES.map((tool) => (
<Link
key={tool.id}
href={tool.href}
className={`flex items-center gap-3 px-3 py-2 rounded-lg transition-colors ${
currentTool === tool.id
? 'bg-violet-100 dark:bg-violet-900/30 text-violet-700 dark:text-violet-300 font-medium'
: 'text-slate-600 dark:text-slate-400 hover:bg-slate-100 dark:hover:bg-gray-700'
}`}
>
<ToolIcon id={tool.id} />
<div className="flex-1 min-w-0">
<div className="text-sm font-medium truncate">{tool.name}</div>
<div className="text-xs text-slate-500 dark:text-slate-500 truncate">
{tool.description}
</div>
</div>
{currentTool === tool.id && (
<span className="flex-shrink-0 w-2 h-2 bg-violet-500 rounded-full" />
)}
</Link>
))}
</div>
{/* Shared Resources Visualisierung */}
<div className="pt-2 border-t border-slate-200 dark:border-gray-700">
<div className="text-xs font-medium text-slate-500 dark:text-slate-400 mb-2 px-1">
Shared Resources
</div>
<div className="flex items-center justify-between px-2 py-2 bg-slate-50 dark:bg-gray-900 rounded-lg">
<div className="flex items-center gap-2 text-xs">
<span title="GPU Infrastruktur">🖥</span>
<span className="text-slate-400"></span>
<span title="LLM Vergleich"></span>
<span className="text-slate-400"></span>
<span title="Test Quality">🧪</span>
</div>
</div>
<div className="text-xs text-slate-400 mt-1 px-1">
GPU-Ressourcen fuer Modelle & Tests
</div>
</div>
{/* Quick Info zum aktuellen Tool */}
<div className="pt-2 border-t border-slate-200 dark:border-gray-700">
<div className="text-xs text-slate-500 dark:text-slate-400 px-1">
{currentTool === 'llm-compare' && (
<span>Vergleichen Sie LLM-Antworten verschiedener Provider</span>
)}
{currentTool === 'test-quality' && (
<span>Ueberwachen Sie die Qualitaet der KI-Ausgaben</span>
)}
{currentTool === 'gpu' && (
<span>Verwalten Sie GPU-Instanzen fuer ML-Training</span>
)}
</div>
</div>
</div>
)}
</div>
)
}
/**
* Responsive Tools Sidebar mit Mobile FAB + Drawer
*
* Desktop (xl+): Fixierte Sidebar rechts
* Mobile/Tablet: Floating Action Button unten rechts, öffnet Drawer
*/
export function AIToolsSidebarResponsive({
currentTool,
compact = false,
className = '',
fabPosition = 'bottom-right',
}: AIToolsSidebarResponsiveProps) {
const [isMobileOpen, setIsMobileOpen] = useState(false)
// Close drawer on escape key
useEffect(() => {
const handleEscape = (e: KeyboardEvent) => {
if (e.key === 'Escape') setIsMobileOpen(false)
}
window.addEventListener('keydown', handleEscape)
return () => window.removeEventListener('keydown', handleEscape)
}, [])
// Prevent body scroll when drawer is open
useEffect(() => {
if (isMobileOpen) {
document.body.style.overflow = 'hidden'
} else {
document.body.style.overflow = ''
}
return () => {
document.body.style.overflow = ''
}
}, [isMobileOpen])
const fabPositionClasses = fabPosition === 'bottom-right'
? 'right-4 bottom-20'
: 'left-4 bottom-20'
return (
<>
{/* Desktop: Fixed Sidebar */}
<div className={`hidden xl:block fixed right-6 top-24 w-64 z-10 ${className}`}>
<AIToolsSidebar currentTool={currentTool} compact={compact} />
</div>
{/* Mobile/Tablet: FAB */}
<button
onClick={() => setIsMobileOpen(true)}
className={`xl:hidden fixed ${fabPositionClasses} z-40 w-14 h-14 bg-gradient-to-r from-violet-500 to-purple-500 text-white rounded-full shadow-lg hover:shadow-xl transition-all flex items-center justify-center group`}
aria-label="KI-Werkzeuge Navigation oeffnen"
>
<WrenchIcon />
{/* Pulse indicator */}
<span className="absolute -top-1 -right-1 w-3 h-3 bg-amber-400 rounded-full animate-pulse" />
</button>
{/* Mobile/Tablet: Drawer Overlay */}
{isMobileOpen && (
<div className="xl:hidden fixed inset-0 z-50">
{/* Backdrop */}
<div
className="absolute inset-0 bg-black/50 backdrop-blur-sm transition-opacity"
onClick={() => setIsMobileOpen(false)}
/>
{/* Drawer */}
<div className="absolute right-0 top-0 bottom-0 w-80 max-w-[85vw] bg-white dark:bg-gray-900 shadow-2xl transform transition-transform animate-slide-in-right">
{/* Drawer Header */}
<div className="flex items-center justify-between px-4 py-4 border-b border-slate-200 dark:border-gray-700 bg-gradient-to-r from-violet-50 to-purple-50 dark:from-violet-900/20 dark:to-purple-900/20">
<div className="flex items-center gap-2">
<span className="text-violet-600 dark:text-violet-400">
<WrenchIcon />
</span>
<span className="font-semibold text-slate-700 dark:text-slate-200">
KI-Werkzeuge
</span>
</div>
<button
onClick={() => setIsMobileOpen(false)}
className="p-2 text-slate-400 hover:text-slate-600 dark:hover:text-slate-300 rounded-lg hover:bg-slate-100 dark:hover:bg-gray-800 transition-colors"
aria-label="Schliessen"
>
<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>
{/* Drawer Content */}
<div className="p-4 space-y-4 overflow-y-auto max-h-[calc(100vh-80px)]">
{/* Tool Links */}
<div className="space-y-2">
{AI_TOOLS_MODULES.map((tool) => (
<Link
key={tool.id}
href={tool.href}
onClick={() => setIsMobileOpen(false)}
className={`flex items-center gap-3 px-4 py-3 rounded-xl transition-all ${
currentTool === tool.id
? 'bg-violet-100 dark:bg-violet-900/30 text-violet-700 dark:text-violet-300 font-medium shadow-sm'
: 'text-slate-600 dark:text-slate-400 hover:bg-slate-100 dark:hover:bg-gray-800'
}`}
>
<ToolIcon id={tool.id} />
<div className="flex-1">
<div className="text-base font-medium">{tool.name}</div>
<div className="text-sm text-slate-500 dark:text-slate-500">
{tool.description}
</div>
</div>
{currentTool === tool.id && (
<span className="flex-shrink-0 w-2.5 h-2.5 bg-violet-500 rounded-full" />
)}
</Link>
))}
</div>
{/* Shared Resources Visualisierung */}
<div className="pt-4 border-t border-slate-200 dark:border-gray-700">
<div className="text-sm font-medium text-slate-500 dark:text-slate-400 mb-3">
Shared Resources
</div>
<div className="flex items-center justify-center gap-3 p-4 bg-slate-50 dark:bg-gray-800 rounded-xl">
<div className="flex flex-col items-center">
<span className="text-2xl">🖥</span>
<span className="text-xs text-slate-500 mt-1">GPU</span>
</div>
<span className="text-slate-400"></span>
<div className="flex flex-col items-center">
<span className="text-2xl"></span>
<span className="text-xs text-slate-500 mt-1">LLM</span>
</div>
<span className="text-slate-400"></span>
<div className="flex flex-col items-center">
<span className="text-2xl">🧪</span>
<span className="text-xs text-slate-500 mt-1">BQAS</span>
</div>
</div>
<div className="text-xs text-slate-400 mt-2 text-center">
GPU-Ressourcen fuer Modelle & Tests
</div>
</div>
{/* Quick Info */}
<div className="pt-4 border-t border-slate-200 dark:border-gray-700">
<div className="text-sm text-slate-600 dark:text-slate-400 p-3 bg-slate-50 dark:bg-gray-800 rounded-xl">
{currentTool === 'llm-compare' && (
<>
<strong className="text-slate-700 dark:text-slate-300">Aktuell:</strong> LLM-Antworten verschiedener Provider vergleichen
</>
)}
{currentTool === 'test-quality' && (
<>
<strong className="text-slate-700 dark:text-slate-300">Aktuell:</strong> Qualitaet der KI-Ausgaben ueberwachen
</>
)}
{currentTool === 'gpu' && (
<>
<strong className="text-slate-700 dark:text-slate-300">Aktuell:</strong> GPU-Instanzen fuer ML-Training verwalten
</>
)}
</div>
</div>
{/* Link zur Pipeline */}
<div className="pt-4 border-t border-slate-200 dark:border-gray-700">
<Link
href="/ai/magic-help"
onClick={() => setIsMobileOpen(false)}
className="flex items-center gap-2 px-3 py-2 text-sm text-teal-600 dark:text-teal-400 hover:bg-teal-50 dark:hover:bg-teal-900/20 rounded-lg 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="M13 10V3L4 14h7v7l9-11h-7z" />
</svg>
<span>Zur KI-Daten-Pipeline</span>
</Link>
</div>
</div>
</div>
</div>
)}
{/* CSS for slide-in animation */}
<style jsx>{`
@keyframes slide-in-right {
from {
transform: translateX(100%);
}
to {
transform: translateX(0);
}
}
.animate-slide-in-right {
animation: slide-in-right 0.2s ease-out;
}
`}</style>
</>
)
}
export default AIToolsSidebar

View File

@@ -0,0 +1,454 @@
/**
* Batch Uploader Component
*
* Multi-file upload with drag & drop, progress tracking, and SSE integration.
* Supports batch OCR processing for multiple images.
*
* Phase 2.1: Batch Processing UI
*/
'use client'
import { useState, useRef, useCallback, useEffect } from 'react'
interface UploadedFile {
id: string
file: File
preview: string
status: 'pending' | 'processing' | 'completed' | 'error'
result?: {
text: string
confidence: number
processing_time_ms: number
}
error?: string
}
interface BatchUploaderProps {
/** API base URL */
apiBase: string
/** Maximum files allowed */
maxFiles?: number
/** Whether to auto-start OCR after upload */
autoProcess?: boolean
/** Callback when all files are processed */
onComplete?: (results: UploadedFile[]) => void
/** Callback when a single file is processed */
onFileProcessed?: (file: UploadedFile) => void
/** Custom class names */
className?: string
}
/**
* Generate unique ID
*/
function generateId(): string {
return Math.random().toString(36).substring(2, 11)
}
/**
* Batch Uploader Component
*/
export function BatchUploader({
apiBase,
maxFiles = 20,
autoProcess = false,
onComplete,
onFileProcessed,
className = ''
}: BatchUploaderProps) {
const [files, setFiles] = useState<UploadedFile[]>([])
const [isProcessing, setIsProcessing] = useState(false)
const [isDragActive, setIsDragActive] = useState(false)
const [progress, setProgress] = useState({ current: 0, total: 0 })
const fileInputRef = useRef<HTMLInputElement>(null)
const eventSourceRef = useRef<EventSource | null>(null)
// Add files to the list
const addFiles = useCallback((newFiles: FileList | File[]) => {
const fileArray = Array.from(newFiles)
const imageFiles = fileArray.filter(f => f.type.startsWith('image/'))
if (imageFiles.length === 0) return
const uploadedFiles: UploadedFile[] = imageFiles
.slice(0, maxFiles - files.length)
.map(file => ({
id: generateId(),
file,
preview: URL.createObjectURL(file),
status: 'pending' as const
}))
setFiles(prev => [...prev, ...uploadedFiles].slice(0, maxFiles))
// Auto-process if enabled
if (autoProcess && uploadedFiles.length > 0) {
setTimeout(() => startProcessing(), 100)
}
}, [files.length, maxFiles, autoProcess])
// Remove a file
const removeFile = useCallback((id: string) => {
setFiles(prev => {
const file = prev.find(f => f.id === id)
if (file) {
URL.revokeObjectURL(file.preview)
}
return prev.filter(f => f.id !== id)
})
}, [])
// Clear all files
const clearAll = useCallback(() => {
files.forEach(f => URL.revokeObjectURL(f.preview))
setFiles([])
setProgress({ current: 0, total: 0 })
}, [files])
// Start batch processing with SSE
const startProcessing = useCallback(async () => {
if (isProcessing || files.length === 0) return
const pendingFiles = files.filter(f => f.status === 'pending')
if (pendingFiles.length === 0) return
setIsProcessing(true)
setProgress({ current: 0, total: pendingFiles.length })
// Close any existing connection
if (eventSourceRef.current) {
eventSourceRef.current.close()
}
// Use SSE for progress updates
const eventSource = new EventSource(
`${apiBase}/api/v1/admin/training/ocr/stream?images_count=${pendingFiles.length}`
)
eventSourceRef.current = eventSource
let processedIndex = 0
eventSource.onmessage = (event) => {
try {
const data = JSON.parse(event.data)
if (data.type === 'progress') {
setProgress({ current: data.current, total: data.total })
// Update file status
if (processedIndex < pendingFiles.length) {
const currentFile = pendingFiles[processedIndex]
setFiles(prev => prev.map(f =>
f.id === currentFile.id
? {
...f,
status: 'completed' as const,
result: data.result
}
: f
))
onFileProcessed?.({
...currentFile,
status: 'completed',
result: data.result
})
processedIndex++
}
} else if (data.type === 'error') {
if (processedIndex < pendingFiles.length) {
const currentFile = pendingFiles[processedIndex]
setFiles(prev => prev.map(f =>
f.id === currentFile.id
? { ...f, status: 'error' as const, error: data.error }
: f
))
processedIndex++
}
} else if (data.type === 'complete') {
eventSource.close()
setIsProcessing(false)
onComplete?.(files)
}
} catch (e) {
console.error('SSE parse error:', e)
}
}
eventSource.onerror = () => {
eventSource.close()
setIsProcessing(false)
// Mark remaining as error
setFiles(prev => prev.map(f =>
f.status === 'pending' || f.status === 'processing'
? { ...f, status: 'error' as const, error: 'Verbindung unterbrochen' }
: f
))
}
// Mark files as processing
setFiles(prev => prev.map(f =>
f.status === 'pending' ? { ...f, status: 'processing' as const } : f
))
}, [apiBase, files, isProcessing, onComplete, onFileProcessed])
// Stop processing
const stopProcessing = useCallback(() => {
if (eventSourceRef.current) {
eventSourceRef.current.close()
eventSourceRef.current = null
}
setIsProcessing(false)
setFiles(prev => prev.map(f =>
f.status === 'processing' ? { ...f, status: 'pending' as const } : f
))
}, [])
// Drag handlers
const handleDragEnter = (e: React.DragEvent) => {
e.preventDefault()
e.stopPropagation()
setIsDragActive(true)
}
const handleDragLeave = (e: React.DragEvent) => {
e.preventDefault()
e.stopPropagation()
setIsDragActive(false)
}
const handleDrop = (e: React.DragEvent) => {
e.preventDefault()
e.stopPropagation()
setIsDragActive(false)
addFiles(e.dataTransfer.files)
}
// Cleanup on unmount
useEffect(() => {
return () => {
files.forEach(f => URL.revokeObjectURL(f.preview))
if (eventSourceRef.current) {
eventSourceRef.current.close()
}
}
}, [])
const completedCount = files.filter(f => f.status === 'completed').length
const errorCount = files.filter(f => f.status === 'error').length
const avgConfidence = files
.filter(f => f.result)
.reduce((sum, f) => sum + (f.result?.confidence || 0), 0) /
Math.max(1, files.filter(f => f.result).length)
return (
<div className={`space-y-4 ${className}`}>
{/* Upload area */}
<div
className={`border-2 border-dashed rounded-xl p-8 text-center cursor-pointer transition-all ${
isDragActive
? 'border-purple-500 bg-purple-50'
: 'border-slate-300 hover:border-purple-400 hover:bg-purple-50/50'
}`}
onDragEnter={handleDragEnter}
onDragOver={(e) => e.preventDefault()}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
onClick={() => fileInputRef.current?.click()}
>
<div className="text-4xl mb-3">📁</div>
<div className="text-slate-700 font-medium">
{isDragActive
? 'Loslassen zum Hochladen'
: 'Bilder hierher ziehen oder klicken'}
</div>
<div className="text-sm text-slate-400 mt-1">
Bis zu {maxFiles} Bilder (PNG, JPG)
</div>
<input
ref={fileInputRef}
type="file"
accept="image/*"
multiple
className="hidden"
onChange={(e) => e.target.files && addFiles(e.target.files)}
/>
</div>
{/* File list */}
{files.length > 0 && (
<div className="space-y-3">
{/* Header with stats */}
<div className="flex items-center justify-between">
<div className="text-sm text-slate-600">
{files.length} Bilder ausgewaehlt
{completedCount > 0 && (
<span className="text-green-600 ml-2">
({completedCount} verarbeitet, {(avgConfidence * 100).toFixed(0)}% Konfidenz)
</span>
)}
{errorCount > 0 && (
<span className="text-red-600 ml-2">({errorCount} Fehler)</span>
)}
</div>
<div className="flex items-center gap-2">
<button
onClick={clearAll}
className="px-3 py-1 text-sm text-slate-600 hover:text-slate-900 transition-colors"
>
Alle entfernen
</button>
{isProcessing ? (
<button
onClick={stopProcessing}
className="px-4 py-1.5 bg-red-600 hover:bg-red-700 text-white rounded-lg text-sm font-medium transition-colors"
>
Stoppen
</button>
) : (
<button
onClick={startProcessing}
disabled={files.filter(f => f.status === 'pending').length === 0}
className="px-4 py-1.5 bg-purple-600 hover:bg-purple-700 disabled:bg-slate-300 text-white rounded-lg text-sm font-medium transition-colors"
>
OCR starten
</button>
)}
</div>
</div>
{/* Progress bar */}
{isProcessing && (
<div className="bg-slate-100 rounded-lg p-3">
<div className="flex items-center justify-between text-sm mb-2">
<span className="text-slate-600">
Verarbeite Bild {progress.current} von {progress.total}
</span>
<span className="text-purple-600 font-medium">
{((progress.current / progress.total) * 100).toFixed(0)}%
</span>
</div>
<div className="h-2 bg-slate-200 rounded-full overflow-hidden">
<div
className="h-full bg-purple-600 transition-all duration-300"
style={{ width: `${(progress.current / progress.total) * 100}%` }}
/>
</div>
</div>
)}
{/* File thumbnails */}
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-3">
{files.map((file) => (
<div
key={file.id}
className={`relative group rounded-lg overflow-hidden border-2 ${
file.status === 'completed'
? 'border-green-300'
: file.status === 'error'
? 'border-red-300'
: file.status === 'processing'
? 'border-purple-300'
: 'border-slate-200'
}`}
>
{/* Thumbnail */}
<img
src={file.preview}
alt={file.file.name}
className="w-full h-24 object-cover"
/>
{/* Overlay for status */}
{file.status === 'processing' && (
<div className="absolute inset-0 bg-purple-900/50 flex items-center justify-center">
<div className="animate-spin rounded-full h-6 w-6 border-b-2 border-white"></div>
</div>
)}
{file.status === 'completed' && file.result && (
<div className="absolute inset-0 bg-green-900/70 flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity">
<div className="text-center text-white text-xs p-2">
<div className="font-medium">
{(file.result.confidence * 100).toFixed(0)}%
</div>
<div className="text-green-200 truncate max-w-full">
{file.result.text.substring(0, 30)}...
</div>
</div>
</div>
)}
{file.status === 'error' && (
<div className="absolute inset-0 bg-red-900/70 flex items-center justify-center">
<div className="text-white text-xs text-center px-2">
Fehler
</div>
</div>
)}
{/* Status badge */}
<div className={`absolute top-1 right-1 w-3 h-3 rounded-full ${
file.status === 'completed'
? 'bg-green-500'
: file.status === 'error'
? 'bg-red-500'
: file.status === 'processing'
? 'bg-purple-500 animate-pulse'
: 'bg-slate-400'
}`} />
{/* Remove button */}
{!isProcessing && (
<button
onClick={(e) => {
e.stopPropagation()
removeFile(file.id)
}}
className="absolute top-1 left-1 w-5 h-5 bg-red-500 text-white rounded-full opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center text-xs"
>
×
</button>
)}
{/* File name */}
<div className="absolute bottom-0 left-0 right-0 bg-black/60 text-white text-xs px-2 py-1 truncate">
{file.file.name}
</div>
</div>
))}
</div>
</div>
)}
{/* Results summary */}
{completedCount > 0 && !isProcessing && (
<div className="bg-green-50 border border-green-200 rounded-lg p-4">
<h4 className="font-medium text-green-800 mb-2">
Verarbeitung abgeschlossen
</h4>
<div className="grid grid-cols-3 gap-4 text-center text-sm">
<div>
<div className="text-2xl font-bold text-green-700">{completedCount}</div>
<div className="text-green-600">Erfolgreich</div>
</div>
<div>
<div className="text-2xl font-bold text-slate-700">
{(avgConfidence * 100).toFixed(0)}%
</div>
<div className="text-slate-600">Durchschnittl. Konfidenz</div>
</div>
<div>
<div className="text-2xl font-bold text-red-700">{errorCount}</div>
<div className="text-red-600">Fehler</div>
</div>
</div>
</div>
)}
</div>
)
}
export default BatchUploader

View File

@@ -0,0 +1,439 @@
/**
* Confidence Heatmap Component
*
* Displays an OCR result with visual confidence overlay on the original image.
* Shows word-level or character-level confidence using color gradients.
*
* Phase 3.1: Wow-Feature for Magic Help
*/
'use client'
import { useState, useRef, useEffect, useMemo } from 'react'
interface WordBox {
text: string
confidence: number
bbox: [number, number, number, number] // [x, y, width, height] as percentages (0-100)
}
interface ConfidenceHeatmapProps {
/** Image source URL or data URL */
imageSrc: string
/** Detected text result */
text: string
/** Overall confidence score (0-1) */
confidence: number
/** Word-level boxes with confidence */
wordBoxes?: WordBox[]
/** Character-level confidences (aligned with text) */
charConfidences?: number[]
/** Custom class names */
className?: string
/** Show legend */
showLegend?: boolean
/** Allow toggling overlay visibility */
toggleable?: boolean
/** Callback when a word box is clicked */
onWordClick?: (word: WordBox) => void
}
/**
* Get color based on confidence value
* Green (high) -> Yellow (medium) -> Red (low)
*/
function getConfidenceColor(confidence: number, opacity = 0.5): string {
if (confidence >= 0.9) {
return `rgba(34, 197, 94, ${opacity})` // green-500
} else if (confidence >= 0.7) {
return `rgba(234, 179, 8, ${opacity})` // yellow-500
} else if (confidence >= 0.5) {
return `rgba(249, 115, 22, ${opacity})` // orange-500
} else {
return `rgba(239, 68, 68, ${opacity})` // red-500
}
}
/**
* Get border color (more saturated version)
*/
function getConfidenceBorderColor(confidence: number): string {
if (confidence >= 0.9) return '#22c55e' // green-500
if (confidence >= 0.7) return '#eab308' // yellow-500
if (confidence >= 0.5) return '#f97316' // orange-500
return '#ef4444' // red-500
}
/**
* Confidence Heatmap Component
*/
export function ConfidenceHeatmap({
imageSrc,
text,
confidence,
wordBoxes = [],
charConfidences = [],
className = '',
showLegend = true,
toggleable = true,
onWordClick
}: ConfidenceHeatmapProps) {
const [showOverlay, setShowOverlay] = useState(true)
const [hoveredWord, setHoveredWord] = useState<WordBox | null>(null)
const [zoom, setZoom] = useState(1)
const [pan, setPan] = useState({ x: 0, y: 0 })
const [isPanning, setIsPanning] = useState(false)
const [lastPanPoint, setLastPanPoint] = useState({ x: 0, y: 0 })
const containerRef = useRef<HTMLDivElement>(null)
// Generate simulated word boxes if not provided
const displayBoxes = useMemo(() => {
if (wordBoxes.length > 0) return wordBoxes
// Simulate word boxes from text and char confidences
if (!text || charConfidences.length === 0) return []
const words = text.split(/\s+/).filter(w => w.length > 0)
const boxes: WordBox[] = []
let charIndex = 0
words.forEach((word, idx) => {
// Calculate average confidence for this word
const wordConfidences = charConfidences.slice(charIndex, charIndex + word.length)
const avgConfidence = wordConfidences.length > 0
? wordConfidences.reduce((a, b) => a + b, 0) / wordConfidences.length
: confidence
// Simulate bbox positions (simple grid layout for demo)
const wordsPerRow = 5
const row = Math.floor(idx / wordsPerRow)
const col = idx % wordsPerRow
const wordWidth = Math.min(18, 5 + word.length * 1.5)
boxes.push({
text: word,
confidence: avgConfidence,
bbox: [
5 + col * 19, // x
10 + row * 12, // y
wordWidth, // width
8 // height
]
})
charIndex += word.length + 1 // +1 for space
})
return boxes
}, [text, wordBoxes, charConfidences, confidence])
// Handle mouse wheel zoom
const handleWheel = (e: React.WheelEvent) => {
if (e.ctrlKey || e.metaKey) {
e.preventDefault()
const delta = e.deltaY > 0 ? -0.1 : 0.1
setZoom(prev => Math.max(1, Math.min(3, prev + delta)))
}
}
// Handle panning
const handleMouseDown = (e: React.MouseEvent) => {
if (zoom > 1) {
setIsPanning(true)
setLastPanPoint({ x: e.clientX, y: e.clientY })
}
}
const handleMouseMove = (e: React.MouseEvent) => {
if (isPanning && zoom > 1) {
const dx = e.clientX - lastPanPoint.x
const dy = e.clientY - lastPanPoint.y
setPan(prev => ({
x: Math.max(-100, Math.min(100, prev.x + dx / zoom)),
y: Math.max(-100, Math.min(100, prev.y + dy / zoom))
}))
setLastPanPoint({ x: e.clientX, y: e.clientY })
}
}
const handleMouseUp = () => {
setIsPanning(false)
}
// Reset zoom and pan
const resetView = () => {
setZoom(1)
setPan({ x: 0, y: 0 })
}
return (
<div className={`relative ${className}`}>
{/* Controls */}
<div className="flex items-center justify-between mb-3 flex-wrap gap-2">
<div className="flex items-center gap-2">
{toggleable && (
<button
onClick={() => setShowOverlay(prev => !prev)}
className={`px-3 py-1 rounded text-sm font-medium transition-colors ${
showOverlay
? 'bg-purple-600 text-white'
: 'bg-slate-200 text-slate-700 hover:bg-slate-300'
}`}
>
{showOverlay ? 'Overlay An' : 'Overlay Aus'}
</button>
)}
<div className="flex items-center gap-1 bg-slate-100 rounded p-1">
<button
onClick={() => setZoom(prev => Math.max(1, prev - 0.25))}
className="p-1 hover:bg-slate-200 rounded transition-colors"
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-xs font-mono w-12 text-center">{(zoom * 100).toFixed(0)}%</span>
<button
onClick={() => setZoom(prev => Math.min(3, prev + 0.25))}
className="p-1 hover:bg-slate-200 rounded transition-colors"
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>
{zoom > 1 && (
<button
onClick={resetView}
className="p-1 hover:bg-slate-200 rounded transition-colors ml-1"
title="Zuruecksetzen"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
</svg>
</button>
)}
</div>
</div>
{/* Overall confidence badge */}
<div className={`px-3 py-1 rounded-full text-sm font-medium ${
confidence >= 0.9 ? 'bg-green-100 text-green-700' :
confidence >= 0.7 ? 'bg-yellow-100 text-yellow-700' :
'bg-red-100 text-red-700'
}`}>
Gesamt: {(confidence * 100).toFixed(0)}%
</div>
</div>
{/* Image container with overlay */}
<div
ref={containerRef}
className="relative overflow-hidden rounded-lg border border-slate-200 bg-slate-100"
style={{ cursor: zoom > 1 ? (isPanning ? 'grabbing' : 'grab') : 'default' }}
onWheel={handleWheel}
onMouseDown={handleMouseDown}
onMouseMove={handleMouseMove}
onMouseUp={handleMouseUp}
onMouseLeave={handleMouseUp}
>
<div
className="relative transition-transform duration-200"
style={{
transform: `scale(${zoom}) translate(${pan.x}px, ${pan.y}px)`,
transformOrigin: 'center center'
}}
>
{/* Original image */}
<img
src={imageSrc}
alt="OCR Dokument"
className="w-full h-auto"
draggable={false}
/>
{/* SVG Overlay */}
{showOverlay && displayBoxes.length > 0 && (
<svg
className="absolute inset-0 w-full h-full pointer-events-none"
viewBox="0 0 100 100"
preserveAspectRatio="none"
>
{displayBoxes.map((box, idx) => (
<g
key={idx}
className="pointer-events-auto cursor-pointer"
onMouseEnter={() => setHoveredWord(box)}
onMouseLeave={() => setHoveredWord(null)}
onClick={() => onWordClick?.(box)}
>
{/* Background fill */}
<rect
x={box.bbox[0]}
y={box.bbox[1]}
width={box.bbox[2]}
height={box.bbox[3]}
fill={getConfidenceColor(box.confidence, 0.3)}
stroke={getConfidenceBorderColor(box.confidence)}
strokeWidth="0.3"
rx="0.5"
className="transition-all duration-150"
style={{
filter: hoveredWord === box ? 'brightness(1.2)' : 'none'
}}
/>
{/* Hover highlight */}
{hoveredWord === box && (
<rect
x={box.bbox[0] - 0.5}
y={box.bbox[1] - 0.5}
width={box.bbox[2] + 1}
height={box.bbox[3] + 1}
fill="none"
stroke="#7c3aed"
strokeWidth="0.5"
rx="0.5"
className="animate-pulse"
/>
)}
</g>
))}
</svg>
)}
</div>
</div>
{/* Hovered word tooltip */}
{hoveredWord && (
<div className="absolute bottom-full left-1/2 -translate-x-1/2 mb-2 px-3 py-2 bg-slate-900 text-white text-sm rounded-lg shadow-lg z-10">
<div className="font-mono">&quot;{hoveredWord.text}&quot;</div>
<div className="text-slate-300 text-xs">
Konfidenz: {(hoveredWord.confidence * 100).toFixed(1)}%
</div>
<div
className="absolute top-full left-1/2 -translate-x-1/2 w-2 h-2 bg-slate-900 rotate-45 -mt-1"
/>
</div>
)}
{/* Legend */}
{showLegend && (
<div className="mt-3 flex items-center justify-center gap-4 text-xs text-slate-600">
<div className="flex items-center gap-1">
<div className="w-4 h-3 rounded" style={{ backgroundColor: 'rgba(34, 197, 94, 0.5)' }} />
<span>&gt;90%</span>
</div>
<div className="flex items-center gap-1">
<div className="w-4 h-3 rounded" style={{ backgroundColor: 'rgba(234, 179, 8, 0.5)' }} />
<span>70-90%</span>
</div>
<div className="flex items-center gap-1">
<div className="w-4 h-3 rounded" style={{ backgroundColor: 'rgba(249, 115, 22, 0.5)' }} />
<span>50-70%</span>
</div>
<div className="flex items-center gap-1">
<div className="w-4 h-3 rounded" style={{ backgroundColor: 'rgba(239, 68, 68, 0.5)' }} />
<span>&lt;50%</span>
</div>
</div>
)}
{/* Instructions */}
<p className="mt-2 text-xs text-slate-400 text-center">
Fahre ueber markierte Bereiche fuer Details. Strg+Scroll zum Zoomen.
</p>
</div>
)
}
/**
* Inline character confidence display
* Shows text with color-coded background for each character
*/
interface InlineConfidenceTextProps {
text: string
charConfidences: number[]
className?: string
}
export function InlineConfidenceText({
text,
charConfidences,
className = ''
}: InlineConfidenceTextProps) {
if (charConfidences.length === 0) {
return <span className={className}>{text}</span>
}
return (
<span className={`font-mono ${className}`}>
{text.split('').map((char, idx) => {
const conf = charConfidences[idx] ?? 0.5
return (
<span
key={idx}
className="relative group"
style={{ backgroundColor: getConfidenceColor(conf, 0.3) }}
title={`'${char}': ${(conf * 100).toFixed(0)}%`}
>
{char}
</span>
)
})}
</span>
)
}
/**
* Confidence Statistics Summary
*/
interface ConfidenceStatsProps {
wordBoxes: WordBox[]
className?: string
}
export function ConfidenceStats({ wordBoxes, className = '' }: ConfidenceStatsProps) {
const stats = useMemo(() => {
if (wordBoxes.length === 0) return null
const confidences = wordBoxes.map(w => w.confidence)
const avg = confidences.reduce((a, b) => a + b, 0) / confidences.length
const min = Math.min(...confidences)
const max = Math.max(...confidences)
const highConf = confidences.filter(c => c >= 0.9).length
const lowConf = confidences.filter(c => c < 0.7).length
return { avg, min, max, highConf, lowConf, total: wordBoxes.length }
}, [wordBoxes])
if (!stats) return null
return (
<div className={`grid grid-cols-2 md:grid-cols-5 gap-3 ${className}`}>
<div className="bg-slate-50 rounded-lg p-3 text-center">
<div className="text-lg font-bold text-slate-900">{(stats.avg * 100).toFixed(0)}%</div>
<div className="text-xs text-slate-500">Durchschnitt</div>
</div>
<div className="bg-slate-50 rounded-lg p-3 text-center">
<div className="text-lg font-bold text-slate-900">{(stats.min * 100).toFixed(0)}%</div>
<div className="text-xs text-slate-500">Minimum</div>
</div>
<div className="bg-slate-50 rounded-lg p-3 text-center">
<div className="text-lg font-bold text-slate-900">{(stats.max * 100).toFixed(0)}%</div>
<div className="text-xs text-slate-500">Maximum</div>
</div>
<div className="bg-green-50 rounded-lg p-3 text-center">
<div className="text-lg font-bold text-green-700">{stats.highConf}</div>
<div className="text-xs text-slate-500">Sicher (&gt;90%)</div>
</div>
<div className="bg-red-50 rounded-lg p-3 text-center">
<div className="text-lg font-bold text-red-700">{stats.lowConf}</div>
<div className="text-xs text-slate-500">Unsicher (&lt;70%)</div>
</div>
</div>
)
}
export default ConfidenceHeatmap

View File

@@ -0,0 +1,613 @@
/**
* Training Metrics Component
*
* Real-time visualization of training progress with loss curves and metrics.
* Supports SSE (Server-Sent Events) for live updates during LoRA fine-tuning.
*
* Phase 3.3: Training Dashboard with Live Metrics
*/
'use client'
import { useState, useEffect, useCallback, useRef } from 'react'
// Training data point for charts
interface TrainingDataPoint {
epoch: number
step: number
loss: number
val_loss?: number
learning_rate: number
timestamp: number
}
// Training job status
interface TrainingStatus {
job_id: string
status: 'queued' | 'running' | 'completed' | 'failed' | 'cancelled'
progress: number // 0-100
current_epoch: number
total_epochs: number
current_step: number
total_steps: number
elapsed_time_ms: number
estimated_remaining_ms: number
metrics: {
loss: number
val_loss?: number
accuracy?: number
learning_rate: number
}
history: TrainingDataPoint[]
error?: string
}
interface TrainingMetricsProps {
/** API base URL */
apiBase: string
/** Job ID to track (null for simulation mode) */
jobId?: string | null
/** Simulate training progress for demo */
simulateMode?: boolean
/** Custom class names */
className?: string
/** Callback when training completes */
onComplete?: (status: TrainingStatus) => void
/** Callback on error */
onError?: (error: string) => void
}
/**
* Custom hook for SSE-based training metrics
*/
export function useTrainingMetricsSSE(
apiBase: string,
jobId: string | null,
onUpdate?: (status: TrainingStatus) => void
) {
const [status, setStatus] = useState<TrainingStatus | null>(null)
const [connected, setConnected] = useState(false)
const [error, setError] = useState<string | null>(null)
const eventSourceRef = useRef<EventSource | null>(null)
useEffect(() => {
if (!jobId) return
const url = `${apiBase}/api/klausur/trocr/training/metrics/stream?job_id=${jobId}`
const eventSource = new EventSource(url)
eventSourceRef.current = eventSource
eventSource.onopen = () => {
setConnected(true)
setError(null)
}
eventSource.onmessage = (event) => {
try {
const data = JSON.parse(event.data) as TrainingStatus
setStatus(data)
onUpdate?.(data)
// Close connection when training is done
if (data.status === 'completed' || data.status === 'failed' || data.status === 'cancelled') {
eventSource.close()
setConnected(false)
}
} catch (e) {
console.error('Failed to parse SSE message:', e)
}
}
eventSource.onerror = () => {
setError('Verbindung zum Server verloren')
setConnected(false)
eventSource.close()
}
return () => {
eventSource.close()
setConnected(false)
}
}, [apiBase, jobId, onUpdate])
const disconnect = useCallback(() => {
eventSourceRef.current?.close()
setConnected(false)
}, [])
return { status, connected, error, disconnect }
}
/**
* Simple line chart component for loss visualization
*/
function LossChart({
data,
height = 200,
className = ''
}: {
data: TrainingDataPoint[]
height?: number
className?: string
}) {
if (data.length === 0) {
return (
<div className={`flex items-center justify-center bg-slate-50 rounded-lg ${className}`} style={{ height }}>
<span className="text-slate-400 text-sm">Keine Daten</span>
</div>
)
}
// Calculate bounds
const losses = data.map(d => d.loss)
const valLosses = data.filter(d => d.val_loss !== undefined).map(d => d.val_loss!)
const allLosses = [...losses, ...valLosses]
const minLoss = Math.min(...allLosses) * 0.9
const maxLoss = Math.max(...allLosses) * 1.1
const lossRange = maxLoss - minLoss || 1
// SVG dimensions
const width = 400
const padding = { top: 20, right: 20, bottom: 30, left: 50 }
const chartWidth = width - padding.left - padding.right
const chartHeight = height - padding.top - padding.bottom
// Generate path for loss line
const generatePath = (values: number[]) => {
if (values.length === 0) return ''
return values
.map((loss, idx) => {
const x = padding.left + (idx / (values.length - 1 || 1)) * chartWidth
const y = padding.top + chartHeight - ((loss - minLoss) / lossRange) * chartHeight
return `${idx === 0 ? 'M' : 'L'} ${x} ${y}`
})
.join(' ')
}
const lossPath = generatePath(losses)
const valLossPath = valLosses.length > 0 ? generatePath(valLosses) : ''
// Y-axis labels
const yLabels = [maxLoss, (maxLoss + minLoss) / 2, minLoss].map(v => v.toFixed(3))
return (
<svg viewBox={`0 0 ${width} ${height}`} className={`w-full ${className}`} preserveAspectRatio="xMidYMid meet">
{/* Grid lines */}
{[0, 0.5, 1].map((ratio, idx) => (
<line
key={idx}
x1={padding.left}
y1={padding.top + chartHeight * ratio}
x2={width - padding.right}
y2={padding.top + chartHeight * ratio}
stroke="#e2e8f0"
strokeWidth="1"
/>
))}
{/* Y-axis labels */}
{yLabels.map((label, idx) => (
<text
key={idx}
x={padding.left - 5}
y={padding.top + (idx / 2) * chartHeight + 4}
textAnchor="end"
className="fill-slate-500 text-xs"
>
{label}
</text>
))}
{/* X-axis label */}
<text
x={width / 2}
y={height - 5}
textAnchor="middle"
className="fill-slate-500 text-xs"
>
Epoche / Schritt
</text>
{/* Y-axis label */}
<text
x={12}
y={height / 2}
textAnchor="middle"
className="fill-slate-500 text-xs"
transform={`rotate(-90, 12, ${height / 2})`}
>
Loss
</text>
{/* Training loss line */}
<path
d={lossPath}
fill="none"
stroke="#8b5cf6"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
{/* Validation loss line (dashed) */}
{valLossPath && (
<path
d={valLossPath}
fill="none"
stroke="#22c55e"
strokeWidth="2"
strokeDasharray="5,5"
strokeLinecap="round"
strokeLinejoin="round"
/>
)}
{/* Data points */}
{data.map((point, idx) => {
const x = padding.left + (idx / (data.length - 1 || 1)) * chartWidth
const y = padding.top + chartHeight - ((point.loss - minLoss) / lossRange) * chartHeight
return (
<circle
key={idx}
cx={x}
cy={y}
r="3"
fill="#8b5cf6"
className="hover:r-4 transition-all"
>
<title>Epoch {point.epoch}, Step {point.step}: {point.loss.toFixed(4)}</title>
</circle>
)
})}
{/* Legend */}
<g transform={`translate(${padding.left}, ${height - 25})`}>
<line x1="0" y1="0" x2="20" y2="0" stroke="#8b5cf6" strokeWidth="2" />
<text x="25" y="4" className="fill-slate-600 text-xs">Training Loss</text>
{valLossPath && (
<>
<line x1="120" y1="0" x2="140" y2="0" stroke="#22c55e" strokeWidth="2" strokeDasharray="5,5" />
<text x="145" y="4" className="fill-slate-600 text-xs">Val Loss</text>
</>
)}
</g>
</svg>
)
}
/**
* Progress ring component
*/
function ProgressRing({
progress,
size = 80,
strokeWidth = 6,
className = ''
}: {
progress: number
size?: number
strokeWidth?: number
className?: string
}) {
const radius = (size - strokeWidth) / 2
const circumference = radius * 2 * Math.PI
const offset = circumference - (progress / 100) * circumference
return (
<svg width={size} height={size} className={className}>
{/* Background circle */}
<circle
cx={size / 2}
cy={size / 2}
r={radius}
fill="none"
stroke="#e2e8f0"
strokeWidth={strokeWidth}
/>
{/* Progress circle */}
<circle
cx={size / 2}
cy={size / 2}
r={radius}
fill="none"
stroke="#8b5cf6"
strokeWidth={strokeWidth}
strokeDasharray={circumference}
strokeDashoffset={offset}
strokeLinecap="round"
transform={`rotate(-90 ${size / 2} ${size / 2})`}
className="transition-all duration-300"
/>
{/* Center text */}
<text
x={size / 2}
y={size / 2}
textAnchor="middle"
dominantBaseline="middle"
className="fill-slate-900 font-bold text-lg"
>
{progress.toFixed(0)}%
</text>
</svg>
)
}
/**
* Format time duration
*/
function formatDuration(ms: number): string {
if (ms < 1000) return `${ms}ms`
const seconds = Math.floor(ms / 1000)
if (seconds < 60) return `${seconds}s`
const minutes = Math.floor(seconds / 60)
const remainingSeconds = seconds % 60
if (minutes < 60) return `${minutes}m ${remainingSeconds}s`
const hours = Math.floor(minutes / 60)
const remainingMinutes = minutes % 60
return `${hours}h ${remainingMinutes}m`
}
/**
* Training Metrics Component - Full Dashboard
*/
export function TrainingMetrics({
apiBase,
jobId = null,
simulateMode = false,
className = '',
onComplete,
onError
}: TrainingMetricsProps) {
const [status, setStatus] = useState<TrainingStatus | null>(null)
const [simulationInterval, setSimulationInterval] = useState<NodeJS.Timeout | null>(null)
// SSE hook for real connection
const { status: sseStatus, connected, error } = useTrainingMetricsSSE(
apiBase,
simulateMode ? null : jobId,
(newStatus) => {
if (newStatus.status === 'completed') {
onComplete?.(newStatus)
} else if (newStatus.status === 'failed' && newStatus.error) {
onError?.(newStatus.error)
}
}
)
// Use SSE status if available
useEffect(() => {
if (sseStatus) {
setStatus(sseStatus)
}
}, [sseStatus])
// Simulation mode for demo
useEffect(() => {
if (!simulateMode) return
let step = 0
const totalSteps = 100
const history: TrainingDataPoint[] = []
const interval = setInterval(() => {
step++
const epoch = Math.floor((step / totalSteps) * 3) + 1
const progress = (step / totalSteps) * 100
// Simulate decreasing loss with noise
const baseLoss = 2.5 * Math.exp(-step / 30) + 0.1
const noise = (Math.random() - 0.5) * 0.1
const loss = Math.max(0.05, baseLoss + noise)
const dataPoint: TrainingDataPoint = {
epoch,
step,
loss,
val_loss: step % 10 === 0 ? loss * (1 + Math.random() * 0.2) : undefined,
learning_rate: 0.00005 * Math.pow(0.95, epoch - 1),
timestamp: Date.now()
}
history.push(dataPoint)
const newStatus: TrainingStatus = {
job_id: 'simulation',
status: step >= totalSteps ? 'completed' : 'running',
progress,
current_epoch: epoch,
total_epochs: 3,
current_step: step,
total_steps: totalSteps,
elapsed_time_ms: step * 500,
estimated_remaining_ms: (totalSteps - step) * 500,
metrics: {
loss,
val_loss: dataPoint.val_loss,
accuracy: 0.7 + (step / totalSteps) * 0.25,
learning_rate: dataPoint.learning_rate
},
history: [...history]
}
setStatus(newStatus)
if (step >= totalSteps) {
clearInterval(interval)
onComplete?.(newStatus)
}
}, 500)
setSimulationInterval(interval)
return () => {
clearInterval(interval)
}
}, [simulateMode, onComplete])
// Stop simulation
const stopSimulation = () => {
if (simulationInterval) {
clearInterval(simulationInterval)
setSimulationInterval(null)
}
}
if (error) {
return (
<div className={`bg-red-50 border border-red-200 rounded-xl p-6 ${className}`}>
<div className="flex items-center gap-3 text-red-700">
<svg className="w-6 h-6" 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>{error}</span>
</div>
</div>
)
}
if (!status) {
return (
<div className={`bg-slate-50 rounded-xl p-6 ${className}`}>
<div className="flex items-center justify-center gap-3 text-slate-500">
<div className="animate-spin rounded-full h-5 w-5 border-b-2 border-purple-600"></div>
<span>Warte auf Training-Daten...</span>
</div>
</div>
)
}
return (
<div className={`bg-white rounded-xl shadow-sm border ${className}`}>
{/* Header */}
<div className="flex items-center justify-between p-4 border-b">
<div className="flex items-center gap-3">
<h3 className="text-lg font-semibold text-slate-900">Training Dashboard</h3>
<div className={`flex items-center gap-1 px-2 py-1 rounded-full text-xs font-medium ${
status.status === 'running' ? 'bg-blue-100 text-blue-700' :
status.status === 'completed' ? 'bg-green-100 text-green-700' :
status.status === 'failed' ? 'bg-red-100 text-red-700' :
'bg-slate-100 text-slate-700'
}`}>
{status.status === 'running' && (
<span className="w-2 h-2 rounded-full bg-blue-500 animate-pulse"></span>
)}
{status.status === 'running' ? 'Laeuft' :
status.status === 'completed' ? 'Abgeschlossen' :
status.status === 'failed' ? 'Fehlgeschlagen' :
status.status}
</div>
{connected && (
<span className="text-xs text-green-600 flex items-center gap-1">
<span className="w-1.5 h-1.5 rounded-full bg-green-500"></span>
Live
</span>
)}
</div>
{simulateMode && status.status === 'running' && (
<button
onClick={stopSimulation}
className="px-3 py-1 text-sm bg-slate-200 hover:bg-slate-300 text-slate-700 rounded transition-colors"
>
Stoppen
</button>
)}
</div>
{/* Main content */}
<div className="p-4 grid grid-cols-1 lg:grid-cols-3 gap-6">
{/* Progress section */}
<div className="flex flex-col items-center justify-center gap-4">
<ProgressRing progress={status.progress} size={120} strokeWidth={8} />
<div className="text-center">
<div className="text-sm text-slate-500">
Epoche {status.current_epoch} / {status.total_epochs}
</div>
<div className="text-xs text-slate-400">
Schritt {status.current_step} / {status.total_steps}
</div>
</div>
</div>
{/* Loss chart */}
<div className="lg:col-span-2">
<h4 className="text-sm font-medium text-slate-700 mb-2">Loss-Verlauf</h4>
<LossChart data={status.history} height={180} />
</div>
</div>
{/* Metrics grid */}
<div className="px-4 pb-4 grid grid-cols-2 md:grid-cols-4 gap-3">
<div className="bg-slate-50 rounded-lg p-3 text-center">
<div className="text-lg font-bold text-slate-900">{status.metrics.loss.toFixed(4)}</div>
<div className="text-xs text-slate-500">Aktueller Loss</div>
</div>
{status.metrics.val_loss !== undefined && (
<div className="bg-slate-50 rounded-lg p-3 text-center">
<div className="text-lg font-bold text-slate-900">{status.metrics.val_loss.toFixed(4)}</div>
<div className="text-xs text-slate-500">Validation Loss</div>
</div>
)}
{status.metrics.accuracy !== undefined && (
<div className="bg-green-50 rounded-lg p-3 text-center">
<div className="text-lg font-bold text-green-700">{(status.metrics.accuracy * 100).toFixed(1)}%</div>
<div className="text-xs text-slate-500">Genauigkeit</div>
</div>
)}
<div className="bg-slate-50 rounded-lg p-3 text-center">
<div className="text-lg font-bold text-slate-900">{status.metrics.learning_rate.toExponential(1)}</div>
<div className="text-xs text-slate-500">Learning Rate</div>
</div>
</div>
{/* Time info */}
<div className="px-4 pb-4 flex items-center justify-between text-sm text-slate-500">
<div>Vergangen: {formatDuration(status.elapsed_time_ms)}</div>
{status.status === 'running' && (
<div>Geschaetzt: {formatDuration(status.estimated_remaining_ms)} verbleibend</div>
)}
</div>
</div>
)
}
/**
* Compact Training Metrics for inline display
*/
export function TrainingMetricsCompact({
progress,
currentEpoch,
totalEpochs,
loss,
status,
className = ''
}: {
progress: number
currentEpoch: number
totalEpochs: number
loss: number
status: 'running' | 'completed' | 'failed'
className?: string
}) {
return (
<div className={`flex items-center gap-4 ${className}`}>
<ProgressRing progress={progress} size={48} strokeWidth={4} />
<div className="flex-1">
<div className="flex items-center gap-2 text-sm">
<span className="font-medium text-slate-900">Epoche {currentEpoch}/{totalEpochs}</span>
<span className={`px-2 py-0.5 rounded-full text-xs ${
status === 'running' ? 'bg-blue-100 text-blue-700' :
status === 'completed' ? 'bg-green-100 text-green-700' :
'bg-red-100 text-red-700'
}`}>
{status === 'running' ? 'Laeuft' : status === 'completed' ? 'Fertig' : 'Fehler'}
</span>
</div>
<div className="text-xs text-slate-500 mt-1">
Loss: {loss.toFixed(4)}
</div>
</div>
</div>
)
}
export default TrainingMetrics

View File

@@ -0,0 +1,13 @@
/**
* AI Components Index
*
* Exports für alle AI-spezifischen Komponenten
*/
export { AIModuleSidebar, AIModuleNav, AIModuleSidebarResponsive } from './AIModuleSidebar'
export type { AIModuleSidebarProps, AIModuleSidebarResponsiveProps } from './AIModuleSidebar'
// Magic Help Components
export { ConfidenceHeatmap, InlineConfidenceText, ConfidenceStats } from './ConfidenceHeatmap'
export { TrainingMetrics, TrainingMetricsCompact, useTrainingMetricsSSE } from './TrainingMetrics'
export { BatchUploader } from './BatchUploader'

View File

@@ -34,9 +34,9 @@ export function Breadcrumbs() {
}
}
} else {
// Check meta modules
// Check meta modules (but skip dashboard as it's already added)
const metaModule = metaModules.find(m => m.href === `/${pathParts[0]}`)
if (metaModule) {
if (metaModule && metaModule.href !== '/dashboard') {
items.push({ label: metaModule.name, href: metaModule.href })
}
}
@@ -50,7 +50,7 @@ export function Breadcrumbs() {
return (
<nav className="flex items-center gap-2 text-sm text-slate-500 mb-4">
{items.map((item, index) => (
<span key={item.href} className="flex items-center gap-2">
<span key={`${index}-${item.href}`} className="flex items-center gap-2">
{index > 0 && (
<svg className="w-4 h-4 text-slate-300" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />

View File

@@ -0,0 +1,217 @@
/**
* Skeleton Loading Components
*
* Animated placeholder components for loading states.
* Used throughout the app for smoother UX during async operations.
*/
import { ReactNode } from 'react'
interface SkeletonTextProps {
/** Number of lines to display */
lines?: number
/** Width variants for varied line lengths */
variant?: 'uniform' | 'varied' | 'paragraph'
/** Custom class names */
className?: string
}
/**
* Animated skeleton text placeholder
*/
export function SkeletonText({ lines = 3, variant = 'varied', className = '' }: SkeletonTextProps) {
const getLineWidth = (index: number) => {
if (variant === 'uniform') return 'w-full'
if (variant === 'paragraph') {
// Last line is shorter for paragraph effect
if (index === lines - 1) return 'w-3/5'
return 'w-full'
}
// Varied widths
const widths = ['w-full', 'w-4/5', 'w-3/4', 'w-5/6', 'w-2/3']
return widths[index % widths.length]
}
return (
<div className={`space-y-3 ${className}`}>
{Array.from({ length: lines }).map((_, i) => (
<div
key={i}
className={`h-4 bg-slate-200 rounded animate-pulse ${getLineWidth(i)}`}
style={{ animationDelay: `${i * 100}ms` }}
/>
))}
</div>
)
}
interface SkeletonBoxProps {
/** Width (Tailwind class or px) */
width?: string
/** Height (Tailwind class or px) */
height?: string
/** Custom class names */
className?: string
/** Border radius */
rounded?: 'none' | 'sm' | 'md' | 'lg' | 'xl' | 'full'
}
/**
* Animated skeleton box for images, avatars, cards
*/
export function SkeletonBox({
width = 'w-full',
height = 'h-32',
className = '',
rounded = 'lg'
}: SkeletonBoxProps) {
const roundedClass = {
none: '',
sm: 'rounded-sm',
md: 'rounded-md',
lg: 'rounded-lg',
xl: 'rounded-xl',
full: 'rounded-full'
}[rounded]
return (
<div
className={`bg-slate-200 animate-pulse ${width} ${height} ${roundedClass} ${className}`}
/>
)
}
interface SkeletonCardProps {
/** Show image placeholder */
showImage?: boolean
/** Number of text lines */
lines?: number
/** Custom class names */
className?: string
}
/**
* Skeleton card with optional image and text lines
*/
export function SkeletonCard({ showImage = true, lines = 3, className = '' }: SkeletonCardProps) {
return (
<div className={`bg-white rounded-xl shadow-sm border p-4 ${className}`}>
{showImage && (
<SkeletonBox height="h-32" className="mb-4" />
)}
<SkeletonBox width="w-2/3" height="h-5" className="mb-3" />
<SkeletonText lines={lines} variant="paragraph" />
</div>
)
}
interface SkeletonOCRResultProps {
/** Custom class names */
className?: string
}
/**
* Skeleton specifically for OCR results display
* Shows loading state while OCR is processing
*/
export function SkeletonOCRResult({ className = '' }: SkeletonOCRResultProps) {
return (
<div className={`bg-slate-50 rounded-lg p-4 ${className}`}>
<div className="flex items-center gap-2 mb-4">
<div className="w-4 h-4 rounded-full bg-purple-200 animate-pulse" />
<SkeletonBox width="w-32" height="h-4" rounded="md" />
</div>
{/* Text area skeleton */}
<div className="bg-white border p-3 rounded space-y-2 mb-4">
<SkeletonText lines={4} variant="paragraph" />
</div>
{/* Metrics grid skeleton */}
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
{Array.from({ length: 4 }).map((_, i) => (
<div key={i} className="bg-white border rounded p-2">
<SkeletonBox width="w-16" height="h-3" className="mb-2" rounded="sm" />
<SkeletonBox width="w-12" height="h-5" rounded="sm" />
</div>
))}
</div>
</div>
)
}
interface SkeletonWrapperProps {
/** Whether to show skeleton or children */
loading: boolean
/** Content to show when not loading */
children: ReactNode
/** Skeleton component or elements to show */
skeleton?: ReactNode
/** Fallback skeleton lines (if skeleton prop not provided) */
lines?: number
/** Custom class names */
className?: string
}
/**
* Wrapper component that toggles between skeleton and content
*/
export function SkeletonWrapper({
loading,
children,
skeleton,
lines = 3,
className = ''
}: SkeletonWrapperProps) {
if (loading) {
return skeleton ? <>{skeleton}</> : <SkeletonText lines={lines} className={className} />
}
return <>{children}</>
}
interface SkeletonProgressProps {
/** Animation speed in seconds */
speed?: number
/** Custom class names */
className?: string
}
/**
* Animated progress skeleton with shimmer effect
*/
export function SkeletonProgress({ speed = 1.5, className = '' }: SkeletonProgressProps) {
return (
<div className={`relative overflow-hidden bg-slate-200 rounded-full h-2 ${className}`}>
<div
className="absolute inset-0 bg-gradient-to-r from-slate-200 via-slate-300 to-slate-200 animate-shimmer"
style={{
backgroundSize: '200% 100%',
animation: `shimmer ${speed}s infinite linear`
}}
/>
<style jsx>{`
@keyframes shimmer {
0% { background-position: 200% 0; }
100% { background-position: -200% 0; }
}
`}</style>
</div>
)
}
/**
* Pulsing dot indicator for inline loading
*/
export function SkeletonDots({ className = '' }: { className?: string }) {
return (
<span className={`inline-flex items-center gap-1 ${className}`}>
{[0, 1, 2].map((i) => (
<span
key={i}
className="w-1.5 h-1.5 rounded-full bg-purple-500 animate-pulse"
style={{ animationDelay: `${i * 200}ms` }}
/>
))}
</span>
)
}

View File

@@ -0,0 +1,312 @@
'use client'
import { useState, useEffect, useCallback } from 'react'
import { Settings, MessageSquare, HelpCircle, RefreshCw } from 'lucide-react'
import { CompanionMode, TeacherSettings, FeedbackType } from '@/lib/companion/types'
import { DEFAULT_TEACHER_SETTINGS, STORAGE_KEYS } from '@/lib/companion/constants'
// Components
import { ModeToggle } from './ModeToggle'
import { PhaseTimeline } from './companion-mode/PhaseTimeline'
import { StatsGrid } from './companion-mode/StatsGrid'
import { SuggestionList } from './companion-mode/SuggestionList'
import { EventsCard } from './companion-mode/EventsCard'
import { LessonContainer } from './lesson-mode/LessonContainer'
import { SettingsModal } from './modals/SettingsModal'
import { FeedbackModal } from './modals/FeedbackModal'
import { OnboardingModal } from './modals/OnboardingModal'
// Hooks
import { useCompanionData } from '@/hooks/companion/useCompanionData'
import { useLessonSession } from '@/hooks/companion/useLessonSession'
import { useKeyboardShortcuts } from '@/hooks/companion/useKeyboardShortcuts'
export function CompanionDashboard() {
// Mode state
const [mode, setMode] = useState<CompanionMode>('companion')
// Modal states
const [showSettings, setShowSettings] = useState(false)
const [showFeedback, setShowFeedback] = useState(false)
const [showOnboarding, setShowOnboarding] = useState(false)
// Settings
const [settings, setSettings] = useState<TeacherSettings>(DEFAULT_TEACHER_SETTINGS)
// Load settings from localStorage
useEffect(() => {
const stored = localStorage.getItem(STORAGE_KEYS.SETTINGS)
if (stored) {
try {
const parsed = JSON.parse(stored)
setSettings({ ...DEFAULT_TEACHER_SETTINGS, ...parsed })
} catch {
// Invalid stored settings
}
}
// Check if onboarding needed
const onboardingStored = localStorage.getItem(STORAGE_KEYS.ONBOARDING_STATE)
if (!onboardingStored) {
setShowOnboarding(true)
}
// Restore last mode
const lastMode = localStorage.getItem(STORAGE_KEYS.LAST_MODE) as CompanionMode
if (lastMode && ['companion', 'lesson', 'classic'].includes(lastMode)) {
setMode(lastMode)
}
}, [])
// Save mode to localStorage
useEffect(() => {
localStorage.setItem(STORAGE_KEYS.LAST_MODE, mode)
}, [mode])
// Companion data hook
const { data: companionData, loading: companionLoading, refresh } = useCompanionData()
// Lesson session hook
const {
session,
startLesson,
endLesson,
pauseLesson,
resumeLesson,
extendTime,
skipPhase,
saveReflection,
addHomework,
removeHomework,
isPaused,
} = useLessonSession({
onOvertimeStart: () => {
// Play sound if enabled
if (settings.soundNotifications) {
// TODO: Play notification sound
}
},
})
// Handle pause/resume toggle
const handlePauseToggle = useCallback(() => {
if (isPaused) {
resumeLesson()
} else {
pauseLesson()
}
}, [isPaused, pauseLesson, resumeLesson])
// Keyboard shortcuts
useKeyboardShortcuts({
onPauseResume: mode === 'lesson' && session ? handlePauseToggle : undefined,
onExtend: mode === 'lesson' && session && !isPaused ? () => extendTime(5) : undefined,
onNextPhase: mode === 'lesson' && session && !isPaused ? skipPhase : undefined,
onCloseModal: () => {
setShowSettings(false)
setShowFeedback(false)
setShowOnboarding(false)
},
enabled: settings.showKeyboardShortcuts,
})
// Handle settings save
const handleSaveSettings = (newSettings: TeacherSettings) => {
setSettings(newSettings)
localStorage.setItem(STORAGE_KEYS.SETTINGS, JSON.stringify(newSettings))
}
// Handle feedback submit
const handleFeedbackSubmit = async (type: FeedbackType, title: string, description: string) => {
const response = await fetch('/api/admin/companion/feedback', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
type,
title,
description,
sessionId: session?.sessionId,
}),
})
if (!response.ok) {
throw new Error('Failed to submit feedback')
}
}
// Handle onboarding complete
const handleOnboardingComplete = (data: { state?: string; schoolType?: string }) => {
localStorage.setItem(STORAGE_KEYS.ONBOARDING_STATE, JSON.stringify({
...data,
completed: true,
completedAt: new Date().toISOString(),
}))
setShowOnboarding(false)
setSettings({ ...settings, onboardingCompleted: true })
}
// Handle lesson start
const handleStartLesson = (data: { classId: string; subject: string; topic?: string; templateId?: string }) => {
startLesson(data)
setMode('lesson')
}
return (
<div className={`min-h-[calc(100vh-200px)] ${settings.highContrastMode ? 'high-contrast' : ''}`}>
{/* Header */}
<div className="flex items-center justify-between mb-6">
<ModeToggle
currentMode={mode}
onModeChange={setMode}
disabled={!!session && session.status === 'in_progress'}
/>
<div className="flex items-center gap-2">
{/* Refresh Button */}
{mode === 'companion' && (
<button
onClick={refresh}
disabled={companionLoading}
className="p-2 text-slate-500 hover:text-slate-700 hover:bg-slate-100 rounded-lg transition-colors"
title="Aktualisieren"
>
<RefreshCw className={`w-5 h-5 ${companionLoading ? 'animate-spin' : ''}`} />
</button>
)}
{/* Feedback Button */}
<button
onClick={() => setShowFeedback(true)}
className="p-2 text-slate-500 hover:text-slate-700 hover:bg-slate-100 rounded-lg transition-colors"
title="Feedback"
>
<MessageSquare className="w-5 h-5" />
</button>
{/* Settings Button */}
<button
onClick={() => setShowSettings(true)}
className="p-2 text-slate-500 hover:text-slate-700 hover:bg-slate-100 rounded-lg transition-colors"
title="Einstellungen"
>
<Settings className="w-5 h-5" />
</button>
{/* Help Button */}
<button
onClick={() => setShowOnboarding(true)}
className="p-2 text-slate-500 hover:text-slate-700 hover:bg-slate-100 rounded-lg transition-colors"
title="Hilfe"
>
<HelpCircle className="w-5 h-5" />
</button>
</div>
</div>
{/* Main Content */}
{mode === 'companion' && (
<div className="space-y-6">
{/* Phase Timeline */}
<div className="bg-white border border-slate-200 rounded-xl p-6">
<h3 className="text-sm font-medium text-slate-500 mb-4">Aktuelle Phase</h3>
{companionData ? (
<PhaseTimeline
phases={companionData.phases}
currentPhaseIndex={companionData.phases.findIndex(p => p.status === 'active')}
/>
) : (
<div className="h-10 bg-slate-100 rounded animate-pulse" />
)}
</div>
{/* Stats */}
<StatsGrid
stats={companionData?.stats || { classesCount: 0, studentsCount: 0, learningUnitsCreated: 0, gradesEntered: 0 }}
loading={companionLoading}
/>
{/* Two Column Layout */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{/* Suggestions */}
<SuggestionList
suggestions={companionData?.suggestions || []}
loading={companionLoading}
onSuggestionClick={(suggestion) => {
// Navigate to action target
window.location.href = suggestion.actionTarget
}}
/>
{/* Events */}
<EventsCard
events={companionData?.upcomingEvents || []}
loading={companionLoading}
/>
</div>
{/* Quick Start Lesson Button */}
<div className="bg-gradient-to-r from-blue-500 to-blue-600 rounded-xl p-6 text-white">
<div className="flex items-center justify-between">
<div>
<h3 className="text-xl font-semibold mb-1">Bereit fuer die naechste Stunde?</h3>
<p className="text-blue-100">Starten Sie den Lesson-Modus fuer strukturierten Unterricht.</p>
</div>
<button
onClick={() => setMode('lesson')}
className="px-6 py-3 bg-white text-blue-600 rounded-xl font-semibold hover:bg-blue-50 transition-colors"
>
Stunde starten
</button>
</div>
</div>
</div>
)}
{mode === 'lesson' && (
<LessonContainer
session={session}
onStartLesson={handleStartLesson}
onEndLesson={endLesson}
onPauseToggle={handlePauseToggle}
onExtendTime={extendTime}
onSkipPhase={skipPhase}
onSaveReflection={saveReflection}
onAddHomework={addHomework}
onRemoveHomework={removeHomework}
/>
)}
{mode === 'classic' && (
<div className="bg-white border border-slate-200 rounded-xl p-8 text-center">
<h2 className="text-xl font-semibold text-slate-800 mb-2">Classic Mode</h2>
<p className="text-slate-600 mb-4">
Die klassische Ansicht ohne Timer und Phasenstruktur.
</p>
<p className="text-sm text-slate-500">
Dieser Modus ist fuer flexible Unterrichtsgestaltung gedacht.
</p>
</div>
)}
{/* Modals */}
<SettingsModal
isOpen={showSettings}
onClose={() => setShowSettings(false)}
settings={settings}
onSave={handleSaveSettings}
/>
<FeedbackModal
isOpen={showFeedback}
onClose={() => setShowFeedback(false)}
onSubmit={handleFeedbackSubmit}
/>
<OnboardingModal
isOpen={showOnboarding}
onClose={() => setShowOnboarding(false)}
onComplete={handleOnboardingComplete}
/>
</div>
)
}

View File

@@ -0,0 +1,61 @@
'use client'
import { GraduationCap, Timer, Layout } from 'lucide-react'
import { CompanionMode } from '@/lib/companion/types'
interface ModeToggleProps {
currentMode: CompanionMode
onModeChange: (mode: CompanionMode) => void
disabled?: boolean
}
const modes: { id: CompanionMode; label: string; icon: React.ReactNode; description: string }[] = [
{
id: 'companion',
label: 'Companion',
icon: <GraduationCap className="w-4 h-4" />,
description: 'Dashboard mit Vorschlaegen',
},
{
id: 'lesson',
label: 'Lesson',
icon: <Timer className="w-4 h-4" />,
description: 'Timer und Phasen',
},
{
id: 'classic',
label: 'Classic',
icon: <Layout className="w-4 h-4" />,
description: 'Klassische Ansicht',
},
]
export function ModeToggle({ currentMode, onModeChange, disabled }: ModeToggleProps) {
return (
<div className="bg-white border border-slate-200 rounded-xl p-1 inline-flex gap-1">
{modes.map((mode) => {
const isActive = currentMode === mode.id
return (
<button
key={mode.id}
onClick={() => onModeChange(mode.id)}
disabled={disabled}
className={`
flex items-center gap-2 px-4 py-2 rounded-lg text-sm font-medium
transition-all duration-200
${isActive
? 'bg-slate-900 text-white shadow-sm'
: 'text-slate-600 hover:bg-slate-100 hover:text-slate-900'
}
${disabled ? 'opacity-50 cursor-not-allowed' : 'cursor-pointer'}
`}
title={mode.description}
>
{mode.icon}
<span>{mode.label}</span>
</button>
)
})}
</div>
)
}

View File

@@ -0,0 +1,173 @@
'use client'
import { Calendar, FileQuestion, Users, Clock, ChevronRight } from 'lucide-react'
import { UpcomingEvent, EventType } from '@/lib/companion/types'
import { EVENT_TYPE_CONFIG } from '@/lib/companion/constants'
interface EventsCardProps {
events: UpcomingEvent[]
onEventClick?: (event: UpcomingEvent) => void
loading?: boolean
maxItems?: number
}
const iconMap: Record<string, React.ComponentType<{ className?: string }>> = {
FileQuestion,
Users,
Clock,
Calendar,
}
function getEventIcon(type: EventType) {
const config = EVENT_TYPE_CONFIG[type]
const Icon = iconMap[config.icon] || Calendar
return { Icon, ...config }
}
function formatEventDate(dateStr: string, inDays: number): string {
if (inDays === 0) return 'Heute'
if (inDays === 1) return 'Morgen'
if (inDays < 7) return `In ${inDays} Tagen`
const date = new Date(dateStr)
return date.toLocaleDateString('de-DE', {
weekday: 'short',
day: 'numeric',
month: 'short',
})
}
interface EventItemProps {
event: UpcomingEvent
onClick?: () => void
}
function EventItem({ event, onClick }: EventItemProps) {
const { Icon, color, bg } = getEventIcon(event.type)
const isUrgent = event.inDays <= 2
return (
<button
onClick={onClick}
className={`
w-full flex items-center gap-3 p-3 rounded-lg
transition-all duration-200
hover:bg-slate-50
${isUrgent ? 'bg-red-50/50' : ''}
`}
>
<div className={`p-2 rounded-lg ${bg}`}>
<Icon className={`w-4 h-4 ${color}`} />
</div>
<div className="flex-1 text-left min-w-0">
<p className="font-medium text-slate-900 truncate">{event.title}</p>
<p className={`text-sm ${isUrgent ? 'text-red-600 font-medium' : 'text-slate-500'}`}>
{formatEventDate(event.date, event.inDays)}
</p>
</div>
<ChevronRight className="w-4 h-4 text-slate-400 flex-shrink-0" />
</button>
)
}
export function EventsCard({
events,
onEventClick,
loading,
maxItems = 5,
}: EventsCardProps) {
const displayEvents = events.slice(0, maxItems)
if (loading) {
return (
<div className="bg-white border border-slate-200 rounded-xl p-6">
<div className="flex items-center gap-2 mb-4">
<Calendar className="w-5 h-5 text-blue-500" />
<h3 className="font-semibold text-slate-900">Termine</h3>
</div>
<div className="space-y-2">
{[1, 2, 3].map((i) => (
<div key={i} className="h-14 bg-slate-100 rounded-lg animate-pulse" />
))}
</div>
</div>
)
}
if (events.length === 0) {
return (
<div className="bg-white border border-slate-200 rounded-xl p-6">
<div className="flex items-center gap-2 mb-4">
<Calendar className="w-5 h-5 text-blue-500" />
<h3 className="font-semibold text-slate-900">Termine</h3>
</div>
<div className="text-center py-6">
<Calendar className="w-10 h-10 text-slate-300 mx-auto mb-2" />
<p className="text-sm text-slate-500">Keine anstehenden Termine</p>
</div>
</div>
)
}
return (
<div className="bg-white border border-slate-200 rounded-xl p-6">
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-2">
<Calendar className="w-5 h-5 text-blue-500" />
<h3 className="font-semibold text-slate-900">Termine</h3>
</div>
<span className="text-sm text-slate-500">
{events.length} Termin{events.length !== 1 ? 'e' : ''}
</span>
</div>
<div className="space-y-1">
{displayEvents.map((event) => (
<EventItem
key={event.id}
event={event}
onClick={() => onEventClick?.(event)}
/>
))}
</div>
{events.length > maxItems && (
<button className="w-full mt-3 py-2 text-sm text-slate-600 hover:text-slate-900 hover:bg-slate-50 rounded-lg transition-colors">
Alle {events.length} anzeigen
</button>
)}
</div>
)
}
/**
* Compact inline version for header/toolbar
*/
export function EventsInline({ events }: { events: UpcomingEvent[] }) {
const nextEvent = events[0]
if (!nextEvent) {
return (
<div className="flex items-center gap-2 text-sm text-slate-500">
<Calendar className="w-4 h-4" />
<span>Keine Termine</span>
</div>
)
}
const { Icon, color } = getEventIcon(nextEvent.type)
const isUrgent = nextEvent.inDays <= 2
return (
<div className={`flex items-center gap-2 text-sm ${isUrgent ? 'text-red-600' : 'text-slate-600'}`}>
<Icon className={`w-4 h-4 ${color}`} />
<span className="truncate max-w-[150px]">{nextEvent.title}</span>
<span className="text-slate-400">-</span>
<span className={isUrgent ? 'font-medium' : ''}>
{formatEventDate(nextEvent.date, nextEvent.inDays)}
</span>
</div>
)
}

View File

@@ -0,0 +1,203 @@
'use client'
import { Check } from 'lucide-react'
import { Phase } from '@/lib/companion/types'
import { PHASE_COLORS, formatMinutes } from '@/lib/companion/constants'
interface PhaseTimelineProps {
phases: Phase[]
currentPhaseIndex: number
onPhaseClick?: (index: number) => void
compact?: boolean
}
export function PhaseTimeline({
phases,
currentPhaseIndex,
onPhaseClick,
compact = false,
}: PhaseTimelineProps) {
return (
<div className={`flex items-center ${compact ? 'gap-2' : 'gap-3'}`}>
{phases.map((phase, index) => {
const isActive = index === currentPhaseIndex
const isCompleted = phase.status === 'completed'
const isPast = index < currentPhaseIndex
const colors = PHASE_COLORS[phase.id]
return (
<div key={phase.id} className="flex items-center">
{/* Phase Dot/Circle */}
<button
onClick={() => onPhaseClick?.(index)}
disabled={!onPhaseClick}
className={`
relative flex items-center justify-center
${compact ? 'w-8 h-8' : 'w-10 h-10'}
rounded-full font-semibold text-sm
transition-all duration-300
${onPhaseClick ? 'cursor-pointer hover:scale-110' : 'cursor-default'}
${isActive
? `ring-4 ring-offset-2 ${colors.tailwind} text-white`
: isCompleted || isPast
? `${colors.tailwind} text-white opacity-80`
: 'bg-slate-200 text-slate-500'
}
`}
style={{
backgroundColor: isActive || isCompleted || isPast ? colors.hex : undefined,
// Use CSS custom property for ring color with Tailwind
'--tw-ring-color': isActive ? colors.hex : undefined,
} as React.CSSProperties}
title={`${phase.displayName} (${formatMinutes(phase.duration)})`}
>
{isCompleted ? (
<Check className={compact ? 'w-4 h-4' : 'w-5 h-5'} />
) : (
phase.shortName
)}
{/* Active indicator pulse */}
{isActive && (
<span
className="absolute inset-0 rounded-full animate-ping opacity-30"
style={{ backgroundColor: colors.hex }}
/>
)}
</button>
{/* Connector Line */}
{index < phases.length - 1 && (
<div
className={`
${compact ? 'w-4' : 'w-8'} h-1 mx-1
${isPast || isCompleted
? 'bg-gradient-to-r'
: 'bg-slate-200'
}
`}
style={{
background: isPast || isCompleted
? `linear-gradient(to right, ${colors.hex}, ${PHASE_COLORS[phases[index + 1].id].hex})`
: undefined,
}}
/>
)}
</div>
)
})}
</div>
)
}
/**
* Detailed Phase Timeline with labels and durations
*/
export function PhaseTimelineDetailed({
phases,
currentPhaseIndex,
onPhaseClick,
}: PhaseTimelineProps) {
return (
<div className="bg-white border border-slate-200 rounded-xl p-6">
<h3 className="text-sm font-medium text-slate-500 mb-4">Unterrichtsphasen</h3>
<div className="flex items-start justify-between">
{phases.map((phase, index) => {
const isActive = index === currentPhaseIndex
const isCompleted = phase.status === 'completed'
const isPast = index < currentPhaseIndex
const colors = PHASE_COLORS[phase.id]
return (
<div key={phase.id} className="flex flex-col items-center flex-1">
{/* Top connector line */}
<div className="w-full flex items-center mb-2">
{index > 0 && (
<div
className="flex-1 h-1"
style={{
background: isPast || isCompleted
? PHASE_COLORS[phases[index - 1].id].hex
: '#e2e8f0',
}}
/>
)}
{index === 0 && <div className="flex-1" />}
{/* Phase Circle */}
<button
onClick={() => onPhaseClick?.(index)}
disabled={!onPhaseClick}
className={`
relative w-12 h-12 rounded-full
flex items-center justify-center
font-bold text-lg
transition-all duration-300
${onPhaseClick ? 'cursor-pointer hover:scale-110' : 'cursor-default'}
${isActive ? 'ring-4 ring-offset-2 shadow-lg' : ''}
`}
style={{
backgroundColor: isActive || isCompleted || isPast ? colors.hex : '#e2e8f0',
color: isActive || isCompleted || isPast ? 'white' : '#64748b',
'--tw-ring-color': isActive ? `${colors.hex}40` : undefined,
} as React.CSSProperties}
>
{isCompleted ? (
<Check className="w-6 h-6" />
) : (
phase.shortName
)}
{isActive && (
<span
className="absolute inset-0 rounded-full animate-ping opacity-20"
style={{ backgroundColor: colors.hex }}
/>
)}
</button>
{index < phases.length - 1 && (
<div
className="flex-1 h-1"
style={{
background: isCompleted ? colors.hex : '#e2e8f0',
}}
/>
)}
{index === phases.length - 1 && <div className="flex-1" />}
</div>
{/* Phase Label */}
<span
className={`
text-sm font-medium mt-2
${isActive ? 'text-slate-900' : 'text-slate-500'}
`}
>
{phase.displayName}
</span>
{/* Duration */}
<span
className={`
text-xs mt-1
${isActive ? 'text-slate-700' : 'text-slate-400'}
`}
>
{formatMinutes(phase.duration)}
</span>
{/* Actual time if completed */}
{phase.actualTime !== undefined && phase.actualTime > 0 && (
<span className="text-xs text-slate-400 mt-0.5">
(tatsaechlich: {Math.round(phase.actualTime / 60)} Min)
</span>
)}
</div>
)
})}
</div>
</div>
)
}

View File

@@ -0,0 +1,114 @@
'use client'
import { Users, GraduationCap, BookOpen, FileCheck } from 'lucide-react'
import { CompanionStats } from '@/lib/companion/types'
interface StatsGridProps {
stats: CompanionStats
loading?: boolean
}
interface StatCardProps {
label: string
value: number
icon: React.ReactNode
color: string
loading?: boolean
}
function StatCard({ label, value, icon, color, loading }: StatCardProps) {
return (
<div className="bg-white border border-slate-200 rounded-xl p-4 hover:shadow-md transition-shadow">
<div className="flex items-start justify-between">
<div>
<p className="text-sm text-slate-500 mb-1">{label}</p>
{loading ? (
<div className="h-8 w-16 bg-slate-200 rounded animate-pulse" />
) : (
<p className="text-2xl font-bold text-slate-900">{value}</p>
)}
</div>
<div className={`p-2 rounded-lg ${color}`}>
{icon}
</div>
</div>
</div>
)
}
export function StatsGrid({ stats, loading }: StatsGridProps) {
const statCards = [
{
label: 'Klassen',
value: stats.classesCount,
icon: <Users className="w-5 h-5 text-blue-600" />,
color: 'bg-blue-100',
},
{
label: 'Schueler',
value: stats.studentsCount,
icon: <GraduationCap className="w-5 h-5 text-green-600" />,
color: 'bg-green-100',
},
{
label: 'Lerneinheiten',
value: stats.learningUnitsCreated,
icon: <BookOpen className="w-5 h-5 text-purple-600" />,
color: 'bg-purple-100',
},
{
label: 'Noten',
value: stats.gradesEntered,
icon: <FileCheck className="w-5 h-5 text-amber-600" />,
color: 'bg-amber-100',
},
]
return (
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
{statCards.map((card) => (
<StatCard
key={card.label}
label={card.label}
value={card.value}
icon={card.icon}
color={card.color}
loading={loading}
/>
))}
</div>
)
}
/**
* Compact version of StatsGrid for sidebar or smaller spaces
*/
export function StatsGridCompact({ stats, loading }: StatsGridProps) {
const items = [
{ label: 'Klassen', value: stats.classesCount, icon: <Users className="w-4 h-4" /> },
{ label: 'Schueler', value: stats.studentsCount, icon: <GraduationCap className="w-4 h-4" /> },
{ label: 'Einheiten', value: stats.learningUnitsCreated, icon: <BookOpen className="w-4 h-4" /> },
{ label: 'Noten', value: stats.gradesEntered, icon: <FileCheck className="w-4 h-4" /> },
]
return (
<div className="bg-white border border-slate-200 rounded-xl p-4">
<h3 className="text-sm font-medium text-slate-500 mb-3">Statistiken</h3>
<div className="space-y-3">
{items.map((item) => (
<div key={item.label} className="flex items-center justify-between">
<div className="flex items-center gap-2 text-slate-600">
{item.icon}
<span className="text-sm">{item.label}</span>
</div>
{loading ? (
<div className="h-5 w-8 bg-slate-200 rounded animate-pulse" />
) : (
<span className="font-semibold text-slate-900">{item.value}</span>
)}
</div>
))}
</div>
</div>
)
}

View File

@@ -0,0 +1,170 @@
'use client'
import { ChevronRight, Clock, Lightbulb, ClipboardCheck, BookOpen, Calendar, Users, MessageSquare, FileText } from 'lucide-react'
import { Suggestion, SuggestionPriority } from '@/lib/companion/types'
import { PRIORITY_COLORS } from '@/lib/companion/constants'
interface SuggestionListProps {
suggestions: Suggestion[]
onSuggestionClick?: (suggestion: Suggestion) => void
loading?: boolean
maxItems?: number
}
const iconMap: Record<string, React.ComponentType<{ className?: string }>> = {
ClipboardCheck,
BookOpen,
Calendar,
Users,
Clock,
MessageSquare,
FileText,
Lightbulb,
}
function getIcon(iconName: string) {
const Icon = iconMap[iconName] || Lightbulb
return Icon
}
interface SuggestionCardProps {
suggestion: Suggestion
onClick?: () => void
}
function SuggestionCard({ suggestion, onClick }: SuggestionCardProps) {
const priorityStyles = PRIORITY_COLORS[suggestion.priority]
const Icon = getIcon(suggestion.icon)
return (
<button
onClick={onClick}
className={`
w-full p-4 rounded-xl border text-left
transition-all duration-200
hover:shadow-md hover:scale-[1.01]
${priorityStyles.bg} ${priorityStyles.border}
`}
>
<div className="flex items-start gap-3">
{/* Priority Dot & Icon */}
<div className="flex-shrink-0 relative">
<div className={`p-2 rounded-lg bg-white shadow-sm`}>
<Icon className={`w-5 h-5 ${priorityStyles.text}`} />
</div>
<span
className={`absolute -top-1 -right-1 w-3 h-3 rounded-full ${priorityStyles.dot}`}
title={`Prioritaet: ${suggestion.priority}`}
/>
</div>
{/* Content */}
<div className="flex-1 min-w-0">
<h4 className={`font-medium ${priorityStyles.text} mb-1`}>
{suggestion.title}
</h4>
<p className="text-sm text-slate-600 line-clamp-2">
{suggestion.description}
</p>
{/* Meta */}
<div className="flex items-center gap-3 mt-2">
<span className="inline-flex items-center gap-1 text-xs text-slate-500">
<Clock className="w-3 h-3" />
~{suggestion.estimatedTime} Min
</span>
<span className={`text-xs font-medium px-2 py-0.5 rounded-full ${priorityStyles.bg} ${priorityStyles.text}`}>
{suggestion.priority}
</span>
</div>
</div>
{/* Arrow */}
<ChevronRight className="w-5 h-5 text-slate-400 flex-shrink-0" />
</div>
</button>
)
}
export function SuggestionList({
suggestions,
onSuggestionClick,
loading,
maxItems = 5,
}: SuggestionListProps) {
// Sort by priority: urgent > high > medium > low
const priorityOrder: Record<SuggestionPriority, number> = {
urgent: 0,
high: 1,
medium: 2,
low: 3,
}
const sortedSuggestions = [...suggestions]
.sort((a, b) => priorityOrder[a.priority] - priorityOrder[b.priority])
.slice(0, maxItems)
if (loading) {
return (
<div className="bg-white border border-slate-200 rounded-xl p-6">
<div className="flex items-center gap-2 mb-4">
<Lightbulb className="w-5 h-5 text-amber-500" />
<h3 className="font-semibold text-slate-900">Vorschlaege</h3>
</div>
<div className="space-y-3">
{[1, 2, 3].map((i) => (
<div key={i} className="h-24 bg-slate-100 rounded-xl animate-pulse" />
))}
</div>
</div>
)
}
if (suggestions.length === 0) {
return (
<div className="bg-white border border-slate-200 rounded-xl p-6">
<div className="flex items-center gap-2 mb-4">
<Lightbulb className="w-5 h-5 text-amber-500" />
<h3 className="font-semibold text-slate-900">Vorschlaege</h3>
</div>
<div className="text-center py-8">
<div className="w-12 h-12 bg-green-100 rounded-full flex items-center justify-center mx-auto mb-3">
<ClipboardCheck className="w-6 h-6 text-green-600" />
</div>
<p className="text-slate-600">Alles erledigt!</p>
<p className="text-sm text-slate-500 mt-1">Keine offenen Aufgaben</p>
</div>
</div>
)
}
return (
<div className="bg-white border border-slate-200 rounded-xl p-6">
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-2">
<Lightbulb className="w-5 h-5 text-amber-500" />
<h3 className="font-semibold text-slate-900">Vorschlaege</h3>
</div>
<span className="text-sm text-slate-500">
{suggestions.length} Aufgabe{suggestions.length !== 1 ? 'n' : ''}
</span>
</div>
<div className="space-y-3">
{sortedSuggestions.map((suggestion) => (
<SuggestionCard
key={suggestion.id}
suggestion={suggestion}
onClick={() => onSuggestionClick?.(suggestion)}
/>
))}
</div>
{suggestions.length > maxItems && (
<button className="w-full mt-4 py-2 text-sm text-slate-600 hover:text-slate-900 hover:bg-slate-50 rounded-lg transition-colors">
Alle {suggestions.length} anzeigen
</button>
)}
</div>
)
}

View File

@@ -0,0 +1,24 @@
// Main components
export { CompanionDashboard } from './CompanionDashboard'
export { ModeToggle } from './ModeToggle'
// Companion Mode components
export { PhaseTimeline, PhaseTimelineDetailed } from './companion-mode/PhaseTimeline'
export { StatsGrid, StatsGridCompact } from './companion-mode/StatsGrid'
export { SuggestionList } from './companion-mode/SuggestionList'
export { EventsCard, EventsInline } from './companion-mode/EventsCard'
// Lesson Mode components
export { LessonContainer } from './lesson-mode/LessonContainer'
export { LessonStartForm } from './lesson-mode/LessonStartForm'
export { LessonActiveView } from './lesson-mode/LessonActiveView'
export { LessonEndedView } from './lesson-mode/LessonEndedView'
export { VisualPieTimer, CompactTimer } from './lesson-mode/VisualPieTimer'
export { QuickActionsBar, QuickActionsCompact } from './lesson-mode/QuickActionsBar'
export { HomeworkSection } from './lesson-mode/HomeworkSection'
export { ReflectionSection } from './lesson-mode/ReflectionSection'
// Modals
export { SettingsModal } from './modals/SettingsModal'
export { FeedbackModal } from './modals/FeedbackModal'
export { OnboardingModal } from './modals/OnboardingModal'

View File

@@ -0,0 +1,153 @@
'use client'
import { useState } from 'react'
import { Plus, Trash2, BookOpen, Calendar } from 'lucide-react'
import { Homework } from '@/lib/companion/types'
interface HomeworkSectionProps {
homeworkList: Homework[]
onAdd: (title: string, dueDate: string) => void
onRemove: (id: string) => void
}
export function HomeworkSection({ homeworkList, onAdd, onRemove }: HomeworkSectionProps) {
const [newTitle, setNewTitle] = useState('')
const [newDueDate, setNewDueDate] = useState('')
const [isAdding, setIsAdding] = useState(false)
// Default due date to next week
const getDefaultDueDate = () => {
const date = new Date()
date.setDate(date.getDate() + 7)
return date.toISOString().split('T')[0]
}
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault()
if (!newTitle.trim()) return
onAdd(newTitle.trim(), newDueDate || getDefaultDueDate())
setNewTitle('')
setNewDueDate('')
setIsAdding(false)
}
const formatDate = (dateStr: string) => {
const date = new Date(dateStr)
return date.toLocaleDateString('de-DE', {
weekday: 'short',
day: 'numeric',
month: 'short',
})
}
return (
<div className="bg-white border border-slate-200 rounded-xl p-6">
<div className="flex items-center justify-between mb-4">
<h3 className="font-semibold text-slate-900 flex items-center gap-2">
<BookOpen className="w-5 h-5 text-slate-400" />
Hausaufgaben
</h3>
{!isAdding && (
<button
onClick={() => setIsAdding(true)}
className="flex items-center gap-2 px-3 py-2 text-sm font-medium text-blue-600 hover:bg-blue-50 rounded-lg transition-colors"
>
<Plus className="w-4 h-4" />
Hinzufuegen
</button>
)}
</div>
{/* Add Form */}
{isAdding && (
<form onSubmit={handleSubmit} className="mb-4 p-4 bg-blue-50 rounded-xl">
<div className="space-y-3">
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">
Aufgabe
</label>
<input
type="text"
value={newTitle}
onChange={(e) => setNewTitle(e.target.value)}
placeholder="z.B. Aufgabe 1-5 auf S. 42..."
className="w-full px-4 py-2 border border-slate-200 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
autoFocus
/>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">
Faellig am
</label>
<input
type="date"
value={newDueDate}
onChange={(e) => setNewDueDate(e.target.value)}
min={new Date().toISOString().split('T')[0]}
className="w-full px-4 py-2 border border-slate-200 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
/>
</div>
<div className="flex gap-2">
<button
type="submit"
disabled={!newTitle.trim()}
className="flex-1 py-2 bg-blue-600 text-white rounded-lg font-medium hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed"
>
Speichern
</button>
<button
type="button"
onClick={() => {
setIsAdding(false)
setNewTitle('')
setNewDueDate('')
}}
className="px-4 py-2 text-slate-600 hover:bg-slate-100 rounded-lg"
>
Abbrechen
</button>
</div>
</div>
</form>
)}
{/* Homework List */}
{homeworkList.length === 0 ? (
<div className="text-center py-8">
<BookOpen className="w-10 h-10 text-slate-300 mx-auto mb-2" />
<p className="text-slate-500">Keine Hausaufgaben eingetragen</p>
<p className="text-sm text-slate-400 mt-1">
Fuegen Sie Hausaufgaben hinzu, um sie zu dokumentieren
</p>
</div>
) : (
<div className="space-y-3">
{homeworkList.map((hw) => (
<div
key={hw.id}
className="flex items-start gap-3 p-4 bg-slate-50 rounded-xl group"
>
<div className="flex-1">
<p className="font-medium text-slate-900">{hw.title}</p>
<div className="flex items-center gap-2 mt-1 text-sm text-slate-500">
<Calendar className="w-3 h-3" />
<span>Faellig: {formatDate(hw.dueDate)}</span>
</div>
</div>
<button
onClick={() => onRemove(hw.id)}
className="p-2 text-slate-400 hover:text-red-500 hover:bg-red-50 rounded-lg opacity-0 group-hover:opacity-100 transition-all"
title="Entfernen"
>
<Trash2 className="w-4 h-4" />
</button>
</div>
))}
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,172 @@
'use client'
import { BookOpen, Clock, Users } from 'lucide-react'
import { LessonSession } from '@/lib/companion/types'
import { VisualPieTimer } from './VisualPieTimer'
import { QuickActionsBar } from './QuickActionsBar'
import { PhaseTimelineDetailed } from '../companion-mode/PhaseTimeline'
import {
PHASE_COLORS,
PHASE_DISPLAY_NAMES,
formatTime,
getTimerColorStatus,
} from '@/lib/companion/constants'
interface LessonActiveViewProps {
session: LessonSession
onPauseToggle: () => void
onExtendTime: (minutes: number) => void
onSkipPhase: () => void
onEndLesson: () => void
}
export function LessonActiveView({
session,
onPauseToggle,
onExtendTime,
onSkipPhase,
onEndLesson,
}: LessonActiveViewProps) {
const currentPhase = session.phases[session.currentPhaseIndex]
const phaseId = currentPhase?.phase || 'einstieg'
const phaseColor = PHASE_COLORS[phaseId].hex
const phaseName = PHASE_DISPLAY_NAMES[phaseId]
// Calculate timer values
const phaseDurationSeconds = (currentPhase?.duration || 0) * 60
const elapsedInPhase = currentPhase?.actualTime || 0
const remainingSeconds = phaseDurationSeconds - elapsedInPhase
const progress = Math.min(elapsedInPhase / phaseDurationSeconds, 1)
const isOvertime = remainingSeconds < 0
const colorStatus = getTimerColorStatus(remainingSeconds, isOvertime)
const isLastPhase = session.currentPhaseIndex === session.phases.length - 1
// Calculate total elapsed
const totalElapsedMinutes = Math.floor(session.elapsedTime / 60)
return (
<div className="space-y-6">
{/* Header with Session Info */}
<div
className="bg-gradient-to-r rounded-xl p-6 text-white"
style={{
background: `linear-gradient(135deg, ${phaseColor}, ${phaseColor}dd)`,
}}
>
<div className="flex items-start justify-between">
<div>
<div className="flex items-center gap-2 text-white/80 text-sm mb-1">
<Users className="w-4 h-4" />
<span>{session.className}</span>
<span className="mx-2">|</span>
<BookOpen className="w-4 h-4" />
<span>{session.subject}</span>
</div>
<h2 className="text-2xl font-bold">
{session.topic || phaseName}
</h2>
</div>
<div className="text-right">
<div className="text-white/80 text-sm">Gesamtzeit</div>
<div className="text-xl font-mono font-bold">
{formatTime(session.elapsedTime)}
</div>
</div>
</div>
</div>
{/* Main Timer Section */}
<div className="bg-white border border-slate-200 rounded-xl p-8">
<div className="flex flex-col items-center">
{/* Visual Pie Timer */}
<VisualPieTimer
progress={progress}
remainingSeconds={remainingSeconds}
totalSeconds={phaseDurationSeconds}
colorStatus={colorStatus}
isPaused={session.isPaused}
currentPhaseName={phaseName}
phaseColor={phaseColor}
onTogglePause={onPauseToggle}
size="lg"
/>
{/* Quick Actions */}
<div className="mt-8 w-full max-w-md">
<QuickActionsBar
onExtend={onExtendTime}
onPause={onPauseToggle}
onResume={onPauseToggle}
onSkip={onSkipPhase}
onEnd={onEndLesson}
isPaused={session.isPaused}
isLastPhase={isLastPhase}
/>
</div>
</div>
</div>
{/* Phase Timeline */}
<PhaseTimelineDetailed
phases={session.phases.map((p, i) => ({
id: p.phase,
shortName: p.phase[0].toUpperCase(),
displayName: PHASE_DISPLAY_NAMES[p.phase],
duration: p.duration,
status: p.status === 'active' ? 'active' : p.status === 'completed' ? 'completed' : 'planned',
actualTime: p.actualTime,
color: PHASE_COLORS[p.phase].hex,
}))}
currentPhaseIndex={session.currentPhaseIndex}
onPhaseClick={(index) => {
// Optional: Allow clicking to navigate to a phase
}}
/>
{/* Lesson Stats */}
<div className="grid grid-cols-3 gap-4">
<div className="bg-white border border-slate-200 rounded-xl p-4 text-center">
<Clock className="w-5 h-5 text-slate-400 mx-auto mb-2" />
<div className="text-2xl font-bold text-slate-900">{totalElapsedMinutes}</div>
<div className="text-sm text-slate-500">Minuten vergangen</div>
</div>
<div className="bg-white border border-slate-200 rounded-xl p-4 text-center">
<div
className="w-5 h-5 rounded-full mx-auto mb-2"
style={{ backgroundColor: phaseColor }}
/>
<div className="text-2xl font-bold text-slate-900">
{session.currentPhaseIndex + 1}/{session.phases.length}
</div>
<div className="text-sm text-slate-500">Phase</div>
</div>
<div className="bg-white border border-slate-200 rounded-xl p-4 text-center">
<Clock className="w-5 h-5 text-slate-400 mx-auto mb-2" />
<div className="text-2xl font-bold text-slate-900">
{session.totalPlannedDuration - totalElapsedMinutes}
</div>
<div className="text-sm text-slate-500">Minuten verbleibend</div>
</div>
</div>
{/* Keyboard Shortcuts Hint */}
<div className="text-center text-sm text-slate-400">
<span className="inline-flex items-center gap-4">
<span>
<kbd className="px-2 py-1 bg-slate-100 rounded text-xs">Leertaste</kbd> Pause
</span>
<span>
<kbd className="px-2 py-1 bg-slate-100 rounded text-xs">E</kbd> +5 Min
</span>
<span>
<kbd className="px-2 py-1 bg-slate-100 rounded text-xs">N</kbd> Weiter
</span>
</span>
</div>
</div>
)
}

View File

@@ -0,0 +1,80 @@
'use client'
import { LessonSession, LessonStatus } from '@/lib/companion/types'
import { LessonStartForm } from './LessonStartForm'
import { LessonActiveView } from './LessonActiveView'
import { LessonEndedView } from './LessonEndedView'
interface LessonContainerProps {
session: LessonSession | null
onStartLesson: (data: { classId: string; subject: string; topic?: string; templateId?: string }) => void
onEndLesson: () => void
onPauseToggle: () => void
onExtendTime: (minutes: number) => void
onSkipPhase: () => void
onSaveReflection: (rating: number, notes: string, nextSteps: string) => void
onAddHomework: (title: string, dueDate: string) => void
onRemoveHomework: (id: string) => void
loading?: boolean
}
export function LessonContainer({
session,
onStartLesson,
onEndLesson,
onPauseToggle,
onExtendTime,
onSkipPhase,
onSaveReflection,
onAddHomework,
onRemoveHomework,
loading,
}: LessonContainerProps) {
// Determine which view to show based on session state
const getView = (): 'start' | 'active' | 'ended' => {
if (!session) return 'start'
const status = session.status
if (status === 'completed') return 'ended'
if (status === 'not_started') return 'start'
return 'active'
}
const view = getView()
if (view === 'start') {
return (
<LessonStartForm
onStart={onStartLesson}
loading={loading}
/>
)
}
if (view === 'ended' && session) {
return (
<LessonEndedView
session={session}
onSaveReflection={onSaveReflection}
onAddHomework={onAddHomework}
onRemoveHomework={onRemoveHomework}
onStartNew={() => onEndLesson()} // This will clear the session and show start form
/>
)
}
if (session) {
return (
<LessonActiveView
session={session}
onPauseToggle={onPauseToggle}
onExtendTime={onExtendTime}
onSkipPhase={onSkipPhase}
onEndLesson={onEndLesson}
/>
)
}
return null
}

View File

@@ -0,0 +1,209 @@
'use client'
import { useState } from 'react'
import { CheckCircle, Clock, BarChart3, Plus, RefreshCw } from 'lucide-react'
import { LessonSession } from '@/lib/companion/types'
import { HomeworkSection } from './HomeworkSection'
import { ReflectionSection } from './ReflectionSection'
import {
PHASE_COLORS,
PHASE_DISPLAY_NAMES,
formatTime,
formatMinutes,
} from '@/lib/companion/constants'
interface LessonEndedViewProps {
session: LessonSession
onSaveReflection: (rating: number, notes: string, nextSteps: string) => void
onAddHomework: (title: string, dueDate: string) => void
onRemoveHomework: (id: string) => void
onStartNew: () => void
}
export function LessonEndedView({
session,
onSaveReflection,
onAddHomework,
onRemoveHomework,
onStartNew,
}: LessonEndedViewProps) {
const [activeTab, setActiveTab] = useState<'summary' | 'homework' | 'reflection'>('summary')
// Calculate analytics
const totalPlannedSeconds = session.totalPlannedDuration * 60
const totalActualSeconds = session.elapsedTime
const timeDiff = totalActualSeconds - totalPlannedSeconds
const timeDiffMinutes = Math.round(timeDiff / 60)
const startTime = new Date(session.startTime)
const endTime = session.endTime ? new Date(session.endTime) : new Date()
return (
<div className="space-y-6">
{/* Success Header */}
<div className="bg-gradient-to-r from-green-500 to-green-600 rounded-xl p-6 text-white">
<div className="flex items-center gap-4">
<div className="p-3 bg-white/20 rounded-full">
<CheckCircle className="w-8 h-8" />
</div>
<div>
<h2 className="text-2xl font-bold">Stunde beendet!</h2>
<p className="text-green-100">
{session.className} - {session.subject}
{session.topic && ` - ${session.topic}`}
</p>
</div>
</div>
</div>
{/* Tab Navigation */}
<div className="bg-white border border-slate-200 rounded-xl p-1 flex">
{[
{ id: 'summary', label: 'Zusammenfassung', icon: BarChart3 },
{ id: 'homework', label: 'Hausaufgaben', icon: Plus },
{ id: 'reflection', label: 'Reflexion', icon: RefreshCw },
].map((tab) => (
<button
key={tab.id}
onClick={() => setActiveTab(tab.id as typeof activeTab)}
className={`
flex-1 flex items-center justify-center gap-2 py-3 px-4 rounded-lg
font-medium transition-all duration-200
${activeTab === tab.id
? 'bg-slate-900 text-white'
: 'text-slate-600 hover:bg-slate-100'
}
`}
>
<tab.icon className="w-4 h-4" />
{tab.label}
</button>
))}
</div>
{/* Tab Content */}
{activeTab === 'summary' && (
<div className="space-y-6">
{/* Time Overview */}
<div className="bg-white border border-slate-200 rounded-xl p-6">
<h3 className="font-semibold text-slate-900 mb-4 flex items-center gap-2">
<Clock className="w-5 h-5 text-slate-400" />
Zeitauswertung
</h3>
<div className="grid grid-cols-3 gap-4 mb-6">
<div className="text-center p-4 bg-slate-50 rounded-xl">
<div className="text-2xl font-bold text-slate-900">
{formatTime(totalActualSeconds)}
</div>
<div className="text-sm text-slate-500">Tatsaechlich</div>
</div>
<div className="text-center p-4 bg-slate-50 rounded-xl">
<div className="text-2xl font-bold text-slate-900">
{formatMinutes(session.totalPlannedDuration)}
</div>
<div className="text-sm text-slate-500">Geplant</div>
</div>
<div className={`text-center p-4 rounded-xl ${timeDiff > 0 ? 'bg-amber-50' : 'bg-green-50'}`}>
<div className={`text-2xl font-bold ${timeDiff > 0 ? 'text-amber-600' : 'text-green-600'}`}>
{timeDiffMinutes > 0 ? '+' : ''}{timeDiffMinutes} Min
</div>
<div className={`text-sm ${timeDiff > 0 ? 'text-amber-500' : 'text-green-500'}`}>
Differenz
</div>
</div>
</div>
{/* Session Times */}
<div className="flex items-center justify-between text-sm text-slate-500 border-t border-slate-100 pt-4">
<span>Start: {startTime.toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' })}</span>
<span>Ende: {endTime.toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' })}</span>
</div>
</div>
{/* Phase Breakdown */}
<div className="bg-white border border-slate-200 rounded-xl p-6">
<h3 className="font-semibold text-slate-900 mb-4 flex items-center gap-2">
<BarChart3 className="w-5 h-5 text-slate-400" />
Phasen-Analyse
</h3>
<div className="space-y-4">
{session.phases.map((phase) => {
const plannedSeconds = phase.duration * 60
const actualSeconds = phase.actualTime
const diff = actualSeconds - plannedSeconds
const diffMinutes = Math.round(diff / 60)
const percentage = Math.min((actualSeconds / plannedSeconds) * 100, 150)
return (
<div key={phase.phase} className="space-y-2">
<div className="flex items-center justify-between text-sm">
<div className="flex items-center gap-2">
<div
className="w-3 h-3 rounded-full"
style={{ backgroundColor: PHASE_COLORS[phase.phase].hex }}
/>
<span className="font-medium text-slate-700">
{PHASE_DISPLAY_NAMES[phase.phase]}
</span>
</div>
<div className="flex items-center gap-3 text-slate-500">
<span>{Math.round(actualSeconds / 60)} / {phase.duration} Min</span>
<span className={`
px-2 py-0.5 rounded text-xs font-medium
${diff > 60 ? 'bg-amber-100 text-amber-700' : diff < -60 ? 'bg-blue-100 text-blue-700' : 'bg-green-100 text-green-700'}
`}>
{diffMinutes > 0 ? '+' : ''}{diffMinutes} Min
</span>
</div>
</div>
{/* Progress Bar */}
<div className="h-3 bg-slate-100 rounded-full overflow-hidden">
<div
className="h-full rounded-full transition-all duration-500"
style={{
width: `${Math.min(percentage, 100)}%`,
backgroundColor: percentage > 100
? '#f59e0b' // amber for overtime
: PHASE_COLORS[phase.phase].hex,
}}
/>
</div>
</div>
)
})}
</div>
</div>
</div>
)}
{activeTab === 'homework' && (
<HomeworkSection
homeworkList={session.homeworkList}
onAdd={onAddHomework}
onRemove={onRemoveHomework}
/>
)}
{activeTab === 'reflection' && (
<ReflectionSection
reflection={session.reflection}
onSave={onSaveReflection}
/>
)}
{/* Start New Lesson Button */}
<div className="pt-4">
<button
onClick={onStartNew}
className="w-full py-4 px-6 bg-slate-900 text-white rounded-xl font-semibold hover:bg-slate-800 transition-colors flex items-center justify-center gap-2"
>
<RefreshCw className="w-5 h-5" />
Neue Stunde starten
</button>
</div>
</div>
)
}

View File

@@ -0,0 +1,269 @@
'use client'
import { useState } from 'react'
import { Play, Clock, BookOpen, Users, ChevronDown, Info } from 'lucide-react'
import { LessonTemplate, PhaseDurations, Class } from '@/lib/companion/types'
import {
SYSTEM_TEMPLATES,
DEFAULT_PHASE_DURATIONS,
PHASE_DISPLAY_NAMES,
PHASE_ORDER,
calculateTotalDuration,
formatMinutes,
} from '@/lib/companion/constants'
interface LessonStartFormProps {
onStart: (data: {
classId: string
subject: string
topic?: string
templateId?: string
}) => void
loading?: boolean
availableClasses?: Class[]
}
// Mock classes for development
const MOCK_CLASSES: Class[] = [
{ id: 'c1', name: '9a', grade: '9', studentCount: 28 },
{ id: 'c2', name: '9b', grade: '9', studentCount: 26 },
{ id: 'c3', name: '10a', grade: '10', studentCount: 24 },
{ id: 'c4', name: 'Deutsch LK', grade: 'Q1', studentCount: 18 },
{ id: 'c5', name: 'Mathe GK', grade: 'Q2', studentCount: 22 },
]
const SUBJECTS = [
'Deutsch',
'Mathematik',
'Englisch',
'Biologie',
'Physik',
'Chemie',
'Geschichte',
'Geographie',
'Politik',
'Kunst',
'Musik',
'Sport',
'Informatik',
'Sonstiges',
]
export function LessonStartForm({
onStart,
loading,
availableClasses = MOCK_CLASSES,
}: LessonStartFormProps) {
const [selectedClass, setSelectedClass] = useState('')
const [selectedSubject, setSelectedSubject] = useState('')
const [topic, setTopic] = useState('')
const [selectedTemplate, setSelectedTemplate] = useState<LessonTemplate | null>(
SYSTEM_TEMPLATES[0] as LessonTemplate
)
const [showTemplateDetails, setShowTemplateDetails] = useState(false)
const totalDuration = selectedTemplate
? calculateTotalDuration(selectedTemplate.durations)
: calculateTotalDuration(DEFAULT_PHASE_DURATIONS)
const canStart = selectedClass && selectedSubject
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault()
if (!canStart) return
onStart({
classId: selectedClass,
subject: selectedSubject,
topic: topic || undefined,
templateId: selectedTemplate?.templateId,
})
}
return (
<div className="bg-white border border-slate-200 rounded-xl p-6">
<div className="flex items-center gap-3 mb-6">
<div className="p-3 bg-blue-100 rounded-xl">
<Play className="w-6 h-6 text-blue-600" />
</div>
<div>
<h2 className="text-xl font-semibold text-slate-900">Neue Stunde starten</h2>
<p className="text-sm text-slate-500">
Waehlen Sie Klasse, Fach und Template
</p>
</div>
</div>
<form onSubmit={handleSubmit} className="space-y-6">
{/* Class Selection */}
<div>
<label className="block text-sm font-medium text-slate-700 mb-2">
<Users className="w-4 h-4 inline mr-2" />
Klasse *
</label>
<select
value={selectedClass}
onChange={(e) => setSelectedClass(e.target.value)}
className="w-full px-4 py-3 border border-slate-200 rounded-xl focus:ring-2 focus:ring-blue-500 focus:border-blue-500 bg-white"
required
>
<option value="">Klasse auswaehlen...</option>
{availableClasses.map((cls) => (
<option key={cls.id} value={cls.id}>
{cls.name} ({cls.studentCount} Schueler)
</option>
))}
</select>
</div>
{/* Subject Selection */}
<div>
<label className="block text-sm font-medium text-slate-700 mb-2">
<BookOpen className="w-4 h-4 inline mr-2" />
Fach *
</label>
<select
value={selectedSubject}
onChange={(e) => setSelectedSubject(e.target.value)}
className="w-full px-4 py-3 border border-slate-200 rounded-xl focus:ring-2 focus:ring-blue-500 focus:border-blue-500 bg-white"
required
>
<option value="">Fach auswaehlen...</option>
{SUBJECTS.map((subject) => (
<option key={subject} value={subject}>
{subject}
</option>
))}
</select>
</div>
{/* Topic (Optional) */}
<div>
<label className="block text-sm font-medium text-slate-700 mb-2">
Thema (optional)
</label>
<input
type="text"
value={topic}
onChange={(e) => setTopic(e.target.value)}
placeholder="z.B. Quadratische Funktionen, Gedichtanalyse..."
className="w-full px-4 py-3 border border-slate-200 rounded-xl focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
/>
</div>
{/* Template Selection */}
<div>
<label className="block text-sm font-medium text-slate-700 mb-2">
<Clock className="w-4 h-4 inline mr-2" />
Template
</label>
<div className="space-y-2">
{SYSTEM_TEMPLATES.map((template) => {
const tpl = template as LessonTemplate
const isSelected = selectedTemplate?.templateId === tpl.templateId
const total = calculateTotalDuration(tpl.durations)
return (
<button
key={tpl.templateId}
type="button"
onClick={() => setSelectedTemplate(tpl)}
className={`
w-full p-4 rounded-xl border text-left transition-all
${isSelected
? 'border-blue-500 bg-blue-50 ring-2 ring-blue-500/20'
: 'border-slate-200 hover:border-slate-300 hover:bg-slate-50'
}
`}
>
<div className="flex items-center justify-between">
<div>
<p className={`font-medium ${isSelected ? 'text-blue-900' : 'text-slate-900'}`}>
{tpl.name}
</p>
<p className="text-sm text-slate-500">{tpl.description}</p>
</div>
<span className={`text-sm font-medium ${isSelected ? 'text-blue-600' : 'text-slate-500'}`}>
{formatMinutes(total)}
</span>
</div>
</button>
)
})}
</div>
{/* Template Details Toggle */}
{selectedTemplate && (
<button
type="button"
onClick={() => setShowTemplateDetails(!showTemplateDetails)}
className="mt-3 flex items-center gap-2 text-sm text-slate-600 hover:text-slate-900"
>
<Info className="w-4 h-4" />
Phasendauern anzeigen
<ChevronDown className={`w-4 h-4 transition-transform ${showTemplateDetails ? 'rotate-180' : ''}`} />
</button>
)}
{/* Template Details */}
{showTemplateDetails && selectedTemplate && (
<div className="mt-3 p-4 bg-slate-50 rounded-xl">
<div className="grid grid-cols-5 gap-2">
{PHASE_ORDER.map((phaseId) => (
<div key={phaseId} className="text-center">
<p className="text-xs text-slate-500">{PHASE_DISPLAY_NAMES[phaseId]}</p>
<p className="font-medium text-slate-900">
{selectedTemplate.durations[phaseId]} Min
</p>
</div>
))}
</div>
</div>
)}
</div>
{/* Summary & Start Button */}
<div className="pt-4 border-t border-slate-200">
<div className="flex items-center justify-between mb-4">
<div className="text-sm text-slate-600">
Gesamtdauer: <span className="font-semibold">{formatMinutes(totalDuration)}</span>
</div>
{selectedClass && (
<div className="text-sm text-slate-600">
Klasse: <span className="font-semibold">
{availableClasses.find((c) => c.id === selectedClass)?.name}
</span>
</div>
)}
</div>
<button
type="submit"
disabled={!canStart || loading}
className={`
w-full py-4 px-6 rounded-xl font-semibold text-lg
flex items-center justify-center gap-3
transition-all duration-200
${canStart && !loading
? 'bg-blue-600 hover:bg-blue-700 text-white shadow-lg shadow-blue-500/25'
: 'bg-slate-200 text-slate-500 cursor-not-allowed'
}
`}
>
{loading ? (
<>
<div className="w-5 h-5 border-2 border-white/30 border-t-white rounded-full animate-spin" />
Stunde wird gestartet...
</>
) : (
<>
<Play className="w-5 h-5" />
Stunde starten
</>
)}
</button>
</div>
</form>
</div>
)
}

View File

@@ -0,0 +1,194 @@
'use client'
import { Plus, Pause, Play, SkipForward, Square, Clock } from 'lucide-react'
interface QuickActionsBarProps {
onExtend: (minutes: number) => void
onPause: () => void
onResume: () => void
onSkip: () => void
onEnd: () => void
isPaused: boolean
isLastPhase: boolean
disabled?: boolean
}
export function QuickActionsBar({
onExtend,
onPause,
onResume,
onSkip,
onEnd,
isPaused,
isLastPhase,
disabled,
}: QuickActionsBarProps) {
return (
<div
className="flex items-center justify-center gap-3 p-4 bg-white border border-slate-200 rounded-xl"
role="toolbar"
aria-label="Steuerung"
>
{/* Extend +5 Min */}
<button
onClick={() => onExtend(5)}
disabled={disabled || isPaused}
className={`
flex items-center gap-2 px-4 py-3 rounded-xl
font-medium transition-all duration-200
min-w-[52px] justify-center
${disabled || isPaused
? 'bg-slate-100 text-slate-400 cursor-not-allowed'
: 'bg-blue-50 text-blue-700 hover:bg-blue-100 active:scale-95'
}
`}
title="+5 Minuten (E)"
aria-keyshortcuts="e"
aria-label="5 Minuten verlaengern"
>
<Plus className="w-5 h-5" />
<span>5 Min</span>
</button>
{/* Pause / Resume */}
<button
onClick={isPaused ? onResume : onPause}
disabled={disabled}
className={`
flex items-center gap-2 px-6 py-3 rounded-xl
font-semibold transition-all duration-200
min-w-[52px] min-h-[52px] justify-center
${disabled
? 'bg-slate-100 text-slate-400 cursor-not-allowed'
: isPaused
? 'bg-green-600 text-white hover:bg-green-700 shadow-lg shadow-green-500/25 active:scale-95'
: 'bg-amber-500 text-white hover:bg-amber-600 shadow-lg shadow-amber-500/25 active:scale-95'
}
`}
title={isPaused ? 'Fortsetzen (Leertaste)' : 'Pausieren (Leertaste)'}
aria-keyshortcuts="Space"
aria-label={isPaused ? 'Stunde fortsetzen' : 'Stunde pausieren'}
>
{isPaused ? (
<>
<Play className="w-5 h-5" />
<span>Fortsetzen</span>
</>
) : (
<>
<Pause className="w-5 h-5" />
<span>Pause</span>
</>
)}
</button>
{/* Skip Phase / End Lesson */}
{isLastPhase ? (
<button
onClick={onEnd}
disabled={disabled}
className={`
flex items-center gap-2 px-4 py-3 rounded-xl
font-medium transition-all duration-200
min-w-[52px] justify-center
${disabled
? 'bg-slate-100 text-slate-400 cursor-not-allowed'
: 'bg-red-50 text-red-700 hover:bg-red-100 active:scale-95'
}
`}
title="Stunde beenden"
aria-label="Stunde beenden"
>
<Square className="w-5 h-5" />
<span>Beenden</span>
</button>
) : (
<button
onClick={onSkip}
disabled={disabled || isPaused}
className={`
flex items-center gap-2 px-4 py-3 rounded-xl
font-medium transition-all duration-200
min-w-[52px] justify-center
${disabled || isPaused
? 'bg-slate-100 text-slate-400 cursor-not-allowed'
: 'bg-slate-100 text-slate-700 hover:bg-slate-200 active:scale-95'
}
`}
title="Naechste Phase (N)"
aria-keyshortcuts="n"
aria-label="Zur naechsten Phase springen"
>
<SkipForward className="w-5 h-5" />
<span>Weiter</span>
</button>
)}
</div>
)
}
/**
* Compact version for mobile or sidebar
*/
export function QuickActionsCompact({
onExtend,
onPause,
onResume,
onSkip,
isPaused,
isLastPhase,
disabled,
}: Omit<QuickActionsBarProps, 'onEnd'>) {
return (
<div className="flex items-center gap-2">
<button
onClick={() => onExtend(5)}
disabled={disabled || isPaused}
className={`
p-2 rounded-lg transition-all
${disabled || isPaused
? 'text-slate-300'
: 'text-blue-600 hover:bg-blue-50'
}
`}
title="+5 Min"
>
<Clock className="w-5 h-5" />
</button>
<button
onClick={isPaused ? onResume : onPause}
disabled={disabled}
className={`
p-2 rounded-lg transition-all
${disabled
? 'text-slate-300'
: isPaused
? 'text-green-600 hover:bg-green-50'
: 'text-amber-600 hover:bg-amber-50'
}
`}
title={isPaused ? 'Fortsetzen' : 'Pausieren'}
>
{isPaused ? <Play className="w-5 h-5" /> : <Pause className="w-5 h-5" />}
</button>
{!isLastPhase && (
<button
onClick={onSkip}
disabled={disabled || isPaused}
className={`
p-2 rounded-lg transition-all
${disabled || isPaused
? 'text-slate-300'
: 'text-slate-600 hover:bg-slate-50'
}
`}
title="Naechste Phase"
>
<SkipForward className="w-5 h-5" />
</button>
)}
</div>
)
}

View File

@@ -0,0 +1,146 @@
'use client'
import { useState, useEffect } from 'react'
import { Star, Save, CheckCircle } from 'lucide-react'
import { LessonReflection } from '@/lib/companion/types'
interface ReflectionSectionProps {
reflection?: LessonReflection
onSave: (rating: number, notes: string, nextSteps: string) => void
}
export function ReflectionSection({ reflection, onSave }: ReflectionSectionProps) {
const [rating, setRating] = useState(reflection?.rating || 0)
const [notes, setNotes] = useState(reflection?.notes || '')
const [nextSteps, setNextSteps] = useState(reflection?.nextSteps || '')
const [hoverRating, setHoverRating] = useState(0)
const [saved, setSaved] = useState(false)
useEffect(() => {
if (reflection) {
setRating(reflection.rating)
setNotes(reflection.notes)
setNextSteps(reflection.nextSteps)
}
}, [reflection])
const handleSave = () => {
if (rating === 0) return
onSave(rating, notes, nextSteps)
setSaved(true)
setTimeout(() => setSaved(false), 2000)
}
const ratingLabels = [
'', // 0
'Verbesserungsbedarf',
'Okay',
'Gut',
'Sehr gut',
'Ausgezeichnet',
]
return (
<div className="bg-white border border-slate-200 rounded-xl p-6 space-y-6">
{/* Star Rating */}
<div>
<label className="block text-sm font-medium text-slate-700 mb-3">
Wie lief die Stunde?
</label>
<div className="flex items-center gap-2">
{[1, 2, 3, 4, 5].map((star) => {
const isFilled = star <= (hoverRating || rating)
return (
<button
key={star}
type="button"
onClick={() => setRating(star)}
onMouseEnter={() => setHoverRating(star)}
onMouseLeave={() => setHoverRating(0)}
className="p-1 transition-transform hover:scale-110"
aria-label={`${star} Stern${star > 1 ? 'e' : ''}`}
>
<Star
className={`w-8 h-8 ${
isFilled
? 'fill-amber-400 text-amber-400'
: 'text-slate-300'
}`}
/>
</button>
)
})}
{(hoverRating || rating) > 0 && (
<span className="ml-3 text-sm text-slate-600">
{ratingLabels[hoverRating || rating]}
</span>
)}
</div>
</div>
{/* Notes */}
<div>
<label className="block text-sm font-medium text-slate-700 mb-2">
Notizen zur Stunde
</label>
<textarea
value={notes}
onChange={(e) => setNotes(e.target.value)}
placeholder="Was lief gut? Was koennte besser laufen? Besondere Vorkommnisse..."
rows={4}
className="w-full px-4 py-3 border border-slate-200 rounded-xl focus:ring-2 focus:ring-blue-500 focus:border-blue-500 resize-none"
/>
</div>
{/* Next Steps */}
<div>
<label className="block text-sm font-medium text-slate-700 mb-2">
Naechste Schritte
</label>
<textarea
value={nextSteps}
onChange={(e) => setNextSteps(e.target.value)}
placeholder="Was muss fuer die naechste Stunde vorbereitet werden? Follow-ups..."
rows={3}
className="w-full px-4 py-3 border border-slate-200 rounded-xl focus:ring-2 focus:ring-blue-500 focus:border-blue-500 resize-none"
/>
</div>
{/* Save Button */}
<button
onClick={handleSave}
disabled={rating === 0}
className={`
w-full py-3 px-6 rounded-xl font-semibold
flex items-center justify-center gap-2
transition-all duration-200
${saved
? 'bg-green-600 text-white'
: rating === 0
? 'bg-slate-100 text-slate-400 cursor-not-allowed'
: 'bg-blue-600 text-white hover:bg-blue-700'
}
`}
>
{saved ? (
<>
<CheckCircle className="w-5 h-5" />
Gespeichert!
</>
) : (
<>
<Save className="w-5 h-5" />
Reflexion speichern
</>
)}
</button>
{/* Previous Reflection Info */}
{reflection?.savedAt && (
<p className="text-center text-sm text-slate-400">
Zuletzt gespeichert: {new Date(reflection.savedAt).toLocaleString('de-DE')}
</p>
)}
</div>
)
}

View File

@@ -0,0 +1,220 @@
'use client'
import { Pause, Play } from 'lucide-react'
import { TimerColorStatus } from '@/lib/companion/types'
import {
PIE_TIMER_RADIUS,
PIE_TIMER_CIRCUMFERENCE,
PIE_TIMER_STROKE_WIDTH,
PIE_TIMER_SIZE,
TIMER_COLOR_CLASSES,
TIMER_BG_COLORS,
formatTime,
} from '@/lib/companion/constants'
interface VisualPieTimerProps {
progress: number // 0-1 (how much time has elapsed)
remainingSeconds: number
totalSeconds: number
colorStatus: TimerColorStatus
isPaused: boolean
currentPhaseName: string
phaseColor: string
onTogglePause?: () => void
size?: 'sm' | 'md' | 'lg'
}
const sizeConfig = {
sm: { outer: 120, viewBox: 100, radius: 38, stroke: 6, fontSize: 'text-lg' },
md: { outer: 180, viewBox: 100, radius: 40, stroke: 7, fontSize: 'text-2xl' },
lg: { outer: 240, viewBox: 100, radius: 42, stroke: 8, fontSize: 'text-4xl' },
}
export function VisualPieTimer({
progress,
remainingSeconds,
totalSeconds,
colorStatus,
isPaused,
currentPhaseName,
phaseColor,
onTogglePause,
size = 'lg',
}: VisualPieTimerProps) {
const config = sizeConfig[size]
const circumference = 2 * Math.PI * config.radius
// Calculate stroke-dashoffset for progress
// Progress goes from 0 (full) to 1 (empty), so offset decreases as time passes
const strokeDashoffset = circumference * (1 - progress)
// For overtime, show a pulsing full circle
const isOvertime = colorStatus === 'overtime'
const displayTime = formatTime(remainingSeconds)
// Get color classes based on status
const colorClasses = TIMER_COLOR_CLASSES[colorStatus]
const bgColorClass = TIMER_BG_COLORS[colorStatus]
return (
<div className="flex flex-col items-center">
{/* Timer Circle */}
<div
className={`relative ${bgColorClass} rounded-full p-4 transition-colors duration-300`}
style={{ width: config.outer, height: config.outer }}
>
<svg
width="100%"
height="100%"
viewBox={`0 0 ${config.viewBox} ${config.viewBox}`}
className="transform -rotate-90"
>
{/* Background circle */}
<circle
cx={config.viewBox / 2}
cy={config.viewBox / 2}
r={config.radius}
fill="none"
stroke="currentColor"
strokeWidth={config.stroke}
className="text-slate-200"
/>
{/* Progress circle */}
<circle
cx={config.viewBox / 2}
cy={config.viewBox / 2}
r={config.radius}
fill="none"
stroke={isOvertime ? '#dc2626' : phaseColor}
strokeWidth={config.stroke}
strokeLinecap="round"
strokeDasharray={circumference}
strokeDashoffset={isOvertime ? 0 : strokeDashoffset}
className={`transition-all duration-100 ${isOvertime ? 'animate-pulse' : ''}`}
/>
</svg>
{/* Center Content */}
<div className="absolute inset-0 flex flex-col items-center justify-center">
{/* Time Display */}
<span
className={`
font-mono font-bold ${config.fontSize}
${isOvertime ? 'text-red-600 animate-pulse' : colorStatus === 'critical' ? 'text-red-500' : colorStatus === 'warning' ? 'text-amber-500' : 'text-slate-900'}
`}
>
{displayTime}
</span>
{/* Phase Name */}
<span className="text-sm text-slate-500 mt-1">
{currentPhaseName}
</span>
{/* Paused Indicator */}
{isPaused && (
<span className="text-xs text-amber-600 font-medium mt-1 flex items-center gap-1">
<Pause className="w-3 h-3" />
Pausiert
</span>
)}
{/* Overtime Badge */}
{isOvertime && (
<span className="absolute -bottom-2 px-2 py-0.5 bg-red-600 text-white text-xs font-bold rounded-full">
+{Math.abs(Math.floor(remainingSeconds / 60))} Min
</span>
)}
</div>
{/* Pause/Play Button (overlay) */}
{onTogglePause && (
<button
onClick={onTogglePause}
className={`
absolute inset-0 rounded-full
flex items-center justify-center
opacity-0 hover:opacity-100
bg-black/20 backdrop-blur-sm
transition-opacity duration-200
`}
aria-label={isPaused ? 'Fortsetzen' : 'Pausieren'}
>
{isPaused ? (
<Play className="w-12 h-12 text-white" />
) : (
<Pause className="w-12 h-12 text-white" />
)}
</button>
)}
</div>
{/* Status Text */}
<div className="mt-4 text-center">
{isOvertime ? (
<p className="text-red-600 font-semibold animate-pulse">
Ueberzogen - Zeit fuer die naechste Phase!
</p>
) : colorStatus === 'critical' ? (
<p className="text-red-500 font-medium">
Weniger als 2 Minuten verbleibend
</p>
) : colorStatus === 'warning' ? (
<p className="text-amber-500">
Weniger als 5 Minuten verbleibend
</p>
) : null}
</div>
</div>
)
}
/**
* Compact timer for header/toolbar
*/
export function CompactTimer({
remainingSeconds,
colorStatus,
isPaused,
phaseName,
phaseColor,
}: {
remainingSeconds: number
colorStatus: TimerColorStatus
isPaused: boolean
phaseName: string
phaseColor: string
}) {
const isOvertime = colorStatus === 'overtime'
return (
<div className="flex items-center gap-3 px-4 py-2 bg-white border border-slate-200 rounded-xl">
{/* Phase indicator */}
<div
className="w-3 h-3 rounded-full"
style={{ backgroundColor: phaseColor }}
/>
{/* Phase name */}
<span className="text-sm font-medium text-slate-600">{phaseName}</span>
{/* Time */}
<span
className={`
font-mono font-bold
${isOvertime ? 'text-red-600 animate-pulse' : colorStatus === 'critical' ? 'text-red-500' : colorStatus === 'warning' ? 'text-amber-500' : 'text-slate-900'}
`}
>
{formatTime(remainingSeconds)}
</span>
{/* Paused badge */}
{isPaused && (
<span className="px-2 py-0.5 bg-amber-100 text-amber-700 text-xs font-medium rounded">
Pausiert
</span>
)}
</div>
)
}

View File

@@ -0,0 +1,201 @@
'use client'
import { useState } from 'react'
import { X, MessageSquare, Bug, Lightbulb, Send, CheckCircle } from 'lucide-react'
import { FeedbackType } from '@/lib/companion/types'
interface FeedbackModalProps {
isOpen: boolean
onClose: () => void
onSubmit: (type: FeedbackType, title: string, description: string) => Promise<void>
}
const feedbackTypes: { id: FeedbackType; label: string; icon: typeof Bug; color: string }[] = [
{ id: 'bug', label: 'Bug melden', icon: Bug, color: 'text-red-600 bg-red-50' },
{ id: 'feature', label: 'Feature-Wunsch', icon: Lightbulb, color: 'text-amber-600 bg-amber-50' },
{ id: 'feedback', label: 'Allgemeines Feedback', icon: MessageSquare, color: 'text-blue-600 bg-blue-50' },
]
export function FeedbackModal({ isOpen, onClose, onSubmit }: FeedbackModalProps) {
const [type, setType] = useState<FeedbackType>('feedback')
const [title, setTitle] = useState('')
const [description, setDescription] = useState('')
const [isSubmitting, setIsSubmitting] = useState(false)
const [isSuccess, setIsSuccess] = useState(false)
if (!isOpen) return null
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
if (!title.trim() || !description.trim()) return
setIsSubmitting(true)
try {
await onSubmit(type, title.trim(), description.trim())
setIsSuccess(true)
setTimeout(() => {
setIsSuccess(false)
setTitle('')
setDescription('')
setType('feedback')
onClose()
}, 2000)
} catch (error) {
console.error('Failed to submit feedback:', error)
} finally {
setIsSubmitting(false)
}
}
return (
<div className="fixed inset-0 z-50 flex items-center justify-center">
{/* Backdrop */}
<div
className="absolute inset-0 bg-black/50 backdrop-blur-sm"
onClick={onClose}
/>
{/* Modal */}
<div className="relative bg-white rounded-2xl shadow-xl w-full max-w-lg mx-4">
{/* Header */}
<div className="flex items-center justify-between p-6 border-b border-slate-200">
<div className="flex items-center gap-3">
<div className="p-2 bg-blue-100 rounded-xl">
<MessageSquare className="w-5 h-5 text-blue-600" />
</div>
<h2 className="text-xl font-semibold text-slate-900">Feedback senden</h2>
</div>
<button
onClick={onClose}
className="p-2 hover:bg-slate-100 rounded-lg transition-colors"
>
<X className="w-5 h-5 text-slate-500" />
</button>
</div>
{/* Success State */}
{isSuccess ? (
<div className="p-12 text-center">
<div className="w-16 h-16 bg-green-100 rounded-full flex items-center justify-center mx-auto mb-4">
<CheckCircle className="w-8 h-8 text-green-600" />
</div>
<h3 className="text-xl font-semibold text-slate-900 mb-2">Vielen Dank!</h3>
<p className="text-slate-600">Ihr Feedback wurde erfolgreich gesendet.</p>
</div>
) : (
<form onSubmit={handleSubmit}>
{/* Content */}
<div className="p-6 space-y-6">
{/* Feedback Type */}
<div>
<label className="block text-sm font-medium text-slate-700 mb-3">
Art des Feedbacks
</label>
<div className="grid grid-cols-3 gap-3">
{feedbackTypes.map((ft) => (
<button
key={ft.id}
type="button"
onClick={() => setType(ft.id)}
className={`
p-4 rounded-xl border-2 text-center transition-all
${type === ft.id
? 'border-blue-500 bg-blue-50'
: 'border-slate-200 hover:border-slate-300'
}
`}
>
<div className={`w-10 h-10 rounded-lg ${ft.color} flex items-center justify-center mx-auto mb-2`}>
<ft.icon className="w-5 h-5" />
</div>
<span className={`text-sm font-medium ${type === ft.id ? 'text-blue-700' : 'text-slate-700'}`}>
{ft.label}
</span>
</button>
))}
</div>
</div>
{/* Title */}
<div>
<label className="block text-sm font-medium text-slate-700 mb-2">
Titel *
</label>
<input
type="text"
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder={
type === 'bug'
? 'z.B. Timer stoppt nach Pause nicht mehr'
: type === 'feature'
? 'z.B. Materialien an Stunde anhaengen'
: 'z.B. Super nuetzliches Tool!'
}
className="w-full px-4 py-3 border border-slate-200 rounded-xl focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
required
/>
</div>
{/* Description */}
<div>
<label className="block text-sm font-medium text-slate-700 mb-2">
Beschreibung *
</label>
<textarea
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder={
type === 'bug'
? 'Bitte beschreiben Sie den Fehler moeglichst genau. Was haben Sie gemacht? Was ist passiert? Was haetten Sie erwartet?'
: type === 'feature'
? 'Beschreiben Sie die gewuenschte Funktion. Warum waere sie hilfreich?'
: 'Teilen Sie uns Ihre Gedanken mit...'
}
rows={5}
className="w-full px-4 py-3 border border-slate-200 rounded-xl focus:ring-2 focus:ring-blue-500 focus:border-blue-500 resize-none"
required
/>
</div>
</div>
{/* Footer */}
<div className="flex items-center justify-end gap-3 p-6 border-t border-slate-200 bg-slate-50">
<button
type="button"
onClick={onClose}
className="px-4 py-2 text-slate-600 hover:bg-slate-200 rounded-lg transition-colors"
>
Abbrechen
</button>
<button
type="submit"
disabled={!title.trim() || !description.trim() || isSubmitting}
className={`
flex items-center gap-2 px-6 py-2 rounded-lg font-medium
transition-all duration-200
${!title.trim() || !description.trim() || isSubmitting
? 'bg-slate-200 text-slate-500 cursor-not-allowed'
: 'bg-blue-600 text-white hover:bg-blue-700'
}
`}
>
{isSubmitting ? (
<>
<div className="w-4 h-4 border-2 border-white/30 border-t-white rounded-full animate-spin" />
Senden...
</>
) : (
<>
<Send className="w-4 h-4" />
Absenden
</>
)}
</button>
</div>
</form>
)}
</div>
</div>
)
}

View File

@@ -0,0 +1,280 @@
'use client'
import { useState } from 'react'
import { ChevronRight, ChevronLeft, Check, GraduationCap, Settings, Timer } from 'lucide-react'
interface OnboardingModalProps {
isOpen: boolean
onClose: () => void
onComplete: (data: { state?: string; schoolType?: string }) => void
}
const STATES = [
'Baden-Wuerttemberg',
'Bayern',
'Berlin',
'Brandenburg',
'Bremen',
'Hamburg',
'Hessen',
'Mecklenburg-Vorpommern',
'Niedersachsen',
'Nordrhein-Westfalen',
'Rheinland-Pfalz',
'Saarland',
'Sachsen',
'Sachsen-Anhalt',
'Schleswig-Holstein',
'Thueringen',
]
const SCHOOL_TYPES = [
'Grundschule',
'Hauptschule',
'Realschule',
'Gymnasium',
'Gesamtschule',
'Berufsschule',
'Foerderschule',
'Andere',
]
interface Step {
id: number
title: string
description: string
icon: typeof GraduationCap
}
const steps: Step[] = [
{
id: 1,
title: 'Willkommen',
description: 'Der Companion hilft Ihnen bei der Unterrichtsplanung und -durchfuehrung.',
icon: GraduationCap,
},
{
id: 2,
title: 'Ihre Schule',
description: 'Waehlen Sie Ihr Bundesland und Ihre Schulform.',
icon: Settings,
},
{
id: 3,
title: 'Bereit!',
description: 'Sie koennen jetzt mit dem Lesson-Modus starten.',
icon: Timer,
},
]
export function OnboardingModal({ isOpen, onClose, onComplete }: OnboardingModalProps) {
const [currentStep, setCurrentStep] = useState(1)
const [selectedState, setSelectedState] = useState('')
const [selectedSchoolType, setSelectedSchoolType] = useState('')
if (!isOpen) return null
const canProceed = () => {
if (currentStep === 2) {
return selectedState !== '' && selectedSchoolType !== ''
}
return true
}
const handleNext = () => {
if (currentStep < 3) {
setCurrentStep(currentStep + 1)
} else {
onComplete({
state: selectedState,
schoolType: selectedSchoolType,
})
}
}
const handleBack = () => {
if (currentStep > 1) {
setCurrentStep(currentStep - 1)
}
}
const currentStepData = steps[currentStep - 1]
const Icon = currentStepData.icon
return (
<div className="fixed inset-0 z-50 flex items-center justify-center">
{/* Backdrop */}
<div className="absolute inset-0 bg-black/50 backdrop-blur-sm" />
{/* Modal */}
<div className="relative bg-white rounded-2xl shadow-xl w-full max-w-lg mx-4 overflow-hidden">
{/* Progress Bar */}
<div className="h-1 bg-slate-100">
<div
className="h-full bg-blue-600 transition-all duration-300"
style={{ width: `${(currentStep / 3) * 100}%` }}
/>
</div>
{/* Content */}
<div className="p-8">
{/* Step Indicator */}
<div className="flex items-center justify-center gap-2 mb-8">
{steps.map((step) => (
<div
key={step.id}
className={`
w-3 h-3 rounded-full transition-all
${step.id === currentStep
? 'bg-blue-600 scale-125'
: step.id < currentStep
? 'bg-blue-600'
: 'bg-slate-200'
}
`}
/>
))}
</div>
{/* Icon */}
<div className="w-16 h-16 bg-blue-100 rounded-2xl flex items-center justify-center mx-auto mb-6">
<Icon className="w-8 h-8 text-blue-600" />
</div>
{/* Title & Description */}
<h2 className="text-2xl font-bold text-slate-900 text-center mb-2">
{currentStepData.title}
</h2>
<p className="text-slate-600 text-center mb-8">
{currentStepData.description}
</p>
{/* Step Content */}
{currentStep === 1 && (
<div className="space-y-4 text-center">
<div className="grid grid-cols-3 gap-4">
<div className="p-4 bg-blue-50 rounded-xl">
<div className="text-2xl mb-1">5</div>
<div className="text-xs text-slate-600">Phasen</div>
</div>
<div className="p-4 bg-green-50 rounded-xl">
<div className="text-2xl mb-1">45</div>
<div className="text-xs text-slate-600">Minuten</div>
</div>
<div className="p-4 bg-purple-50 rounded-xl">
<div className="text-2xl mb-1"></div>
<div className="text-xs text-slate-600">Flexibel</div>
</div>
</div>
<p className="text-sm text-slate-500 mt-4">
Einstieg Erarbeitung Sicherung Transfer Reflexion
</p>
</div>
)}
{currentStep === 2 && (
<div className="space-y-4">
{/* State Selection */}
<div>
<label className="block text-sm font-medium text-slate-700 mb-2">
Bundesland
</label>
<select
value={selectedState}
onChange={(e) => setSelectedState(e.target.value)}
className="w-full px-4 py-3 border border-slate-200 rounded-xl focus:ring-2 focus:ring-blue-500 focus:border-blue-500 bg-white"
>
<option value="">Bitte waehlen...</option>
{STATES.map((state) => (
<option key={state} value={state}>
{state}
</option>
))}
</select>
</div>
{/* School Type Selection */}
<div>
<label className="block text-sm font-medium text-slate-700 mb-2">
Schulform
</label>
<select
value={selectedSchoolType}
onChange={(e) => setSelectedSchoolType(e.target.value)}
className="w-full px-4 py-3 border border-slate-200 rounded-xl focus:ring-2 focus:ring-blue-500 focus:border-blue-500 bg-white"
>
<option value="">Bitte waehlen...</option>
{SCHOOL_TYPES.map((type) => (
<option key={type} value={type}>
{type}
</option>
))}
</select>
</div>
</div>
)}
{currentStep === 3 && (
<div className="text-center space-y-4">
<div className="w-20 h-20 bg-green-100 rounded-full flex items-center justify-center mx-auto">
<Check className="w-10 h-10 text-green-600" />
</div>
<div className="p-4 bg-slate-50 rounded-xl">
<p className="text-sm text-slate-600">
<strong>Bundesland:</strong> {selectedState || 'Nicht angegeben'}
</p>
<p className="text-sm text-slate-600">
<strong>Schulform:</strong> {selectedSchoolType || 'Nicht angegeben'}
</p>
</div>
<p className="text-sm text-slate-500">
Sie koennen diese Einstellungen jederzeit aendern.
</p>
</div>
)}
</div>
{/* Footer */}
<div className="flex items-center justify-between p-6 border-t border-slate-200 bg-slate-50">
<button
onClick={currentStep === 1 ? onClose : handleBack}
className="flex items-center gap-2 px-4 py-2 text-slate-600 hover:bg-slate-200 rounded-lg transition-colors"
>
{currentStep === 1 ? (
'Ueberspringen'
) : (
<>
<ChevronLeft className="w-4 h-4" />
Zurueck
</>
)}
</button>
<button
onClick={handleNext}
disabled={!canProceed()}
className={`
flex items-center gap-2 px-6 py-2 rounded-lg font-medium
transition-all duration-200
${canProceed()
? 'bg-blue-600 text-white hover:bg-blue-700'
: 'bg-slate-200 text-slate-500 cursor-not-allowed'
}
`}
>
{currentStep === 3 ? (
<>
<Check className="w-4 h-4" />
Fertig
</>
) : (
<>
Weiter
<ChevronRight className="w-4 h-4" />
</>
)}
</button>
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,248 @@
'use client'
import { useState, useEffect } from 'react'
import { X, Settings, Save, RotateCcw } from 'lucide-react'
import { TeacherSettings, PhaseDurations } from '@/lib/companion/types'
import {
DEFAULT_TEACHER_SETTINGS,
DEFAULT_PHASE_DURATIONS,
PHASE_ORDER,
PHASE_DISPLAY_NAMES,
PHASE_COLORS,
calculateTotalDuration,
} from '@/lib/companion/constants'
interface SettingsModalProps {
isOpen: boolean
onClose: () => void
settings: TeacherSettings
onSave: (settings: TeacherSettings) => void
}
export function SettingsModal({
isOpen,
onClose,
settings,
onSave,
}: SettingsModalProps) {
const [localSettings, setLocalSettings] = useState<TeacherSettings>(settings)
const [durations, setDurations] = useState<PhaseDurations>(settings.defaultPhaseDurations)
useEffect(() => {
setLocalSettings(settings)
setDurations(settings.defaultPhaseDurations)
}, [settings])
if (!isOpen) return null
const totalDuration = calculateTotalDuration(durations)
const handleDurationChange = (phase: keyof PhaseDurations, value: number) => {
const newDurations = { ...durations, [phase]: Math.max(1, Math.min(60, value)) }
setDurations(newDurations)
}
const handleReset = () => {
setDurations(DEFAULT_PHASE_DURATIONS)
setLocalSettings(DEFAULT_TEACHER_SETTINGS)
}
const handleSave = () => {
const newSettings: TeacherSettings = {
...localSettings,
defaultPhaseDurations: durations,
}
onSave(newSettings)
onClose()
}
return (
<div className="fixed inset-0 z-50 flex items-center justify-center">
{/* Backdrop */}
<div
className="absolute inset-0 bg-black/50 backdrop-blur-sm"
onClick={onClose}
/>
{/* Modal */}
<div className="relative bg-white rounded-2xl shadow-xl w-full max-w-lg mx-4 max-h-[90vh] overflow-hidden">
{/* Header */}
<div className="flex items-center justify-between p-6 border-b border-slate-200">
<div className="flex items-center gap-3">
<div className="p-2 bg-slate-100 rounded-xl">
<Settings className="w-5 h-5 text-slate-600" />
</div>
<h2 className="text-xl font-semibold text-slate-900">Einstellungen</h2>
</div>
<button
onClick={onClose}
className="p-2 hover:bg-slate-100 rounded-lg transition-colors"
>
<X className="w-5 h-5 text-slate-500" />
</button>
</div>
{/* Content */}
<div className="p-6 space-y-6 overflow-y-auto max-h-[60vh]">
{/* Phase Durations */}
<div>
<h3 className="text-sm font-medium text-slate-700 mb-4">
Standard-Phasendauern (Minuten)
</h3>
<div className="space-y-4">
{PHASE_ORDER.map((phase) => (
<div key={phase} className="flex items-center gap-4">
<div className="flex items-center gap-2 w-32">
<div
className="w-3 h-3 rounded-full"
style={{ backgroundColor: PHASE_COLORS[phase].hex }}
/>
<span className="text-sm text-slate-700">
{PHASE_DISPLAY_NAMES[phase]}
</span>
</div>
<input
type="number"
min={1}
max={60}
value={durations[phase]}
onChange={(e) => handleDurationChange(phase, parseInt(e.target.value) || 1)}
className="w-20 px-3 py-2 border border-slate-200 rounded-lg text-center focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
/>
<input
type="range"
min={1}
max={45}
value={durations[phase]}
onChange={(e) => handleDurationChange(phase, parseInt(e.target.value))}
className="flex-1"
style={{
accentColor: PHASE_COLORS[phase].hex,
}}
/>
</div>
))}
</div>
<div className="mt-4 p-3 bg-slate-50 rounded-xl flex items-center justify-between">
<span className="text-sm text-slate-600">Gesamtdauer:</span>
<span className="font-semibold text-slate-900">{totalDuration} Minuten</span>
</div>
</div>
{/* Other Settings */}
<div className="space-y-4 pt-4 border-t border-slate-200">
<h3 className="text-sm font-medium text-slate-700 mb-4">
Weitere Einstellungen
</h3>
{/* Auto Advance */}
<label className="flex items-center justify-between p-3 bg-slate-50 rounded-xl cursor-pointer">
<div>
<span className="text-sm font-medium text-slate-700">
Automatischer Phasenwechsel
</span>
<p className="text-xs text-slate-500">
Phasen automatisch wechseln wenn Zeit abgelaufen
</p>
</div>
<input
type="checkbox"
checked={localSettings.autoAdvancePhases}
onChange={(e) =>
setLocalSettings({ ...localSettings, autoAdvancePhases: e.target.checked })
}
className="w-5 h-5 text-blue-600 rounded focus:ring-blue-500"
/>
</label>
{/* Sound Notifications */}
<label className="flex items-center justify-between p-3 bg-slate-50 rounded-xl cursor-pointer">
<div>
<span className="text-sm font-medium text-slate-700">
Ton-Benachrichtigungen
</span>
<p className="text-xs text-slate-500">
Signalton bei Phasenende und Warnungen
</p>
</div>
<input
type="checkbox"
checked={localSettings.soundNotifications}
onChange={(e) =>
setLocalSettings({ ...localSettings, soundNotifications: e.target.checked })
}
className="w-5 h-5 text-blue-600 rounded focus:ring-blue-500"
/>
</label>
{/* Keyboard Shortcuts */}
<label className="flex items-center justify-between p-3 bg-slate-50 rounded-xl cursor-pointer">
<div>
<span className="text-sm font-medium text-slate-700">
Tastaturkuerzel anzeigen
</span>
<p className="text-xs text-slate-500">
Hinweise zu Tastaturkuerzeln einblenden
</p>
</div>
<input
type="checkbox"
checked={localSettings.showKeyboardShortcuts}
onChange={(e) =>
setLocalSettings({ ...localSettings, showKeyboardShortcuts: e.target.checked })
}
className="w-5 h-5 text-blue-600 rounded focus:ring-blue-500"
/>
</label>
{/* High Contrast */}
<label className="flex items-center justify-between p-3 bg-slate-50 rounded-xl cursor-pointer">
<div>
<span className="text-sm font-medium text-slate-700">
Hoher Kontrast
</span>
<p className="text-xs text-slate-500">
Bessere Sichtbarkeit durch erhoehten Kontrast
</p>
</div>
<input
type="checkbox"
checked={localSettings.highContrastMode}
onChange={(e) =>
setLocalSettings({ ...localSettings, highContrastMode: e.target.checked })
}
className="w-5 h-5 text-blue-600 rounded focus:ring-blue-500"
/>
</label>
</div>
</div>
{/* Footer */}
<div className="flex items-center justify-between p-6 border-t border-slate-200 bg-slate-50">
<button
onClick={handleReset}
className="flex items-center gap-2 px-4 py-2 text-slate-600 hover:bg-slate-200 rounded-lg transition-colors"
>
<RotateCcw className="w-4 h-4" />
Zuruecksetzen
</button>
<div className="flex items-center gap-3">
<button
onClick={onClose}
className="px-4 py-2 text-slate-600 hover:bg-slate-200 rounded-lg transition-colors"
>
Abbrechen
</button>
<button
onClick={handleSave}
className="flex items-center gap-2 px-6 py-2 bg-blue-600 text-white rounded-lg font-medium hover:bg-blue-700 transition-colors"
>
<Save className="w-4 h-4" />
Speichern
</button>
</div>
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,231 @@
'use client'
import { useEffect, useState, useCallback } from 'react'
import Link from 'next/link'
interface NightModeConfig {
enabled: boolean
shutdown_time: string
startup_time: string
last_action: string | null
last_action_time: string | null
}
interface NightModeStatus {
config: NightModeConfig
current_time: string
next_action: string | null
next_action_time: string | null
time_until_next_action: string | null
services_status: Record<string, string>
}
export function NightModeWidget() {
const [status, setStatus] = useState<NightModeStatus | null>(null)
const [loading, setLoading] = useState(true)
const [actionLoading, setActionLoading] = useState<string | null>(null)
const [error, setError] = useState<string | null>(null)
const fetchData = useCallback(async () => {
try {
const response = await fetch('/api/admin/night-mode')
if (response.ok) {
setStatus(await response.json())
setError(null)
} else {
setError('Nicht erreichbar')
}
} catch {
setError('Verbindung fehlgeschlagen')
} finally {
setLoading(false)
}
}, [])
useEffect(() => {
fetchData()
const interval = setInterval(fetchData, 30000)
return () => clearInterval(interval)
}, [fetchData])
const toggleEnabled = async () => {
if (!status) return
setActionLoading('toggle')
try {
const response = await fetch('/api/admin/night-mode', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ ...status.config, enabled: !status.config.enabled }),
})
if (response.ok) {
fetchData()
}
} catch {
// ignore
} finally {
setActionLoading(null)
}
}
const executeAction = async (action: 'start' | 'stop') => {
setActionLoading(action)
try {
const response = await fetch('/api/admin/night-mode/execute', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ action }),
})
if (response.ok) {
fetchData()
}
} catch {
// ignore
} finally {
setActionLoading(null)
}
}
const runningCount = Object.values(status?.services_status || {}).filter(
s => s.toLowerCase() === 'running' || s.toLowerCase().includes('up')
).length
const totalCount = Object.keys(status?.services_status || {}).length
if (loading) {
return (
<div className="bg-white rounded-xl border border-slate-200 shadow-sm p-4">
<div className="animate-pulse flex items-center gap-3">
<div className="w-10 h-10 bg-slate-200 rounded-full" />
<div className="flex-1">
<div className="h-4 bg-slate-200 rounded w-24 mb-2" />
<div className="h-3 bg-slate-200 rounded w-32" />
</div>
</div>
</div>
)
}
if (error) {
return (
<div className="bg-white rounded-xl border border-slate-200 shadow-sm p-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="w-10 h-10 bg-slate-100 rounded-full flex items-center justify-center">
<svg className="w-5 h-5 text-slate-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M20.354 15.354A9 9 0 018.646 3.646 9.003 9.003 0 0012 21a9.003 9.003 0 008.354-5.646z" />
</svg>
</div>
<div>
<h3 className="font-medium text-slate-900">Nachtabschaltung</h3>
<p className="text-sm text-red-500">{error}</p>
</div>
</div>
<Link href="/infrastructure/night-mode" className="text-sm text-primary-600 hover:text-primary-700">
Details
</Link>
</div>
</div>
)
}
return (
<div className="bg-white rounded-xl border border-slate-200 shadow-sm overflow-hidden">
{/* Header */}
<div className="px-4 py-3 border-b border-slate-200 flex items-center justify-between">
<div className="flex items-center gap-3">
<div className={`w-10 h-10 rounded-full flex items-center justify-center ${
status?.config.enabled ? 'bg-orange-100' : 'bg-slate-100'
}`}>
<svg className={`w-5 h-5 ${status?.config.enabled ? 'text-orange-600' : 'text-slate-400'}`} fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M20.354 15.354A9 9 0 018.646 3.646 9.003 9.003 0 0012 21a9.003 9.003 0 008.354-5.646z" />
</svg>
</div>
<div>
<h3 className="font-semibold text-slate-900">Nachtabschaltung</h3>
<p className="text-sm text-slate-500">
{status?.config.enabled
? `${status.config.shutdown_time} - ${status.config.startup_time}`
: 'Deaktiviert'}
</p>
</div>
</div>
<Link href="/infrastructure/night-mode" className="text-sm text-primary-600 hover:text-primary-700">
Einstellungen
</Link>
</div>
{/* Content */}
<div className="p-4">
{/* Status Row */}
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-4">
{/* Toggle */}
<button
onClick={toggleEnabled}
disabled={actionLoading !== null}
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors ${
status?.config.enabled ? 'bg-orange-600' : 'bg-slate-300'
} ${actionLoading === 'toggle' ? 'opacity-50' : ''}`}
>
<span className={`inline-block h-4 w-4 transform rounded-full bg-white shadow transition-transform ${
status?.config.enabled ? 'translate-x-6' : 'translate-x-1'
}`} />
</button>
{/* Countdown */}
{status?.config.enabled && status.time_until_next_action && (
<div className="flex items-center gap-2">
<span className={`text-sm font-medium ${
status.next_action === 'shutdown' ? 'text-red-600' : 'text-green-600'
}`}>
{status.next_action === 'shutdown' ? '⏸' : '▶'} {status.time_until_next_action}
</span>
</div>
)}
</div>
{/* Service Count */}
<div className="text-sm text-slate-500">
<span className="font-semibold text-green-600">{runningCount}</span>
<span className="text-slate-400">/{totalCount}</span>
<span className="ml-1">aktiv</span>
</div>
</div>
{/* Action Buttons */}
<div className="flex gap-2">
<button
onClick={() => executeAction('stop')}
disabled={actionLoading !== null}
className="flex-1 flex items-center justify-center gap-2 px-3 py-2 bg-red-50 text-red-700 rounded-lg text-sm font-medium hover:bg-red-100 disabled:opacity-50 transition-colors"
>
{actionLoading === 'stop' ? (
<span className="animate-spin text-xs"></span>
) : (
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 10h6v4H9z" />
</svg>
)}
Stoppen
</button>
<button
onClick={() => executeAction('start')}
disabled={actionLoading !== null}
className="flex-1 flex items-center justify-center gap-2 px-3 py-2 bg-green-50 text-green-700 rounded-lg text-sm font-medium hover:bg-green-100 disabled:opacity-50 transition-colors"
>
{actionLoading === 'start' ? (
<span className="animate-spin text-xs"></span>
) : (
<svg className="w-4 h-4" 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>
)}
Starten
</button>
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,366 @@
'use client'
/**
* Dokumente Tab for edu-search page
* Shows filterable list of Abitur documents with pagination
*/
import { useState, useEffect } from 'react'
import { FileText, Filter, ChevronLeft, ChevronRight, Eye, Download, Search, X, Loader2 } from 'lucide-react'
import {
AbiturDokument,
AbiturDocsResponse,
formatFileSize,
formatDocumentTitle,
FAECHER,
JAHRE,
BUNDESLAENDER,
NIVEAUS,
TYPEN,
} from '@/lib/education/abitur-docs-types'
import { PDFPreviewModal } from './PDFPreviewModal'
interface DokumenteTabProps {
onDocumentCountChange?: (count: number) => void
}
export function DokumenteTab({ onDocumentCountChange }: DokumenteTabProps) {
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
// 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>('')
// Modal
const [selectedDocument, setSelectedDocument] = useState<AbiturDokument | null>(null)
// Fetch documents
useEffect(() => {
const fetchDocuments = 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)
try {
const response = await fetch(`/api/education/abitur-docs?${params.toString()}`)
if (!response.ok) throw new Error('Fehler beim Laden der Dokumente')
const data: AbiturDocsResponse = await response.json()
setDocuments(data.documents || [])
setTotalPages(data.total_pages || 1)
setTotal(data.total || 0)
onDocumentCountChange?.(data.total || 0)
} catch (err) {
setError(err instanceof Error ? err.message : 'Unbekannter Fehler')
} finally {
setLoading(false)
}
}
fetchDocuments()
}, [page, filterFach, filterJahr, filterBundesland, filterNiveau, filterTyp, onDocumentCountChange])
const clearFilters = () => {
setFilterFach('')
setFilterJahr('')
setFilterBundesland('')
setFilterNiveau('')
setFilterTyp('')
setPage(1)
}
const hasActiveFilters = filterFach || filterJahr || filterBundesland || filterNiveau || filterTyp
return (
<div className="space-y-4">
{/* Filter Bar */}
<div className="bg-white rounded-xl border border-slate-200 p-4">
<div className="flex items-center justify-between mb-4">
<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].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 zurücksetzen
</button>
)}
</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 Fächer</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 Bundesländer</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>
{/* Document List */}
<div className="bg-white rounded-xl border border-slate-200 overflow-hidden">
{loading ? (
<div className="flex items-center justify-center py-12">
<Loader2 className="w-8 h-8 text-blue-600 animate-spin" />
</div>
) : error ? (
<div className="text-center py-12 text-red-600">
<p>{error}</p>
<button
onClick={() => setPage(1)}
className="mt-2 text-sm text-blue-600 hover:underline"
>
Erneut versuchen
</button>
</div>
) : documents.length === 0 ? (
<div className="text-center py-12 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 zurücksetzen
</button>
)}
</div>
) : (
<>
<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>
<a
href={doc.file_path}
download={doc.dateiname}
className="p-1.5 text-slate-600 hover:bg-slate-100 rounded"
title="Download"
>
<Download className="w-4 h-4" />
</a>
</div>
</td>
</tr>
)
})}
</tbody>
</table>
{/* Pagination */}
<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>
{/* PDF Preview Modal */}
<PDFPreviewModal
document={selectedDocument}
onClose={() => setSelectedDocument(null)}
/>
</div>
)
}

View File

@@ -0,0 +1,207 @@
'use client'
/**
* PDF Preview Modal for Abitur Documents
* Shows PDF iframe with metadata sidebar
*/
import { X, Download, ExternalLink, FileText, Calendar, BookOpen, Layers, Search } from 'lucide-react'
import { AbiturDokument, formatFileSize, formatDocumentTitle, FAECHER, NIVEAUS } from '@/lib/education/abitur-docs-types'
interface PDFPreviewModalProps {
document: AbiturDokument | null
onClose: () => void
backendUrl?: string
}
export function PDFPreviewModal({ document, onClose, backendUrl = '' }: PDFPreviewModalProps) {
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 - try backend first, fall back to direct path
const pdfUrl = backendUrl
? `${backendUrl}/api/abitur-docs/${document.id}/file`
: document.file_path
const handleDownload = () => {
const link = window.document.createElement('a')
link.href = pdfUrl
link.download = document.dateiname
link.click()
}
const handleSearchInRAG = () => {
// Navigate to edu-search with document as context
window.location.href = `/education/edu-search?doc=${document.id}&search=1`
}
return (
<div className="fixed inset-0 z-50 flex items-center justify-center">
{/* Backdrop */}
<div
className="absolute inset-0 bg-black/60 backdrop-blur-sm"
onClick={onClose}
/>
{/* Modal */}
<div className="relative bg-white rounded-2xl shadow-2xl w-[95vw] h-[90vh] max-w-7xl flex overflow-hidden">
{/* Header */}
<div className="absolute top-0 left-0 right-0 h-14 bg-white border-b border-slate-200 flex items-center justify-between px-4 z-10">
<h2 className="font-semibold text-slate-900 truncate flex items-center gap-2">
<FileText className="w-5 h-5 text-blue-600" />
{formatDocumentTitle(document)}
</h2>
<div className="flex items-center gap-2">
<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" />
RAG-Suche
</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"
>
<Download className="w-4 h-4" />
Download
</button>
<button
onClick={onClose}
className="p-2 hover:bg-slate-100 rounded-lg transition-colors"
title="Schliessen"
>
<X className="w-5 h-5 text-slate-500" />
</button>
</div>
</div>
{/* Content */}
<div className="flex w-full h-full pt-14">
{/* PDF Viewer */}
<div className="flex-1 bg-slate-100 p-4">
<iframe
src={pdfUrl}
className="w-full h-full rounded-lg border border-slate-200 bg-white"
title={document.dateiname}
/>
</div>
{/* Metadata Sidebar */}
<div className="w-80 border-l border-slate-200 bg-slate-50 p-4 overflow-y-auto">
<h3 className="font-semibold text-slate-900 mb-4">Dokument-Details</h3>
<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>
{/* Aufgaben-Nummer */}
<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>
</div>
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,533 @@
'use client'
/**
* DevOps Pipeline Sidebar
*
* Kompakte Sidebar-Komponente fuer Cross-Navigation zwischen DevOps-Modulen.
* Zeigt Pipeline-Flow und ermoeglicht schnelle Navigation.
*
* Features:
* - Desktop: Fixierte Sidebar rechts
* - Mobile: Floating Action Button mit Slide-In Drawer
* - Live Pipeline-Status Badge
* - Backlog-Count Badge
* - Security-Findings-Count Badge
* - Quick-Action: Pipeline triggern
*
* Datenfluss: CI/CD -> Tests -> SBOM -> Security
*/
import Link from 'next/link'
import { useState, useEffect } from 'react'
import type {
DevOpsToolId,
DevOpsPipelineSidebarProps,
DevOpsPipelineSidebarResponsiveProps,
PipelineLiveStatus,
} from '@/types/infrastructure-modules'
import { DEVOPS_PIPELINE_MODULES } from '@/types/infrastructure-modules'
// =============================================================================
// Icons
// =============================================================================
const ToolIcon = ({ id }: { id: DevOpsToolId }) => {
switch (id) {
case 'ci-cd':
return (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2}
d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
</svg>
)
case 'tests':
return (
<svg className="w-5 h-5" 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>
)
case 'sbom':
return (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2}
d="M20 7l-8-4-8 4m16 0l-8 4m8-4v10l-8 4m0-10L4 7m8 4v10M4 7v10l8 4" />
</svg>
)
case 'security':
return (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2}
d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z" />
</svg>
)
default:
return null
}
}
// Server/Pipeline Icon fuer Header
const ServerIcon = () => (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2}
d="M5 12h14M5 12a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v4a2 2 0 01-2 2M5 12a2 2 0 00-2 2v4a2 2 0 002 2h14a2 2 0 002-2v-4a2 2 0 00-2-2m-2-4h.01M17 16h.01" />
</svg>
)
// Play Icon fuer Quick Action
const PlayIcon = () => (
<svg className="w-4 h-4" 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>
)
// =============================================================================
// Live Status Hook (optional - fetches status from API)
// =============================================================================
function usePipelineLiveStatus(): PipelineLiveStatus | null {
const [status, setStatus] = useState<PipelineLiveStatus | null>(null)
useEffect(() => {
// Optional: Fetch live status from API
// For now, return null and display static content
// Uncomment below to enable live status fetching
/*
const fetchStatus = async () => {
try {
const response = await fetch('/api/admin/infrastructure/woodpecker/status')
if (response.ok) {
const data = await response.json()
setStatus(data)
}
} catch (error) {
console.error('Failed to fetch pipeline status:', error)
}
}
fetchStatus()
const interval = setInterval(fetchStatus, 30000) // Poll every 30s
return () => clearInterval(interval)
*/
}, [])
return status
}
// =============================================================================
// Status Badge Component
// =============================================================================
interface StatusBadgeProps {
count: number
type: 'backlog' | 'security' | 'running'
}
function StatusBadge({ count, type }: StatusBadgeProps) {
if (count === 0) return null
const colors = {
backlog: 'bg-amber-500',
security: 'bg-red-500',
running: 'bg-green-500 animate-pulse',
}
return (
<span className={`${colors[type]} text-white text-xs font-medium px-1.5 py-0.5 rounded-full min-w-[1.25rem] text-center`}>
{count}
</span>
)
}
// =============================================================================
// Main Sidebar Component
// =============================================================================
export function DevOpsPipelineSidebar({
currentTool,
compact = false,
className = '',
}: DevOpsPipelineSidebarProps) {
const [isExpanded, setIsExpanded] = useState(!compact)
const liveStatus = usePipelineLiveStatus()
return (
<div className={`bg-white dark:bg-gray-800 rounded-xl shadow-sm border border-slate-200 dark:border-gray-700 overflow-hidden ${className}`}>
{/* Header */}
<div
className="px-4 py-3 bg-gradient-to-r from-orange-50 to-amber-50 dark:from-orange-900/20 dark:to-amber-900/20 border-b border-slate-200 dark:border-gray-700 cursor-pointer"
onClick={() => setIsExpanded(!isExpanded)}
>
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<span className="text-orange-600 dark:text-orange-400">
<ServerIcon />
</span>
<span className="font-semibold text-slate-700 dark:text-slate-200 text-sm">
DevOps Pipeline
</span>
{/* Live status indicator */}
{liveStatus?.isRunning && (
<span className="w-2 h-2 bg-green-500 rounded-full animate-pulse" title="Pipeline laeuft" />
)}
</div>
<svg
className={`w-4 h-4 text-slate-400 transition-transform ${isExpanded ? '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>
</div>
</div>
{/* Content */}
{isExpanded && (
<div className="p-3 space-y-3">
{/* Tool Links */}
<div className="space-y-1">
{DEVOPS_PIPELINE_MODULES.map((tool) => (
<Link
key={tool.id}
href={tool.href}
className={`flex items-center gap-3 px-3 py-2 rounded-lg transition-colors ${
currentTool === tool.id
? 'bg-orange-100 dark:bg-orange-900/30 text-orange-700 dark:text-orange-300 font-medium'
: 'text-slate-600 dark:text-slate-400 hover:bg-slate-100 dark:hover:bg-gray-700'
}`}
>
<ToolIcon id={tool.id} />
<div className="flex-1 min-w-0">
<div className="text-sm font-medium truncate">{tool.name}</div>
<div className="text-xs text-slate-500 dark:text-slate-500 truncate">
{tool.description}
</div>
</div>
{/* Status badges */}
{tool.id === 'tests' && liveStatus && (
<StatusBadge count={liveStatus.backlogCount} type="backlog" />
)}
{tool.id === 'security' && liveStatus && (
<StatusBadge count={liveStatus.securityFindingsCount} type="security" />
)}
{currentTool === tool.id && (
<span className="flex-shrink-0 w-2 h-2 bg-orange-500 rounded-full" />
)}
</Link>
))}
</div>
{/* Pipeline Flow Visualization */}
<div className="pt-2 border-t border-slate-200 dark:border-gray-700">
<div className="text-xs font-medium text-slate-500 dark:text-slate-400 mb-2 px-1">
Pipeline Flow
</div>
<div className="flex items-center justify-between px-2 py-2 bg-slate-50 dark:bg-gray-900 rounded-lg">
<div className="flex items-center gap-1.5 text-xs">
<span title="Code" className={currentTool === 'ci-cd' ? 'opacity-100' : 'opacity-50'}>📝</span>
<span className="text-slate-400"></span>
<span title="Build" className={currentTool === 'ci-cd' ? 'opacity-100' : 'opacity-50'}>🏗</span>
<span className="text-slate-400"></span>
<span title="Test" className={currentTool === 'tests' ? 'opacity-100' : 'opacity-50'}></span>
<span className="text-slate-400"></span>
<span title="SBOM" className={currentTool === 'sbom' ? 'opacity-100' : 'opacity-50'}>📦</span>
<span className="text-slate-400"></span>
<span title="Security" className={currentTool === 'security' ? 'opacity-100' : 'opacity-50'}>🛡</span>
<span className="text-slate-400"></span>
<span title="Deploy" className="opacity-50">🚀</span>
</div>
</div>
</div>
{/* Quick Info zum aktuellen Tool */}
<div className="pt-2 border-t border-slate-200 dark:border-gray-700">
<div className="text-xs text-slate-500 dark:text-slate-400 px-1">
{currentTool === 'ci-cd' && (
<span>Verwalten Sie Woodpecker Pipelines und Deployments</span>
)}
{currentTool === 'tests' && (
<span>Ueberwachen Sie 280+ Tests ueber alle Services</span>
)}
{currentTool === 'sbom' && (
<span>Pruefen Sie Abhaengigkeiten und Lizenzen</span>
)}
{currentTool === 'security' && (
<span>Analysieren Sie Vulnerabilities und Security-Scans</span>
)}
</div>
</div>
{/* Quick Action: Pipeline triggern */}
<div className="pt-2 border-t border-slate-200 dark:border-gray-700">
<button
onClick={() => {
// TODO: Implement pipeline trigger
alert('Pipeline wird getriggert...')
}}
className="w-full flex items-center justify-center gap-2 px-3 py-2 text-sm text-orange-600 dark:text-orange-400 bg-orange-50 dark:bg-orange-900/20 hover:bg-orange-100 dark:hover:bg-orange-900/30 rounded-lg transition-colors"
>
<PlayIcon />
<span>Pipeline triggern</span>
</button>
</div>
</div>
)}
</div>
)
}
// =============================================================================
// Responsive Version with Mobile FAB + Drawer
// =============================================================================
/**
* Responsive DevOps Sidebar mit Mobile FAB + Drawer
*
* Desktop (xl+): Fixierte Sidebar rechts
* Mobile/Tablet: Floating Action Button unten rechts, oeffnet Drawer
*/
export function DevOpsPipelineSidebarResponsive({
currentTool,
compact = false,
className = '',
fabPosition = 'bottom-right',
}: DevOpsPipelineSidebarResponsiveProps) {
const [isMobileOpen, setIsMobileOpen] = useState(false)
const liveStatus = usePipelineLiveStatus()
// Close drawer on escape key
useEffect(() => {
const handleEscape = (e: KeyboardEvent) => {
if (e.key === 'Escape') setIsMobileOpen(false)
}
window.addEventListener('keydown', handleEscape)
return () => window.removeEventListener('keydown', handleEscape)
}, [])
// Prevent body scroll when drawer is open
useEffect(() => {
if (isMobileOpen) {
document.body.style.overflow = 'hidden'
} else {
document.body.style.overflow = ''
}
return () => {
document.body.style.overflow = ''
}
}, [isMobileOpen])
const fabPositionClasses = fabPosition === 'bottom-right'
? 'right-4 bottom-20'
: 'left-4 bottom-20'
// Calculate total badge count for FAB
const totalBadgeCount = liveStatus
? liveStatus.backlogCount + liveStatus.securityFindingsCount
: 0
return (
<>
{/* Desktop: Fixed Sidebar */}
<div className={`hidden xl:block fixed right-6 top-24 w-64 z-10 ${className}`}>
<DevOpsPipelineSidebar currentTool={currentTool} compact={compact} />
</div>
{/* Mobile/Tablet: FAB */}
<button
onClick={() => setIsMobileOpen(true)}
className={`xl:hidden fixed ${fabPositionClasses} z-40 w-14 h-14 bg-gradient-to-r from-orange-500 to-amber-500 text-white rounded-full shadow-lg hover:shadow-xl transition-all flex items-center justify-center group`}
aria-label="DevOps Pipeline Navigation oeffnen"
>
<ServerIcon />
{/* Badge indicator */}
{totalBadgeCount > 0 && (
<span className="absolute -top-1 -right-1 w-5 h-5 bg-red-500 text-white text-xs font-bold rounded-full flex items-center justify-center">
{totalBadgeCount > 9 ? '9+' : totalBadgeCount}
</span>
)}
{/* Pulse indicator when pipeline is running */}
{liveStatus?.isRunning && (
<span className="absolute -top-1 -right-1 w-3 h-3 bg-green-400 rounded-full animate-pulse" />
)}
</button>
{/* Mobile/Tablet: Drawer Overlay */}
{isMobileOpen && (
<div className="xl:hidden fixed inset-0 z-50">
{/* Backdrop */}
<div
className="absolute inset-0 bg-black/50 backdrop-blur-sm transition-opacity"
onClick={() => setIsMobileOpen(false)}
/>
{/* Drawer */}
<div className="absolute right-0 top-0 bottom-0 w-80 max-w-[85vw] bg-white dark:bg-gray-900 shadow-2xl transform transition-transform animate-slide-in-right">
{/* Drawer Header */}
<div className="flex items-center justify-between px-4 py-4 border-b border-slate-200 dark:border-gray-700 bg-gradient-to-r from-orange-50 to-amber-50 dark:from-orange-900/20 dark:to-amber-900/20">
<div className="flex items-center gap-2">
<span className="text-orange-600 dark:text-orange-400">
<ServerIcon />
</span>
<span className="font-semibold text-slate-700 dark:text-slate-200">
DevOps Pipeline
</span>
{liveStatus?.isRunning && (
<span className="w-2 h-2 bg-green-500 rounded-full animate-pulse" />
)}
</div>
<button
onClick={() => setIsMobileOpen(false)}
className="p-2 text-slate-400 hover:text-slate-600 dark:hover:text-slate-300 rounded-lg hover:bg-slate-100 dark:hover:bg-gray-800 transition-colors"
aria-label="Schliessen"
>
<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>
{/* Drawer Content */}
<div className="p-4 space-y-4 overflow-y-auto max-h-[calc(100vh-80px)]">
{/* Tool Links */}
<div className="space-y-2">
{DEVOPS_PIPELINE_MODULES.map((tool) => (
<Link
key={tool.id}
href={tool.href}
onClick={() => setIsMobileOpen(false)}
className={`flex items-center gap-3 px-4 py-3 rounded-xl transition-all ${
currentTool === tool.id
? 'bg-orange-100 dark:bg-orange-900/30 text-orange-700 dark:text-orange-300 font-medium shadow-sm'
: 'text-slate-600 dark:text-slate-400 hover:bg-slate-100 dark:hover:bg-gray-800'
}`}
>
<ToolIcon id={tool.id} />
<div className="flex-1">
<div className="text-base font-medium">{tool.name}</div>
<div className="text-sm text-slate-500 dark:text-slate-500">
{tool.description}
</div>
</div>
{/* Status badges */}
{tool.id === 'tests' && liveStatus && (
<StatusBadge count={liveStatus.backlogCount} type="backlog" />
)}
{tool.id === 'security' && liveStatus && (
<StatusBadge count={liveStatus.securityFindingsCount} type="security" />
)}
{currentTool === tool.id && (
<span className="flex-shrink-0 w-2.5 h-2.5 bg-orange-500 rounded-full" />
)}
</Link>
))}
</div>
{/* Pipeline Flow Visualization */}
<div className="pt-4 border-t border-slate-200 dark:border-gray-700">
<div className="text-sm font-medium text-slate-500 dark:text-slate-400 mb-3">
Pipeline Flow
</div>
<div className="flex items-center justify-center gap-2 p-4 bg-slate-50 dark:bg-gray-800 rounded-xl">
<div className="flex flex-col items-center">
<span className="text-2xl">📝</span>
<span className="text-xs text-slate-500 mt-1">Code</span>
</div>
<span className="text-slate-400"></span>
<div className="flex flex-col items-center">
<span className="text-2xl">🏗</span>
<span className="text-xs text-slate-500 mt-1">Build</span>
</div>
<span className="text-slate-400"></span>
<div className="flex flex-col items-center">
<span className="text-2xl"></span>
<span className="text-xs text-slate-500 mt-1">Test</span>
</div>
<span className="text-slate-400"></span>
<div className="flex flex-col items-center">
<span className="text-2xl">🚀</span>
<span className="text-xs text-slate-500 mt-1">Deploy</span>
</div>
</div>
</div>
{/* Quick Info */}
<div className="pt-4 border-t border-slate-200 dark:border-gray-700">
<div className="text-sm text-slate-600 dark:text-slate-400 p-3 bg-slate-50 dark:bg-gray-800 rounded-xl">
{currentTool === 'ci-cd' && (
<>
<strong className="text-slate-700 dark:text-slate-300">Aktuell:</strong> Woodpecker Pipelines und Deployments verwalten
</>
)}
{currentTool === 'tests' && (
<>
<strong className="text-slate-700 dark:text-slate-300">Aktuell:</strong> 280+ Tests ueber alle Services ueberwachen
</>
)}
{currentTool === 'sbom' && (
<>
<strong className="text-slate-700 dark:text-slate-300">Aktuell:</strong> Abhaengigkeiten und Lizenzen pruefen
</>
)}
{currentTool === 'security' && (
<>
<strong className="text-slate-700 dark:text-slate-300">Aktuell:</strong> Vulnerabilities und Security-Scans analysieren
</>
)}
</div>
</div>
{/* Quick Action: Pipeline triggern */}
<div className="pt-4 border-t border-slate-200 dark:border-gray-700">
<button
onClick={() => {
// TODO: Implement pipeline trigger
alert('Pipeline wird getriggert...')
setIsMobileOpen(false)
}}
className="w-full flex items-center justify-center gap-2 px-4 py-3 text-base text-white bg-gradient-to-r from-orange-500 to-amber-500 hover:from-orange-600 hover:to-amber-600 rounded-xl transition-colors font-medium"
>
<PlayIcon />
<span>Pipeline triggern</span>
</button>
</div>
{/* Link to Infrastructure Overview */}
<div className="pt-4 border-t border-slate-200 dark:border-gray-700">
<Link
href="/infrastructure"
onClick={() => setIsMobileOpen(false)}
className="flex items-center gap-2 px-3 py-2 text-sm text-orange-600 dark:text-orange-400 hover:bg-orange-50 dark:hover:bg-orange-900/20 rounded-lg 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="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6" />
</svg>
<span>Zur Infrastructure-Uebersicht</span>
</Link>
</div>
</div>
</div>
</div>
)}
{/* CSS for slide-in animation */}
<style jsx>{`
@keyframes slide-in-right {
from {
transform: translateX(100%);
}
to {
transform: translateX(0);
}
}
.animate-slide-in-right {
animation: slide-in-right 0.2s ease-out;
}
`}</style>
</>
)
}
export default DevOpsPipelineSidebar

View File

@@ -237,23 +237,44 @@ export function Sidebar({ onRoleChange }: SidebarProps) {
{/* Category Modules */}
{!collapsed && expandedCategories.has(category.id) && (
<div className="ml-4 mt-1 space-y-1">
{category.modules.map((module) => (
<Link
key={module.id}
href={module.href}
className={`flex items-center gap-2 px-3 py-2 rounded-lg text-sm transition-colors ${
isModuleActive(module.href)
? 'bg-primary-600 text-white'
: 'text-slate-400 hover:bg-slate-800 hover:text-white'
}`}
>
<span
className="w-1.5 h-1.5 rounded-full"
style={{ backgroundColor: category.color }}
/>
<span className="truncate">{module.name}</span>
</Link>
))}
{(() => {
// Group modules by subgroup
const subgroups = new Map<string | undefined, typeof category.modules>()
category.modules.forEach((module) => {
const key = module.subgroup
if (!subgroups.has(key)) {
subgroups.set(key, [])
}
subgroups.get(key)!.push(module)
})
return Array.from(subgroups.entries()).map(([subgroupName, modules]) => (
<div key={subgroupName || 'default'}>
{subgroupName && (
<div className="px-3 py-1.5 text-xs font-medium text-slate-500 uppercase tracking-wider mt-2 first:mt-0">
{subgroupName}
</div>
)}
{modules.map((module) => (
<Link
key={module.id}
href={module.href}
className={`flex items-center gap-2 px-3 py-2 rounded-lg text-sm transition-colors ${
isModuleActive(module.href)
? 'bg-primary-600 text-white'
: 'text-slate-400 hover:bg-slate-800 hover:text-white'
}`}
>
<span
className="w-1.5 h-1.5 rounded-full"
style={{ backgroundColor: category.color }}
/>
<span className="truncate">{module.name}</span>
</Link>
))}
</div>
))
})()}
</div>
)}
</div>
@@ -266,18 +287,18 @@ export function Sidebar({ onRoleChange }: SidebarProps) {
<div className="p-4 border-t border-slate-700">
<RoleIndicator collapsed={collapsed} onRoleChange={onRoleChange} />
<Link
href="/"
<a
href="https://macmini:3000/admin"
className={`flex items-center gap-3 px-3 py-2 rounded-lg text-slate-400 hover:text-white hover:bg-slate-800 transition-colors mt-2 ${
collapsed ? 'justify-center' : ''
}`}
title="Zur Website"
title="Altes Admin (Port 3000)"
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 19l-7-7m0 0l7-7m-7 7h18" />
</svg>
{!collapsed && <span>Altes Admin</span>}
</Link>
</a>
</div>
</aside>
)

View File

@@ -0,0 +1,458 @@
'use client'
/**
* BlockReviewPanel Component
*
* Provides a block-by-block review interface for OCR comparison results.
* Shows what each OCR method (A, B, C, D) detected for each grid block.
* Allows approving or correcting each block.
*/
import { useState, useCallback, useMemo } from 'react'
import { cn } from '@/lib/utils'
import type { GridData, GridCell } from './GridOverlay'
import { getCellBlockNumber } from './GridOverlay'
export type BlockStatus = 'pending' | 'approved' | 'corrected' | 'skipped'
export interface MethodResult {
methodId: string
methodName: string
text: string
confidence?: number
}
export interface BlockReviewData {
blockNumber: number
cell: GridCell
methodResults: MethodResult[]
status: BlockStatus
correctedText?: string
approvedMethodId?: string
}
interface BlockReviewPanelProps {
grid: GridData
methodResults: Record<string, { vocabulary: Array<{ english: string; german: string; example?: string }> }>
currentBlockNumber: number
onBlockChange: (blockNumber: number) => void
onApprove: (blockNumber: number, methodId: string, text: string) => void
onCorrect: (blockNumber: number, correctedText: string) => void
onSkip: (blockNumber: number) => void
reviewData: Record<number, BlockReviewData>
className?: string
}
// Method colors for consistent display
const METHOD_COLORS: Record<string, { bg: string; border: string; text: string }> = {
vision_llm: { bg: 'bg-blue-50', border: 'border-blue-300', text: 'text-blue-700' },
tesseract: { bg: 'bg-green-50', border: 'border-green-300', text: 'text-green-700' },
paddleocr: { bg: 'bg-purple-50', border: 'border-purple-300', text: 'text-purple-700' },
claude_vision: { bg: 'bg-amber-50', border: 'border-amber-300', text: 'text-amber-700' },
}
const METHOD_LABELS: Record<string, string> = {
vision_llm: 'B: Vision LLM',
tesseract: 'D: Tesseract',
paddleocr: 'C: PaddleOCR',
claude_vision: 'E: Claude Vision',
}
export function BlockReviewPanel({
grid,
methodResults,
currentBlockNumber,
onBlockChange,
onApprove,
onCorrect,
onSkip,
reviewData,
className,
}: BlockReviewPanelProps) {
const [correctionText, setCorrectionText] = useState('')
const [showCorrection, setShowCorrection] = useState(false)
// Get all non-empty blocks
const nonEmptyBlocks = useMemo(() => {
const blocks: { blockNumber: number; cell: GridCell }[] = []
grid.cells.flat().forEach((cell) => {
if (cell.status !== 'empty') {
blocks.push({
blockNumber: getCellBlockNumber(cell, grid),
cell,
})
}
})
return blocks.sort((a, b) => a.blockNumber - b.blockNumber)
}, [grid])
// Current block data
const currentBlock = useMemo(() => {
return nonEmptyBlocks.find((b) => b.blockNumber === currentBlockNumber)
}, [nonEmptyBlocks, currentBlockNumber])
// Current block index for navigation
const currentIndex = useMemo(() => {
return nonEmptyBlocks.findIndex((b) => b.blockNumber === currentBlockNumber)
}, [nonEmptyBlocks, currentBlockNumber])
// Find matching vocabulary for current cell from each method
const blockMethodResults = useMemo(() => {
if (!currentBlock) return []
const results: MethodResult[] = []
const cellRow = currentBlock.cell.row
const cellCol = currentBlock.cell.col
// For each method, try to find vocabulary that matches this cell position
Object.entries(methodResults).forEach(([methodId, data]) => {
if (!data.vocabulary || data.vocabulary.length === 0) return
// Get the text from the grid cell itself (from grid detection)
const cellText = currentBlock.cell.text
// Try to match vocabulary entries
// This is a simplified matching - in production you'd match by position
const vocabIndex = cellRow * grid.columns + cellCol
let matchedText = ''
if (data.vocabulary[vocabIndex]) {
const v = data.vocabulary[vocabIndex]
matchedText = currentBlock.cell.column_type === 'english'
? v.english
: currentBlock.cell.column_type === 'german'
? v.german
: v.example || ''
}
results.push({
methodId,
methodName: METHOD_LABELS[methodId] || methodId,
text: matchedText || cellText || '(nicht erkannt)',
confidence: currentBlock.cell.confidence,
})
})
// Always include the grid detection result
if (currentBlock.cell.text) {
results.unshift({
methodId: 'grid_detection',
methodName: 'Grid-OCR',
text: currentBlock.cell.text,
confidence: currentBlock.cell.confidence,
})
}
return results
}, [currentBlock, methodResults, grid.columns])
// Navigation handlers
const goToPrevious = useCallback(() => {
if (currentIndex > 0) {
onBlockChange(nonEmptyBlocks[currentIndex - 1].blockNumber)
setShowCorrection(false)
setCorrectionText('')
}
}, [currentIndex, nonEmptyBlocks, onBlockChange])
const goToNext = useCallback(() => {
if (currentIndex < nonEmptyBlocks.length - 1) {
onBlockChange(nonEmptyBlocks[currentIndex + 1].blockNumber)
setShowCorrection(false)
setCorrectionText('')
}
}, [currentIndex, nonEmptyBlocks, onBlockChange])
// Action handlers
const handleApprove = useCallback((methodId: string, text: string) => {
onApprove(currentBlockNumber, methodId, text)
goToNext()
}, [currentBlockNumber, onApprove, goToNext])
const handleCorrect = useCallback(() => {
if (correctionText.trim()) {
onCorrect(currentBlockNumber, correctionText.trim())
goToNext()
}
}, [currentBlockNumber, correctionText, onCorrect, goToNext])
const handleSkip = useCallback(() => {
onSkip(currentBlockNumber)
goToNext()
}, [currentBlockNumber, onSkip, goToNext])
// Calculate progress
const reviewedCount = Object.values(reviewData).filter(
(r) => r.status !== 'pending'
).length
const progress = nonEmptyBlocks.length > 0
? Math.round((reviewedCount / nonEmptyBlocks.length) * 100)
: 0
const currentReview = reviewData[currentBlockNumber]
if (!currentBlock) {
return (
<div className={cn('p-4 text-center text-slate-500', className)}>
Keine Blöcke zum Überprüfen
</div>
)
}
return (
<div className={cn('flex flex-col h-full', className)}>
{/* Header with progress */}
<div className="flex items-center justify-between px-4 py-3 border-b border-slate-200 bg-slate-50">
<div className="flex items-center gap-3">
<span className="font-semibold text-slate-700">
Block {currentBlockNumber}
</span>
<span className="text-sm text-slate-500">
({currentIndex + 1} von {nonEmptyBlocks.length})
</span>
</div>
<div className="flex items-center gap-2">
<div className="w-32 h-2 bg-slate-200 rounded-full overflow-hidden">
<div
className="h-full bg-green-500 transition-all duration-300"
style={{ width: `${progress}%` }}
/>
</div>
<span className="text-xs text-slate-500">{progress}%</span>
</div>
</div>
{/* Block status indicator */}
{currentReview && currentReview.status !== 'pending' && (
<div className={cn(
'px-4 py-2 text-sm font-medium',
currentReview.status === 'approved' && 'bg-green-100 text-green-700',
currentReview.status === 'corrected' && 'bg-blue-100 text-blue-700',
currentReview.status === 'skipped' && 'bg-slate-100 text-slate-600',
)}>
{currentReview.status === 'approved' && `✓ Freigegeben: "${currentReview.correctedText}"`}
{currentReview.status === 'corrected' && `✎ Korrigiert: "${currentReview.correctedText}"`}
{currentReview.status === 'skipped' && '○ Übersprungen'}
</div>
)}
{/* Cell info */}
<div className="px-4 py-2 bg-slate-50 border-b text-sm">
<span className="text-slate-500">Position: </span>
<span className="font-medium">Zeile {currentBlock.cell.row + 1}, Spalte {currentBlock.cell.col + 1}</span>
{currentBlock.cell.column_type && (
<>
<span className="text-slate-500 ml-3">Typ: </span>
<span className="font-medium capitalize">{currentBlock.cell.column_type}</span>
</>
)}
</div>
{/* Method results */}
<div className="flex-1 overflow-y-auto p-4 space-y-3">
<h4 className="text-sm font-medium text-slate-600 mb-2">Erkannte Texte:</h4>
{blockMethodResults.map((result) => {
const colors = METHOD_COLORS[result.methodId] || {
bg: 'bg-slate-50',
border: 'border-slate-300',
text: 'text-slate-700'
}
return (
<div
key={result.methodId}
className={cn(
'p-3 rounded-lg border-2 transition-all',
colors.bg,
colors.border,
'hover:shadow-md cursor-pointer'
)}
onClick={() => handleApprove(result.methodId, result.text)}
>
<div className="flex items-center justify-between mb-1">
<span className={cn('text-xs font-semibold', colors.text)}>
{result.methodName}
</span>
{result.confidence !== undefined && result.confidence < 0.7 && (
<span className="text-xs text-orange-500">
{Math.round(result.confidence * 100)}%
</span>
)}
</div>
<div className="text-sm font-medium text-slate-900">
{result.text || <span className="text-slate-400 italic">(leer)</span>}
</div>
<div className="mt-2 text-xs text-slate-500">
Klicken zum Übernehmen
</div>
</div>
)
})}
{/* Manual correction */}
{showCorrection ? (
<div className="p-3 rounded-lg border-2 border-indigo-300 bg-indigo-50">
<label className="text-xs font-semibold text-indigo-700 block mb-2">
Manuelle Korrektur:
</label>
<input
type="text"
value={correctionText}
onChange={(e) => setCorrectionText(e.target.value)}
placeholder="Korrekten Text eingeben..."
className="w-full px-3 py-2 border border-indigo-200 rounded-lg text-sm focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500"
autoFocus
onKeyDown={(e) => {
if (e.key === 'Enter') handleCorrect()
if (e.key === 'Escape') setShowCorrection(false)
}}
/>
<div className="flex gap-2 mt-2">
<button
onClick={handleCorrect}
disabled={!correctionText.trim()}
className="flex-1 px-3 py-1.5 bg-indigo-600 text-white text-sm font-medium rounded-lg hover:bg-indigo-700 disabled:opacity-50 disabled:cursor-not-allowed"
>
Übernehmen
</button>
<button
onClick={() => setShowCorrection(false)}
className="px-3 py-1.5 bg-slate-200 text-slate-700 text-sm font-medium rounded-lg hover:bg-slate-300"
>
Abbrechen
</button>
</div>
</div>
) : (
<button
onClick={() => setShowCorrection(true)}
className="w-full p-3 rounded-lg border-2 border-dashed border-slate-300 text-slate-500 text-sm hover:border-indigo-400 hover:text-indigo-600 transition-colors"
>
+ Manuell korrigieren
</button>
)}
</div>
{/* Navigation footer */}
<div className="flex items-center justify-between px-4 py-3 border-t border-slate-200 bg-slate-50">
<button
onClick={goToPrevious}
disabled={currentIndex === 0}
className="flex items-center gap-1 px-3 py-2 text-sm font-medium text-slate-600 hover:text-slate-900 disabled:opacity-40 disabled:cursor-not-allowed"
>
<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>
Zurück
</button>
<button
onClick={handleSkip}
className="px-4 py-2 text-sm font-medium text-slate-500 hover:text-slate-700 hover:bg-slate-100 rounded-lg transition-colors"
>
Überspringen
</button>
<button
onClick={goToNext}
disabled={currentIndex === nonEmptyBlocks.length - 1}
className="flex items-center gap-1 px-3 py-2 text-sm font-medium text-slate-600 hover:text-slate-900 disabled:opacity-40 disabled:cursor-not-allowed"
>
Weiter
<svg className="w-4 h-4" 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>
)
}
/**
* BlockReviewSummary Component
*
* Shows a summary of all reviewed blocks.
*/
interface BlockReviewSummaryProps {
reviewData: Record<number, BlockReviewData>
totalBlocks: number
onBlockClick: (blockNumber: number) => void
className?: string
}
export function BlockReviewSummary({
reviewData,
totalBlocks,
onBlockClick,
className,
}: BlockReviewSummaryProps) {
const stats = useMemo(() => {
const values = Object.values(reviewData)
return {
approved: values.filter((r) => r.status === 'approved').length,
corrected: values.filter((r) => r.status === 'corrected').length,
skipped: values.filter((r) => r.status === 'skipped').length,
pending: totalBlocks - values.length,
}
}, [reviewData, totalBlocks])
return (
<div className={cn('p-4 space-y-4', className)}>
<h3 className="font-semibold text-slate-800">Überprüfungsübersicht</h3>
{/* Stats */}
<div className="grid grid-cols-2 gap-2">
<div className="p-2 bg-green-50 rounded-lg text-center">
<div className="text-xl font-bold text-green-700">{stats.approved}</div>
<div className="text-xs text-green-600">Freigegeben</div>
</div>
<div className="p-2 bg-blue-50 rounded-lg text-center">
<div className="text-xl font-bold text-blue-700">{stats.corrected}</div>
<div className="text-xs text-blue-600">Korrigiert</div>
</div>
<div className="p-2 bg-slate-100 rounded-lg text-center">
<div className="text-xl font-bold text-slate-600">{stats.skipped}</div>
<div className="text-xs text-slate-500">Übersprungen</div>
</div>
<div className="p-2 bg-amber-50 rounded-lg text-center">
<div className="text-xl font-bold text-amber-600">{stats.pending}</div>
<div className="text-xs text-amber-500">Ausstehend</div>
</div>
</div>
{/* Block list */}
<div className="space-y-1 max-h-64 overflow-y-auto">
{Object.entries(reviewData)
.sort(([a], [b]) => Number(a) - Number(b))
.map(([blockNum, data]) => (
<button
key={blockNum}
onClick={() => onBlockClick(Number(blockNum))}
className={cn(
'w-full flex items-center justify-between px-3 py-2 rounded-lg text-sm transition-colors',
data.status === 'approved' && 'bg-green-50 hover:bg-green-100',
data.status === 'corrected' && 'bg-blue-50 hover:bg-blue-100',
data.status === 'skipped' && 'bg-slate-50 hover:bg-slate-100',
)}
>
<span className="font-medium text-slate-700">Block {blockNum}</span>
<span className={cn(
'text-xs',
data.status === 'approved' && 'text-green-600',
data.status === 'corrected' && 'text-blue-600',
data.status === 'skipped' && 'text-slate-500',
)}>
{data.status === 'approved' && '✓'}
{data.status === 'corrected' && '✎'}
{data.status === 'skipped' && '○'}
{' '}
{data.correctedText?.substring(0, 20)}
{data.correctedText && data.correctedText.length > 20 && '...'}
</span>
</button>
))}
</div>
</div>
)
}

View File

@@ -0,0 +1,294 @@
'use client'
/**
* CellCorrectionDialog Component
*
* Modal dialog for manually correcting OCR text in problematic or recognized cells.
* Shows cropped image of the cell for reference and allows text input.
*/
import { useState, useEffect } from 'react'
import type { GridCell } from './GridOverlay'
interface CellCorrectionDialogProps {
cell: GridCell
columnType: 'english' | 'german' | 'example' | 'unknown'
sessionId: string
pageNumber: number
onSave: (text: string) => void
onRetryOCR?: () => void
onClose: () => void
}
export function CellCorrectionDialog({
cell,
columnType,
sessionId,
pageNumber,
onSave,
onRetryOCR,
onClose,
}: CellCorrectionDialogProps) {
const [text, setText] = useState(cell.text || '')
const [loading, setLoading] = useState(false)
const [retrying, setRetrying] = useState(false)
const [cropUrl, setCropUrl] = useState<string | null>(null)
const KLAUSUR_API = '/klausur-api'
// Load cell crop image
useEffect(() => {
const loadCrop = async () => {
try {
const res = await fetch(
`${KLAUSUR_API}/api/v1/vocab/sessions/${sessionId}/cell-crop/${pageNumber}/${cell.row}/${cell.col}`
)
if (res.ok) {
const blob = await res.blob()
setCropUrl(URL.createObjectURL(blob))
}
} catch (e) {
console.error('Failed to load cell crop:', e)
}
}
loadCrop()
return () => {
if (cropUrl) {
URL.revokeObjectURL(cropUrl)
}
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [sessionId, pageNumber, cell.row, cell.col])
const handleSave = async () => {
if (!text.trim()) return
setLoading(true)
try {
const formData = new FormData()
formData.append('text', text)
const res = await fetch(
`${KLAUSUR_API}/api/v1/vocab/sessions/${sessionId}/cell/${cell.row}/${cell.col}`,
{
method: 'PUT',
body: formData,
}
)
if (res.ok) {
onSave(text)
onClose()
} else {
console.error('Failed to save cell:', await res.text())
}
} catch (e) {
console.error('Save failed:', e)
} finally {
setLoading(false)
}
}
const handleRetryOCR = async () => {
if (!onRetryOCR) return
setRetrying(true)
try {
await onRetryOCR()
} finally {
setRetrying(false)
}
}
const getColumnLabel = () => {
switch (columnType) {
case 'english':
return 'Englisch'
case 'german':
return 'Deutsch'
case 'example':
return 'Beispielsatz'
default:
return 'Text'
}
}
const getPlaceholder = () => {
switch (columnType) {
case 'english':
return 'Englisches Wort eingeben...'
case 'german':
return 'Deutsche Ubersetzung eingeben...'
case 'example':
return 'Beispielsatz eingeben...'
default:
return 'Text eingeben...'
}
}
return (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
{/* Backdrop */}
<div
className="absolute inset-0 bg-black/50 backdrop-blur-sm"
onClick={onClose}
/>
{/* Dialog */}
<div className="relative w-full max-w-lg bg-white rounded-xl shadow-xl overflow-hidden">
{/* Header */}
<div className="px-6 py-4 border-b border-slate-200 flex items-center justify-between">
<div>
<h3 className="text-lg font-semibold text-slate-900">
{cell.status === 'problematic' ? 'Nicht erkannter Bereich' : 'Text bearbeiten'}
</h3>
<p className="text-sm text-slate-500">
Zeile {cell.row + 1}, Spalte {cell.col + 1} ({getColumnLabel()})
</p>
</div>
<button
onClick={onClose}
className="p-2 rounded-lg hover:bg-slate-100 transition-colors"
>
<svg className="w-5 h-5 text-slate-500" 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>
{/* Content */}
<div className="p-6 space-y-4">
{/* Cell image preview */}
<div className="border rounded-lg p-3 bg-slate-50">
<p className="text-xs text-slate-500 mb-2 font-medium">Originalbild:</p>
{cropUrl ? (
<img
src={cropUrl}
alt="Zellinhalt"
className="max-h-32 mx-auto rounded border border-slate-200 bg-white"
/>
) : (
<div className="h-20 flex items-center justify-center text-slate-400 text-sm">
Lade Vorschau...
</div>
)}
</div>
{/* Status indicator */}
{cell.status === 'problematic' && (
<div className="flex items-center gap-2 p-3 bg-orange-50 border border-orange-200 rounded-lg">
<svg className="w-5 h-5 text-orange-500 flex-shrink-0" 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-sm text-orange-700">
Diese Zelle konnte nicht automatisch erkannt werden.
</span>
</div>
)}
{/* Current recognized text */}
{cell.status === 'recognized' && cell.text && (
<div className="p-3 bg-green-50 border border-green-200 rounded-lg">
<p className="text-xs text-green-600 font-medium mb-1">Erkannter Text:</p>
<p className="text-sm text-green-800">{cell.text}</p>
{cell.confidence < 1 && (
<p className="text-xs text-green-600 mt-1">
Konfidenz: {Math.round(cell.confidence * 100)}%
</p>
)}
</div>
)}
{/* Text input */}
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">
{cell.status === 'problematic' ? 'Text eingeben' : 'Text korrigieren'}
</label>
{columnType === 'example' ? (
<textarea
value={text}
onChange={(e) => setText(e.target.value)}
placeholder={getPlaceholder()}
rows={3}
className="w-full px-3 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 text-slate-900"
/>
) : (
<input
type="text"
value={text}
onChange={(e) => setText(e.target.value)}
placeholder={getPlaceholder()}
className="w-full px-3 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 text-slate-900"
autoFocus
onKeyDown={(e) => {
if (e.key === 'Enter') {
handleSave()
}
}}
/>
)}
</div>
</div>
{/* Footer */}
<div className="px-6 py-4 border-t border-slate-200 flex items-center justify-between bg-slate-50">
<div>
{onRetryOCR && cell.status === 'problematic' && (
<button
onClick={handleRetryOCR}
disabled={retrying}
className="px-4 py-2 text-sm font-medium text-slate-700 bg-white border border-slate-300 rounded-lg hover:bg-slate-50 disabled:opacity-50 flex items-center gap-2"
>
{retrying ? (
<>
<svg className="animate-spin w-4 h-4" 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>
Erneut versuchen...
</>
) : (
<>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
</svg>
OCR erneut versuchen
</>
)}
</button>
)}
</div>
<div className="flex gap-3">
<button
onClick={onClose}
className="px-4 py-2 text-sm font-medium text-slate-700 bg-white border border-slate-300 rounded-lg hover:bg-slate-50"
>
Abbrechen
</button>
<button
onClick={handleSave}
disabled={loading || !text.trim()}
className="px-4 py-2 text-sm font-medium text-white bg-indigo-600 rounded-lg hover:bg-indigo-700 disabled:opacity-50 flex items-center gap-2"
>
{loading ? (
<>
<svg className="animate-spin w-4 h-4" 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>
Speichern...
</>
) : (
'Speichern'
)}
</button>
</div>
</div>
</div>
</div>
)
}
export default CellCorrectionDialog

Some files were not shown because too many files have changed in this diff Show More