Initial commit: breakpilot-lehrer - Lehrer KI Platform

Services: Admin-Lehrer, Backend-Lehrer, Studio v2, Website,
Klausur-Service, School-Service, Voice-Service, Geo-Service,
BreakPilot Drive, Agent-Core

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Benjamin Boenisch
2026-02-11 23:47:26 +01:00
commit 5a31f52310
1224 changed files with 425430 additions and 0 deletions

View File

@@ -0,0 +1,504 @@
'use client'
/**
* Abitur-Archiv - Lehrer Version
* Simplified archive view for teachers
*/
import { useState, useEffect, useCallback } from 'react'
import Link from 'next/link'
import {
FileText, Filter, ChevronLeft, ChevronRight, Eye, Download, Search,
X, Loader2, Grid, List, LayoutGrid, Plus, Archive, BookOpen
} from 'lucide-react'
// API Base URL
const API_BASE = '/api/education/abitur-archiv'
// Filter constants
const FAECHER = [
{ id: 'deutsch', label: 'Deutsch' },
{ id: 'englisch', label: 'Englisch' },
{ id: 'mathematik', label: 'Mathematik' },
{ id: 'biologie', label: 'Biologie' },
{ id: 'physik', label: 'Physik' },
{ id: 'chemie', label: 'Chemie' },
{ id: 'geschichte', label: 'Geschichte' },
]
const JAHRE = [2025, 2024, 2023, 2022, 2021]
const NIVEAUS = [
{ id: 'eA', label: 'Erhoehtes Niveau (eA)' },
{ id: 'gA', label: 'Grundlegendes Niveau (gA)' },
]
const TYPEN = [
{ id: 'aufgabe', label: 'Aufgabe' },
{ id: 'erwartungshorizont', label: 'Erwartungshorizont' },
]
interface AbiturDokument {
id: string
dateiname: string
fach: string
jahr: number
niveau: 'eA' | 'gA'
typ: 'aufgabe' | 'erwartungshorizont'
aufgaben_nummer: string
status: string
file_path: string
file_size: number
}
export default function AbiturArchivPage() {
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<'grid' | 'list'>('grid')
// Filters
const [filterOpen, setFilterOpen] = useState(false)
const [filterFach, setFilterFach] = useState('')
const [filterJahr, setFilterJahr] = useState('')
const [filterNiveau, setFilterNiveau] = useState('')
const [filterTyp, setFilterTyp] = useState('')
const [searchQuery, setSearchQuery] = useState('')
// Preview modal
const [previewDoc, setPreviewDoc] = useState<AbiturDokument | null>(null)
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 (filterNiveau) params.set('niveau', filterNiveau)
if (filterTyp) params.set('typ', filterTyp)
if (searchQuery) params.set('thema', searchQuery)
try {
// Try the admin-v2 API first
const response = await fetch(`${API_BASE}?${params.toString()}`)
if (!response.ok) throw new Error('Fehler beim Laden')
const data = await response.json()
setDocuments(data.documents || [])
setTotalPages(data.total_pages || 1)
setTotal(data.total || 0)
} catch (err) {
// Fallback: use mock data
setDocuments(getMockDocuments())
setTotal(getMockDocuments().length)
setTotalPages(1)
} finally {
setLoading(false)
}
}, [page, filterFach, filterJahr, filterNiveau, filterTyp, searchQuery])
useEffect(() => {
fetchDocuments()
}, [fetchDocuments])
const clearFilters = () => {
setFilterFach('')
setFilterJahr('')
setFilterNiveau('')
setFilterTyp('')
setSearchQuery('')
setPage(1)
}
const handleSearch = (e: React.FormEvent) => {
e.preventDefault()
setPage(1)
fetchDocuments()
}
const handleDownload = (doc: AbiturDokument) => {
const link = document.createElement('a')
link.href = doc.file_path
link.download = doc.dateiname
link.click()
}
const handleAddToKlausur = (doc: AbiturDokument) => {
window.location.href = `/lehrer/klausur-korrektur?archiv_doc_id=${doc.id}`
}
const hasActiveFilters = filterFach || filterJahr || filterNiveau || filterTyp || searchQuery
const formatFileSize = (bytes: number) => {
if (bytes < 1024) return bytes + ' B'
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB'
return (bytes / (1024 * 1024)).toFixed(1) + ' MB'
}
return (
<div className="space-y-6">
{/* Header */}
<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-purple-500 to-purple-700 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>
<div className="text-right">
<div className="text-2xl font-bold text-slate-800">{total}</div>
<div className="text-sm text-slate-500">Dokumente</div>
</div>
</div>
{/* Search Bar */}
<div className="bg-white rounded-xl border border-slate-200 p-4">
<form onSubmit={handleSearch} className="flex gap-3">
<div className="relative flex-1">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-slate-400" />
<input
type="text"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder="Thema suchen (z.B. Gedichtanalyse, Eroerterung...)"
className="w-full pl-10 pr-4 py-2.5 border border-slate-300 rounded-lg focus:ring-2 focus:ring-blue-500"
/>
</div>
<button
type="submit"
className="px-6 py-2.5 bg-blue-600 text-white rounded-lg hover:bg-blue-700"
>
Suchen
</button>
</form>
</div>
{/* Filters */}
<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, filterNiveau, filterTyp].filter(Boolean).length}
</span>
)}
</button>
{hasActiveFilters && (
<button
onClick={clearFilters}
className="text-sm text-slate-500 hover:text-slate-700 flex items-center gap-1"
>
<X className="w-4 h-4" />
Zuruecksetzen
</button>
)}
</div>
<div className="flex items-center gap-3">
<span className="text-sm text-slate-500">{total} Treffer</span>
<div className="flex bg-slate-100 rounded-lg p-1">
<button
onClick={() => setViewMode('grid')}
className={`p-2 rounded-md ${viewMode === 'grid' ? 'bg-white shadow-sm' : ''}`}
>
<LayoutGrid className="w-4 h-4" />
</button>
<button
onClick={() => setViewMode('list')}
className={`p-2 rounded-md ${viewMode === 'list' ? 'bg-white shadow-sm' : ''}`}
>
<List className="w-4 h-4" />
</button>
</div>
</div>
</div>
{filterOpen && (
<div className="grid grid-cols-2 md:grid-cols-4 gap-3 pt-4 border-t border-slate-200">
<select
value={filterFach}
onChange={(e) => { setFilterFach(e.target.value); setPage(1) }}
className="px-3 py-2 border border-slate-300 rounded-lg text-sm"
>
<option value="">Alle Faecher</option>
{FAECHER.map(f => <option key={f.id} value={f.id}>{f.label}</option>)}
</select>
<select
value={filterJahr}
onChange={(e) => { setFilterJahr(e.target.value); setPage(1) }}
className="px-3 py-2 border border-slate-300 rounded-lg text-sm"
>
<option value="">Alle Jahre</option>
{JAHRE.map(j => <option key={j} value={j}>{j}</option>)}
</select>
<select
value={filterNiveau}
onChange={(e) => { setFilterNiveau(e.target.value); setPage(1) }}
className="px-3 py-2 border border-slate-300 rounded-lg text-sm"
>
<option value="">Alle Niveaus</option>
{NIVEAUS.map(n => <option key={n.id} value={n.id}>{n.label}</option>)}
</select>
<select
value={filterTyp}
onChange={(e) => { setFilterTyp(e.target.value); setPage(1) }}
className="px-3 py-2 border border-slate-300 rounded-lg text-sm"
>
<option value="">Alle Typen</option>
{TYPEN.map(t => <option key={t.id} value={t.id}>{t.label}</option>)}
</select>
</div>
)}
</div>
{/* Documents */}
<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>
) : 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>
</div>
) : viewMode === 'grid' ? (
<div className="p-4 grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
{documents.map((doc) => {
const fachLabel = FAECHER.find(f => f.id === doc.fach)?.label || doc.fach
return (
<div
key={doc.id}
className="bg-white rounded-xl border border-slate-200 overflow-hidden hover:shadow-lg transition-all cursor-pointer group"
onClick={() => setPreviewDoc(doc)}
>
<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" />
<span className={`absolute top-3 left-3 px-2.5 py-1 rounded-full text-xs font-medium ${
doc.typ === 'erwartungshorizont'
? 'bg-orange-100 text-orange-700'
: 'bg-purple-100 text-purple-700'
}`}>
{doc.typ === 'erwartungshorizont' ? 'EWH' : 'Aufgabe'}
</span>
<span className="absolute top-3 right-3 px-2 py-1 bg-white/80 rounded-lg text-xs font-semibold">
{doc.jahr}
</span>
</div>
<div className="p-4">
<h3 className="font-semibold text-slate-800 mb-2">
{fachLabel} {doc.niveau} - {doc.aufgaben_nummer}
</h3>
<div className="text-sm text-slate-500 mb-3">{doc.dateiname}</div>
<div className="flex gap-2">
<button
onClick={(e) => { e.stopPropagation(); setPreviewDoc(doc) }}
className="flex-1 px-3 py-2 bg-blue-50 text-blue-700 rounded-lg hover:bg-blue-100 text-sm font-medium flex items-center justify-center gap-1.5"
>
<Eye className="w-4 h-4" />
Vorschau
</button>
<button
onClick={(e) => { e.stopPropagation(); handleDownload(doc) }}
className="px-3 py-2 text-slate-600 hover:bg-slate-100 rounded-lg"
>
<Download className="w-4 h-4" />
</button>
<button
onClick={(e) => { e.stopPropagation(); handleAddToKlausur(doc) }}
className="px-3 py-2 text-green-600 hover:bg-green-50 rounded-lg"
>
<Plus className="w-4 h-4" />
</button>
</div>
</div>
</div>
)
})}
</div>
) : (
<table className="w-full text-sm">
<thead className="bg-slate-50 border-b border-slate-200">
<tr>
<th className="text-left px-4 py-3 font-medium text-slate-600">Dokument</th>
<th className="text-left px-4 py-3 font-medium text-slate-600">Fach</th>
<th className="text-center px-4 py-3 font-medium text-slate-600">Jahr</th>
<th className="text-center px-4 py-3 font-medium text-slate-600">Niveau</th>
<th className="text-center px-4 py-3 font-medium text-slate-600">Typ</th>
<th className="text-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">
<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">{doc.dateiname}</span>
</div>
</td>
<td className="px-4 py-3">{fachLabel}</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-center">
<div className="flex items-center justify-center gap-1">
<button onClick={() => setPreviewDoc(doc)} className="p-1.5 text-blue-600 hover:bg-blue-50 rounded">
<Eye className="w-4 h-4" />
</button>
<button onClick={() => handleDownload(doc)} className="p-1.5 text-slate-600 hover:bg-slate-100 rounded">
<Download className="w-4 h-4" />
</button>
<button onClick={() => handleAddToKlausur(doc)} className="p-1.5 text-green-600 hover:bg-green-50 rounded">
<Plus 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">
Seite {page} von {totalPages}
</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"
>
<ChevronLeft className="w-4 h-4" />
</button>
<button
onClick={() => setPage(p => Math.min(totalPages, p + 1))}
disabled={page === totalPages}
className="p-2 rounded-lg hover:bg-slate-200 disabled:opacity-50"
>
<ChevronRight className="w-4 h-4" />
</button>
</div>
</div>
)}
</div>
{/* Preview Modal */}
{previewDoc && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60">
<div className="bg-white rounded-2xl shadow-2xl w-[90vw] h-[85vh] max-w-5xl flex flex-col">
<div className="flex items-center justify-between px-4 py-3 border-b border-slate-200">
<h2 className="font-semibold text-slate-900">{previewDoc.dateiname}</h2>
<div className="flex items-center gap-2">
<button
onClick={() => handleDownload(previewDoc)}
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"
>
<Download className="w-4 h-4" />
Download
</button>
<button
onClick={() => handleAddToKlausur(previewDoc)}
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"
>
<Plus className="w-4 h-4" />
Zur Klausur
</button>
<button
onClick={() => setPreviewDoc(null)}
className="p-2 hover:bg-slate-100 rounded-lg"
>
<X className="w-5 h-5 text-slate-500" />
</button>
</div>
</div>
<div className="flex-1 bg-slate-100 p-4">
<iframe
src={previewDoc.file_path}
className="w-full h-full rounded-lg border border-slate-200 bg-white"
title={previewDoc.dateiname}
/>
</div>
</div>
</div>
)}
</div>
)
}
function getMockDocuments(): AbiturDokument[] {
const docs: AbiturDokument[] = []
const faecher = ['deutsch', 'englisch']
const jahre = [2024, 2023, 2022]
const niveaus: Array<'eA' | 'gA'> = ['eA', 'gA']
const typen: Array<'aufgabe' | 'erwartungshorizont'> = ['aufgabe', 'erwartungshorizont']
const nummern = ['I', 'II', 'III']
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) {
docs.push({
id: `doc-${id++}`,
dateiname: `${jahr}_${fach}_${niveau}_${nummer}${typ === 'erwartungshorizont' ? '_EWH' : ''}.pdf`,
fach,
jahr,
niveau,
typ,
aufgaben_nummer: nummer,
status: 'indexed',
file_path: '#',
file_size: 250000 + Math.random() * 500000
})
}
}
}
}
}
return docs
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,497 @@
'use client'
/**
* Fairness-Dashboard
*
* Visualizes grading consistency and identifies outliers for review.
* Features:
* - Grade distribution histogram
* - Criteria heatmap
* - Outlier list with quick navigation
* - Fairness score gauge
*/
import { useState, useEffect, useCallback } from 'react'
import { useParams, useRouter } from 'next/navigation'
import AdminLayout from '@/components/admin/AdminLayout'
import Link from 'next/link'
const API_BASE = process.env.NEXT_PUBLIC_KLAUSUR_SERVICE_URL || 'http://localhost:8086'
// Grade labels
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'
}
// Criterion colors
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)
// Fetch data
const fetchData = useCallback(async () => {
try {
setLoading(true)
// Fetch klausur
const klausurRes = await fetch(`${API_BASE}/api/v1/klausuren/${klausurId}`)
if (klausurRes.ok) {
setKlausur(await klausurRes.json())
}
// Fetch fairness analysis
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])
// Calculate grade distribution for histogram
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 (
<AdminLayout title="Lade Fairness-Analyse..." description="">
<div className="flex items-center justify-center h-[calc(100vh-200px)]">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary-600"></div>
</div>
</AdminLayout>
)
}
return (
<AdminLayout
title="Fairness-Analyse"
description={klausur?.title || ''}
>
{/* Header */}
<div className="bg-white border-b border-slate-200 -mx-6 -mt-6 px-6 py-4 mb-6">
<div className="flex items-center justify-between">
<Link
href={`/lehrer/klausur-korrektur/${klausurId}`}
className="text-primary-600 hover:text-primary-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>
{/* 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">
{/* Background circle */}
<svg className="w-32 h-32 transform -rotate-90">
<circle
cx="64"
cy="64"
r="56"
fill="none"
stroke="#e2e8f0"
strokeWidth="12"
/>
{/* Progress circle */}
<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>
{/* Score text */}
<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-primary-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">
{/* Range bar */}
<div
className="absolute h-full opacity-30"
style={{
backgroundColor: color,
left: `${data.min}%`,
width: `${range}%`
}}
/>
{/* Average marker */}
<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={`/lehrer/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={`/lehrer/klausur-korrektur/${klausurId}/${student.id}`}
className="text-primary-600 hover:text-primary-800 text-sm"
>
Bearbeiten
</Link>
</td>
</tr>
)
})}
</tbody>
</table>
</div>
</div>
</div>
)}
</AdminLayout>
)
}

View File

@@ -0,0 +1,499 @@
'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 AdminLayout from '@/components/admin/AdminLayout'
import Link from 'next/link'
import type { Klausur, StudentWork, STATUS_COLORS, STATUS_LABELS } from '../types'
const API_BASE = process.env.NEXT_PUBLIC_KLAUSUR_SERVICE_URL || 'http://localhost:8086'
// Status configuration
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 läuft', 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)
// Fetch Klausur details
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])
// Fetch students
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])
// PDF Export functions
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)
}
}
// Handle file upload
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()
// Reset file input
if (fileInputRef.current) {
fileInputRef.current.value = ''
}
}
// Handle delete student work
const handleDeleteStudent = async (studentId: string) => {
if (!confirm('Studentenarbeit wirklich löschen?')) 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 Löschen')
}
} catch (err) {
console.error('Failed to delete student:', err)
setError('Fehler beim Löschen')
}
}
// Get grade display
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] || '-'
}
}
// Calculate statistics
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 (
<AdminLayout title="Lädt..." description="">
<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>
</div>
</AdminLayout>
)
}
return (
<AdminLayout
title={klausur?.title || 'Klausur'}
description={`${klausur?.subject} - ${klausur?.year} | ${students.length} Arbeiten`}
>
{/* Breadcrumb */}
<div className="mb-4">
<Link
href="/lehrer/klausur-korrektur"
className="text-primary-600 hover:text-primary-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>
Zurück zur Übersicht
</Link>
</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-primary-600">{stats.avgGrade}</div>
<div className="text-sm text-slate-500">Ø Note</div>
</div>
</div>
{/* Fairness Analysis Button */}
{stats.completed >= 2 && (
<div className="mb-6 flex flex-wrap gap-3">
<Link
href={`/lehrer/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>
{/* PDF Export Buttons */}
<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-primary-600 text-white hover:bg-primary-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>
{/* Upload progress */}
{uploading && (
<div className="h-2 bg-slate-100 rounded-full overflow-hidden">
<div
className="h-full bg-primary-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-primary-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"
>
{/* Index */}
<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>
{/* Student info */}
<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>
{/* Grade */}
<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>
{/* Progress indicator */}
<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>
{/* Actions */}
<div className="flex items-center gap-2">
<Link
href={`/lehrer/klausur-korrektur/${klausurId}/${student.id}`}
className="px-3 py-1.5 bg-primary-600 text-white text-sm rounded-lg hover:bg-primary-700"
>
Korrigieren
</Link>
<button
onClick={() => handleDeleteStudent(student.id)}
className="p-1.5 text-red-600 hover:bg-red-50 rounded-lg"
title="Löschen"
>
<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 verfügbar</h3>
<p className="text-sm text-blue-600">
Prüfen Sie die Bewertungen auf Konsistenz und Fairness
</p>
</div>
<button className="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700">
Fairness-Check starten
</button>
</div>
</div>
)}
</AdminLayout>
)
}

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-primary-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-primary-500"
/>
)}
<div className="flex gap-2">
<button
onClick={() => handleSaveEdit(annotation.id)}
className="flex-1 py-1 text-xs bg-primary-600 text-white rounded hover:bg-primary-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="/admin/rag"
className="inline-block px-4 py-2 bg-primary-600 text-white text-sm rounded-lg hover:bg-primary-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-primary-600 text-white rounded-lg hover:bg-primary-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-primary-100 text-primary-700 rounded hover:bg-primary-200"
>
Im Gutachten verwenden
</button>
)}
</div>
</div>
))}
</div>
</div>
)
}

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 läuft',
OCR_COMPLETE: 'OCR fertig',
ANALYZING: 'Analyse läuft',
FIRST_EXAMINER: 'Erstkorrektur',
SECOND_EXAMINER: 'Zweitkorrektur',
COMPLETED: 'Abgeschlossen',
ERROR: 'Fehler',
}

