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

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