View File

@@ -0,0 +1,19 @@
/**
* Lehrer Section Layout
* Wraps all /lehrer/* pages with LehrerLayout
*/
import LehrerLayout from '@/components/lehrer/LehrerLayout'
export const metadata = {
title: 'Lehrer-Portal - BreakPilot',
description: 'KI-gestuetzte Werkzeuge fuer Lehrer',
}
export default function LehrerRootLayout({
children,
}: {
children: React.ReactNode
}) {
return <LehrerLayout>{children}</LehrerLayout>
}

136
website/app/lehrer/page.tsx Normal file
View File

@@ -0,0 +1,136 @@
'use client'
/**
* Lehrer Dashboard
* Main page for teacher section with quick links and overview
*/
import Link from 'next/link'
const quickLinks = [
{
title: 'Abitur-Archiv',
description: 'Durchsuchen Sie Zentralabitur-Materialien (2021-2025) nach Themen, Fach und Jahr.',
href: '/lehrer/abitur-archiv',
icon: (
<svg className="w-8 h-8" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M5 8h14M5 8a2 2 0 110-4h14a2 2 0 110 4M5 8v10a2 2 0 002 2h10a2 2 0 002-2V8m-9 4h4" />
</svg>
),
color: 'from-purple-500 to-purple-700',
stats: '328 Dokumente',
},
{
title: 'Klausur-Korrektur',
description: 'KI-gestuetzte Korrektur von Abitur- und Vorabitur-Klausuren mit automatischen Gutachten.',
href: '/lehrer/klausur-korrektur',
icon: (
<svg className="w-8 h-8" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
),
color: 'from-blue-500 to-blue-700',
stats: 'Bis zu 80% Zeit sparen',
},
]
const recentActivity = [
{ type: 'korrektur', title: 'Deutsch LK Abitur 2025', status: '5/24 korrigiert', date: 'vor 2 Stunden' },
{ type: 'archiv', title: 'Gedichtanalyse 2024 eA', status: 'Heruntergeladen', date: 'Gestern' },
{ type: 'korrektur', title: 'Englisch GK Vorabitur', status: 'Abgeschlossen', date: 'vor 3 Tagen' },
]
export default function LehrerDashboard() {
return (
<div className="space-y-8">
{/* Hero Section */}
<div className="bg-gradient-to-r from-blue-600 to-purple-600 rounded-2xl p-8 text-white">
<h1 className="text-3xl font-bold mb-2">Willkommen im Lehrer-Portal</h1>
<p className="text-blue-100 text-lg max-w-2xl">
Nutzen Sie KI-gestuetzte Werkzeuge fuer die Abitur-Korrektur und durchsuchen Sie
das zentrale Archiv mit Aufgaben und Erwartungshorizonten.
</p>
</div>
{/* Quick Links */}
<div className="grid md:grid-cols-2 gap-6">
{quickLinks.map((link) => (
<Link
key={link.href}
href={link.href}
className="group bg-white rounded-xl border border-slate-200 p-6 hover:shadow-lg transition-all"
>
<div className="flex items-start gap-4">
<div className={`w-14 h-14 rounded-xl bg-gradient-to-br ${link.color} flex items-center justify-center text-white`}>
{link.icon}
</div>
<div className="flex-1">
<h3 className="text-lg font-semibold text-slate-800 group-hover:text-blue-600 transition-colors">
{link.title}
</h3>
<p className="text-sm text-slate-500 mt-1 mb-3">
{link.description}
</p>
<span className="inline-flex items-center text-sm text-blue-600 font-medium">
{link.stats}
<svg className="w-4 h-4 ml-1 group-hover:translate-x-1 transition-transform" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
</svg>
</span>
</div>
</div>
</Link>
))}
</div>
{/* Recent Activity */}
<div className="bg-white rounded-xl border border-slate-200 p-6">
<h2 className="text-lg font-semibold text-slate-800 mb-4">Letzte Aktivitaeten</h2>
{recentActivity.length > 0 ? (
<div className="space-y-3">
{recentActivity.map((activity, idx) => (
<div key={idx} className="flex items-center gap-4 p-3 bg-slate-50 rounded-lg">
<div className={`w-10 h-10 rounded-full flex items-center justify-center ${
activity.type === 'korrektur' ? 'bg-blue-100 text-blue-600' : 'bg-purple-100 text-purple-600'
}`}>
{activity.type === 'korrektur' ? (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
) : (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 8h14M5 8a2 2 0 110-4h14a2 2 0 110 4M5 8v10a2 2 0 002 2h10a2 2 0 002-2V8m-9 4h4" />
</svg>
)}
</div>
<div className="flex-1">
<div className="font-medium text-slate-800">{activity.title}</div>
<div className="text-sm text-slate-500">{activity.status}</div>
</div>
<div className="text-sm text-slate-400">{activity.date}</div>
</div>
))}
</div>
) : (
<p className="text-slate-500 text-center py-8">Noch keine Aktivitaeten</p>
)}
</div>
{/* Help Section */}
<div className="bg-slate-100 rounded-xl p-6 text-center">
<h3 className="font-medium text-slate-800 mb-2">Brauchen Sie Hilfe?</h3>
<p className="text-sm text-slate-500 mb-4">
Lesen Sie unsere Dokumentation oder kontaktieren Sie den Support.
</p>
<div className="flex justify-center gap-4">
<button className="px-4 py-2 bg-white text-slate-700 rounded-lg hover:bg-slate-50 border border-slate-200">
Dokumentation
</button>
<button className="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700">
Support kontaktieren
</button>
</div>
</div>
</div>
)
}