feat: BreakPilot PWA - Full codebase (clean push without large binaries)
Some checks failed
Tests / Go Tests (push) Has been cancelled
Tests / Python Tests (push) Has been cancelled
Tests / Integration Tests (push) Has been cancelled
Tests / Go Lint (push) Has been cancelled
Tests / Python Lint (push) Has been cancelled
Tests / Security Scan (push) Has been cancelled
Tests / All Checks Passed (push) Has been cancelled
Security Scanning / Secret Scanning (push) Has been cancelled
Security Scanning / Dependency Vulnerability Scan (push) Has been cancelled
Security Scanning / Go Security Scan (push) Has been cancelled
Security Scanning / Python Security Scan (push) Has been cancelled
Security Scanning / Node.js Security Scan (push) Has been cancelled
Security Scanning / Docker Image Security (push) Has been cancelled
Security Scanning / Security Summary (push) Has been cancelled
CI/CD Pipeline / Go Tests (push) Has been cancelled
CI/CD Pipeline / Python Tests (push) Has been cancelled
CI/CD Pipeline / Website Tests (push) Has been cancelled
CI/CD Pipeline / Linting (push) Has been cancelled
CI/CD Pipeline / Security Scan (push) Has been cancelled
CI/CD Pipeline / Docker Build & Push (push) Has been cancelled
CI/CD Pipeline / Integration Tests (push) Has been cancelled
CI/CD Pipeline / Deploy to Staging (push) Has been cancelled
CI/CD Pipeline / Deploy to Production (push) Has been cancelled
CI/CD Pipeline / CI Summary (push) Has been cancelled
ci/woodpecker/manual/build-ci-image Pipeline was successful
ci/woodpecker/manual/main Pipeline failed
Some checks failed
Tests / Go Tests (push) Has been cancelled
Tests / Python Tests (push) Has been cancelled
Tests / Integration Tests (push) Has been cancelled
Tests / Go Lint (push) Has been cancelled
Tests / Python Lint (push) Has been cancelled
Tests / Security Scan (push) Has been cancelled
Tests / All Checks Passed (push) Has been cancelled
Security Scanning / Secret Scanning (push) Has been cancelled
Security Scanning / Dependency Vulnerability Scan (push) Has been cancelled
Security Scanning / Go Security Scan (push) Has been cancelled
Security Scanning / Python Security Scan (push) Has been cancelled
Security Scanning / Node.js Security Scan (push) Has been cancelled
Security Scanning / Docker Image Security (push) Has been cancelled
Security Scanning / Security Summary (push) Has been cancelled
CI/CD Pipeline / Go Tests (push) Has been cancelled
CI/CD Pipeline / Python Tests (push) Has been cancelled
CI/CD Pipeline / Website Tests (push) Has been cancelled
CI/CD Pipeline / Linting (push) Has been cancelled
CI/CD Pipeline / Security Scan (push) Has been cancelled
CI/CD Pipeline / Docker Build & Push (push) Has been cancelled
CI/CD Pipeline / Integration Tests (push) Has been cancelled
CI/CD Pipeline / Deploy to Staging (push) Has been cancelled
CI/CD Pipeline / Deploy to Production (push) Has been cancelled
CI/CD Pipeline / CI Summary (push) Has been cancelled
ci/woodpecker/manual/build-ci-image Pipeline was successful
ci/woodpecker/manual/main Pipeline failed
All services: admin-v2, studio-v2, website, ai-compliance-sdk, consent-service, klausur-service, voice-service, and infrastructure. Large PDFs and compiled binaries excluded via .gitignore.
This commit is contained in:
504
website/app/lehrer/abitur-archiv/page.tsx
Normal file
504
website/app/lehrer/abitur-archiv/page.tsx
Normal 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
@@ -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>
|
||||
)
|
||||
}
|
||||
499
website/app/lehrer/klausur-korrektur/[klausurId]/page.tsx
Normal file
499
website/app/lehrer/klausur-korrektur/[klausurId]/page.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,281 @@
|
||||
'use client'
|
||||
|
||||
/**
|
||||
* AnnotationLayer
|
||||
*
|
||||
* SVG overlay component for displaying and creating annotations on documents.
|
||||
* Renders positioned rectangles with color-coding by annotation type.
|
||||
*/
|
||||
|
||||
import { useState, useRef, useCallback } from 'react'
|
||||
import type { Annotation, AnnotationType, AnnotationPosition } from '../types'
|
||||
import { ANNOTATION_COLORS } from '../types'
|
||||
|
||||
interface AnnotationLayerProps {
|
||||
annotations: Annotation[]
|
||||
selectedTool: AnnotationType | null
|
||||
onCreateAnnotation: (position: AnnotationPosition, type: AnnotationType) => void
|
||||
onSelectAnnotation: (annotation: Annotation) => void
|
||||
selectedAnnotationId?: string
|
||||
disabled?: boolean
|
||||
}
|
||||
|
||||
export default function AnnotationLayer({
|
||||
annotations,
|
||||
selectedTool,
|
||||
onCreateAnnotation,
|
||||
onSelectAnnotation,
|
||||
selectedAnnotationId,
|
||||
disabled = false,
|
||||
}: AnnotationLayerProps) {
|
||||
const svgRef = useRef<SVGSVGElement>(null)
|
||||
const [isDrawing, setIsDrawing] = useState(false)
|
||||
const [startPos, setStartPos] = useState<{ x: number; y: number } | null>(null)
|
||||
const [currentRect, setCurrentRect] = useState<AnnotationPosition | null>(null)
|
||||
|
||||
// Convert mouse position to percentage
|
||||
const getPercentPosition = useCallback((e: React.MouseEvent<SVGSVGElement>) => {
|
||||
if (!svgRef.current) return null
|
||||
|
||||
const rect = svgRef.current.getBoundingClientRect()
|
||||
const x = ((e.clientX - rect.left) / rect.width) * 100
|
||||
const y = ((e.clientY - rect.top) / rect.height) * 100
|
||||
|
||||
return { x: Math.max(0, Math.min(100, x)), y: Math.max(0, Math.min(100, y)) }
|
||||
}, [])
|
||||
|
||||
// Handle mouse down - start drawing
|
||||
const handleMouseDown = useCallback(
|
||||
(e: React.MouseEvent<SVGSVGElement>) => {
|
||||
if (disabled || !selectedTool) return
|
||||
|
||||
const pos = getPercentPosition(e)
|
||||
if (!pos) return
|
||||
|
||||
setIsDrawing(true)
|
||||
setStartPos(pos)
|
||||
setCurrentRect({ x: pos.x, y: pos.y, width: 0, height: 0 })
|
||||
},
|
||||
[disabled, selectedTool, getPercentPosition]
|
||||
)
|
||||
|
||||
// Handle mouse move - update rectangle
|
||||
const handleMouseMove = useCallback(
|
||||
(e: React.MouseEvent<SVGSVGElement>) => {
|
||||
if (!isDrawing || !startPos) return
|
||||
|
||||
const pos = getPercentPosition(e)
|
||||
if (!pos) return
|
||||
|
||||
const x = Math.min(startPos.x, pos.x)
|
||||
const y = Math.min(startPos.y, pos.y)
|
||||
const width = Math.abs(pos.x - startPos.x)
|
||||
const height = Math.abs(pos.y - startPos.y)
|
||||
|
||||
setCurrentRect({ x, y, width, height })
|
||||
},
|
||||
[isDrawing, startPos, getPercentPosition]
|
||||
)
|
||||
|
||||
// Handle mouse up - finish drawing
|
||||
const handleMouseUp = useCallback(() => {
|
||||
if (!isDrawing || !currentRect || !selectedTool) {
|
||||
setIsDrawing(false)
|
||||
setStartPos(null)
|
||||
setCurrentRect(null)
|
||||
return
|
||||
}
|
||||
|
||||
// Only create annotation if rectangle is large enough (min 1% x 0.5%)
|
||||
if (currentRect.width > 1 && currentRect.height > 0.5) {
|
||||
onCreateAnnotation(currentRect, selectedTool)
|
||||
}
|
||||
|
||||
setIsDrawing(false)
|
||||
setStartPos(null)
|
||||
setCurrentRect(null)
|
||||
}, [isDrawing, currentRect, selectedTool, onCreateAnnotation])
|
||||
|
||||
// Handle clicking on existing annotation
|
||||
const handleAnnotationClick = useCallback(
|
||||
(e: React.MouseEvent, annotation: Annotation) => {
|
||||
e.stopPropagation()
|
||||
onSelectAnnotation(annotation)
|
||||
},
|
||||
[onSelectAnnotation]
|
||||
)
|
||||
|
||||
return (
|
||||
<svg
|
||||
ref={svgRef}
|
||||
className={`absolute inset-0 w-full h-full ${
|
||||
selectedTool && !disabled ? 'cursor-crosshair' : 'cursor-default'
|
||||
}`}
|
||||
style={{ pointerEvents: disabled ? 'none' : 'auto' }}
|
||||
onMouseDown={handleMouseDown}
|
||||
onMouseMove={handleMouseMove}
|
||||
onMouseUp={handleMouseUp}
|
||||
onMouseLeave={handleMouseUp}
|
||||
>
|
||||
{/* SVG Defs for patterns */}
|
||||
<defs>
|
||||
{/* Wavy pattern for Rechtschreibung errors */}
|
||||
<pattern id="wavyPattern" patternUnits="userSpaceOnUse" width="10" height="4">
|
||||
<path
|
||||
d="M0 2 Q 2.5 0, 5 2 T 10 2"
|
||||
stroke="#dc2626"
|
||||
strokeWidth="1.5"
|
||||
fill="none"
|
||||
/>
|
||||
</pattern>
|
||||
{/* Straight underline pattern for Grammatik errors */}
|
||||
<pattern id="straightPattern" patternUnits="userSpaceOnUse" width="6" height="3">
|
||||
<line x1="0" y1="1.5" x2="6" y2="1.5" stroke="#2563eb" strokeWidth="1.5" />
|
||||
</pattern>
|
||||
</defs>
|
||||
|
||||
{/* Existing annotations */}
|
||||
{annotations.map((annotation) => {
|
||||
const isSelected = annotation.id === selectedAnnotationId
|
||||
const color = ANNOTATION_COLORS[annotation.type] || '#6b7280'
|
||||
const isRS = annotation.type === 'rechtschreibung'
|
||||
const isGram = annotation.type === 'grammatik'
|
||||
|
||||
return (
|
||||
<g key={annotation.id} onClick={(e) => handleAnnotationClick(e, annotation)}>
|
||||
{/* Background rectangle - different styles for RS/Gram */}
|
||||
{isRS || isGram ? (
|
||||
<>
|
||||
{/* Light highlight background */}
|
||||
<rect
|
||||
x={`${annotation.position.x}%`}
|
||||
y={`${annotation.position.y}%`}
|
||||
width={`${annotation.position.width}%`}
|
||||
height={`${annotation.position.height}%`}
|
||||
fill={color}
|
||||
fillOpacity={isSelected ? 0.25 : 0.15}
|
||||
className="cursor-pointer hover:fill-opacity-25 transition-all"
|
||||
/>
|
||||
{/* Underline - wavy for RS, straight for Gram */}
|
||||
<rect
|
||||
x={`${annotation.position.x}%`}
|
||||
y={`${annotation.position.y + annotation.position.height - 0.5}%`}
|
||||
width={`${annotation.position.width}%`}
|
||||
height="0.5%"
|
||||
fill={isRS ? 'url(#wavyPattern)' : color}
|
||||
stroke="none"
|
||||
/>
|
||||
{/* Border when selected */}
|
||||
{isSelected && (
|
||||
<rect
|
||||
x={`${annotation.position.x}%`}
|
||||
y={`${annotation.position.y}%`}
|
||||
width={`${annotation.position.width}%`}
|
||||
height={`${annotation.position.height}%`}
|
||||
fill="none"
|
||||
stroke={color}
|
||||
strokeWidth={2}
|
||||
strokeDasharray="4,2"
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
/* Standard rectangle for other annotation types */
|
||||
<rect
|
||||
x={`${annotation.position.x}%`}
|
||||
y={`${annotation.position.y}%`}
|
||||
width={`${annotation.position.width}%`}
|
||||
height={`${annotation.position.height}%`}
|
||||
fill={color}
|
||||
fillOpacity={0.2}
|
||||
stroke={color}
|
||||
strokeWidth={isSelected ? 3 : 2}
|
||||
strokeDasharray={annotation.severity === 'minor' ? '4,2' : undefined}
|
||||
className="cursor-pointer hover:fill-opacity-30 transition-all"
|
||||
rx="2"
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Type indicator icon (small circle in corner) */}
|
||||
<circle
|
||||
cx={`${annotation.position.x}%`}
|
||||
cy={`${annotation.position.y}%`}
|
||||
r="6"
|
||||
fill={color}
|
||||
stroke="white"
|
||||
strokeWidth="1"
|
||||
/>
|
||||
|
||||
{/* Type letter */}
|
||||
<text
|
||||
x={`${annotation.position.x}%`}
|
||||
y={`${annotation.position.y}%`}
|
||||
textAnchor="middle"
|
||||
dominantBaseline="middle"
|
||||
fill="white"
|
||||
fontSize="8"
|
||||
fontWeight="bold"
|
||||
style={{ pointerEvents: 'none' }}
|
||||
>
|
||||
{annotation.type.charAt(0).toUpperCase()}
|
||||
</text>
|
||||
|
||||
{/* Severity indicator (small dot) */}
|
||||
{annotation.severity === 'critical' && (
|
||||
<circle
|
||||
cx={`${annotation.position.x + annotation.position.width}%`}
|
||||
cy={`${annotation.position.y}%`}
|
||||
r="4"
|
||||
fill="#dc2626"
|
||||
stroke="white"
|
||||
strokeWidth="1"
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Selection indicator */}
|
||||
{isSelected && (
|
||||
<>
|
||||
{/* Corner handles */}
|
||||
{[
|
||||
{ cx: annotation.position.x, cy: annotation.position.y },
|
||||
{ cx: annotation.position.x + annotation.position.width, cy: annotation.position.y },
|
||||
{ cx: annotation.position.x, cy: annotation.position.y + annotation.position.height },
|
||||
{
|
||||
cx: annotation.position.x + annotation.position.width,
|
||||
cy: annotation.position.y + annotation.position.height,
|
||||
},
|
||||
].map((corner, i) => (
|
||||
<circle
|
||||
key={i}
|
||||
cx={`${corner.cx}%`}
|
||||
cy={`${corner.cy}%`}
|
||||
r="4"
|
||||
fill="white"
|
||||
stroke={color}
|
||||
strokeWidth="2"
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</g>
|
||||
)
|
||||
})}
|
||||
|
||||
{/* Currently drawing rectangle */}
|
||||
{currentRect && selectedTool && (
|
||||
<rect
|
||||
x={`${currentRect.x}%`}
|
||||
y={`${currentRect.y}%`}
|
||||
width={`${currentRect.width}%`}
|
||||
height={`${currentRect.height}%`}
|
||||
fill={ANNOTATION_COLORS[selectedTool]}
|
||||
fillOpacity={0.3}
|
||||
stroke={ANNOTATION_COLORS[selectedTool]}
|
||||
strokeWidth={2}
|
||||
strokeDasharray="5,5"
|
||||
rx="2"
|
||||
/>
|
||||
)}
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,267 @@
|
||||
'use client'
|
||||
|
||||
/**
|
||||
* AnnotationPanel
|
||||
*
|
||||
* Panel for viewing, editing, and managing annotations.
|
||||
* Shows a list of all annotations with options to edit text, change severity, or delete.
|
||||
*/
|
||||
|
||||
import { useState } from 'react'
|
||||
import type { Annotation, AnnotationType } from '../types'
|
||||
import { ANNOTATION_COLORS } from '../types'
|
||||
|
||||
interface AnnotationPanelProps {
|
||||
annotations: Annotation[]
|
||||
selectedAnnotation: Annotation | null
|
||||
onSelectAnnotation: (annotation: Annotation | null) => void
|
||||
onUpdateAnnotation: (id: string, updates: Partial<Annotation>) => void
|
||||
onDeleteAnnotation: (id: string) => void
|
||||
}
|
||||
|
||||
const SEVERITY_OPTIONS = [
|
||||
{ value: 'minor', label: 'Leicht', color: '#fbbf24' },
|
||||
{ value: 'major', label: 'Mittel', color: '#f97316' },
|
||||
{ value: 'critical', label: 'Schwer', color: '#dc2626' },
|
||||
] as const
|
||||
|
||||
const TYPE_LABELS: Record<AnnotationType, string> = {
|
||||
rechtschreibung: 'Rechtschreibung',
|
||||
grammatik: 'Grammatik',
|
||||
inhalt: 'Inhalt',
|
||||
struktur: 'Struktur',
|
||||
stil: 'Stil',
|
||||
comment: 'Kommentar',
|
||||
highlight: 'Markierung',
|
||||
}
|
||||
|
||||
export default function AnnotationPanel({
|
||||
annotations,
|
||||
selectedAnnotation,
|
||||
onSelectAnnotation,
|
||||
onUpdateAnnotation,
|
||||
onDeleteAnnotation,
|
||||
}: AnnotationPanelProps) {
|
||||
const [editingId, setEditingId] = useState<string | null>(null)
|
||||
const [editText, setEditText] = useState('')
|
||||
const [editSuggestion, setEditSuggestion] = useState('')
|
||||
|
||||
// Group annotations by type
|
||||
const groupedAnnotations = annotations.reduce(
|
||||
(acc, ann) => {
|
||||
if (!acc[ann.type]) {
|
||||
acc[ann.type] = []
|
||||
}
|
||||
acc[ann.type].push(ann)
|
||||
return acc
|
||||
},
|
||||
{} as Record<AnnotationType, Annotation[]>
|
||||
)
|
||||
|
||||
const handleEdit = (annotation: Annotation) => {
|
||||
setEditingId(annotation.id)
|
||||
setEditText(annotation.text)
|
||||
setEditSuggestion(annotation.suggestion || '')
|
||||
}
|
||||
|
||||
const handleSaveEdit = (id: string) => {
|
||||
onUpdateAnnotation(id, { text: editText, suggestion: editSuggestion || undefined })
|
||||
setEditingId(null)
|
||||
setEditText('')
|
||||
setEditSuggestion('')
|
||||
}
|
||||
|
||||
const handleCancelEdit = () => {
|
||||
setEditingId(null)
|
||||
setEditText('')
|
||||
setEditSuggestion('')
|
||||
}
|
||||
|
||||
if (annotations.length === 0) {
|
||||
return (
|
||||
<div className="p-4 text-center text-slate-500">
|
||||
<svg className="w-12 h-12 mx-auto mb-3 text-slate-300" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={1.5}
|
||||
d="M7 8h10M7 12h4m1 8l-4-4H5a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v8a2 2 0 01-2 2h-3l-4 4z"
|
||||
/>
|
||||
</svg>
|
||||
<p className="text-sm">Keine Annotationen vorhanden</p>
|
||||
<p className="text-xs mt-1">Waehlen Sie ein Werkzeug und markieren Sie Stellen im Dokument</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="h-full overflow-auto">
|
||||
{/* Summary */}
|
||||
<div className="p-3 border-b border-slate-200 bg-slate-50">
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="font-medium text-slate-700">{annotations.length} Annotationen</span>
|
||||
<div className="flex gap-2">
|
||||
{Object.entries(groupedAnnotations).map(([type, anns]) => (
|
||||
<span
|
||||
key={type}
|
||||
className="px-2 py-0.5 text-xs rounded-full text-white"
|
||||
style={{ backgroundColor: ANNOTATION_COLORS[type as AnnotationType] }}
|
||||
>
|
||||
{anns.length}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Annotations list by type */}
|
||||
<div className="divide-y divide-slate-100">
|
||||
{(Object.entries(groupedAnnotations) as [AnnotationType, Annotation[]][]).map(([type, anns]) => (
|
||||
<div key={type}>
|
||||
{/* Type header */}
|
||||
<div
|
||||
className="px-3 py-2 text-xs font-semibold text-white"
|
||||
style={{ backgroundColor: ANNOTATION_COLORS[type] }}
|
||||
>
|
||||
{TYPE_LABELS[type]} ({anns.length})
|
||||
</div>
|
||||
|
||||
{/* Annotations in this type */}
|
||||
{anns.map((annotation) => {
|
||||
const isSelected = selectedAnnotation?.id === annotation.id
|
||||
const isEditing = editingId === annotation.id
|
||||
const severityInfo = SEVERITY_OPTIONS.find((s) => s.value === annotation.severity)
|
||||
|
||||
return (
|
||||
<div
|
||||
key={annotation.id}
|
||||
className={`p-3 cursor-pointer transition-colors ${
|
||||
isSelected ? 'bg-blue-50 border-l-4 border-blue-500' : 'hover:bg-slate-50'
|
||||
}`}
|
||||
onClick={() => onSelectAnnotation(isSelected ? null : annotation)}
|
||||
>
|
||||
{isEditing ? (
|
||||
/* Edit mode */
|
||||
<div className="space-y-2" onClick={(e) => e.stopPropagation()}>
|
||||
<textarea
|
||||
value={editText}
|
||||
onChange={(e) => setEditText(e.target.value)}
|
||||
placeholder="Kommentar..."
|
||||
className="w-full p-2 text-sm border border-slate-300 rounded resize-none focus:ring-2 focus:ring-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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
'use client'
|
||||
|
||||
/**
|
||||
* AnnotationToolbar
|
||||
*
|
||||
* Toolbar for selecting annotation tools and controlling the document viewer.
|
||||
*/
|
||||
|
||||
import type { AnnotationType } from '../types'
|
||||
import { ANNOTATION_COLORS } from '../types'
|
||||
|
||||
interface AnnotationToolbarProps {
|
||||
selectedTool: AnnotationType | null
|
||||
onSelectTool: (tool: AnnotationType | null) => void
|
||||
zoom: number
|
||||
onZoomChange: (zoom: number) => void
|
||||
annotationCounts: Record<AnnotationType, number>
|
||||
disabled?: boolean
|
||||
}
|
||||
|
||||
const ANNOTATION_TOOLS: { type: AnnotationType; label: string; shortcut: string }[] = [
|
||||
{ type: 'rechtschreibung', label: 'Rechtschreibung', shortcut: 'R' },
|
||||
{ type: 'grammatik', label: 'Grammatik', shortcut: 'G' },
|
||||
{ type: 'inhalt', label: 'Inhalt', shortcut: 'I' },
|
||||
{ type: 'struktur', label: 'Struktur', shortcut: 'S' },
|
||||
{ type: 'stil', label: 'Stil', shortcut: 'T' },
|
||||
{ type: 'comment', label: 'Kommentar', shortcut: 'K' },
|
||||
]
|
||||
|
||||
export default function AnnotationToolbar({
|
||||
selectedTool,
|
||||
onSelectTool,
|
||||
zoom,
|
||||
onZoomChange,
|
||||
annotationCounts,
|
||||
disabled = false,
|
||||
}: AnnotationToolbarProps) {
|
||||
const handleToolClick = (type: AnnotationType) => {
|
||||
if (disabled) return
|
||||
onSelectTool(selectedTool === type ? null : type)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-3 border-b border-slate-200 flex items-center justify-between bg-slate-50">
|
||||
{/* Annotation tools */}
|
||||
<div className="flex items-center gap-1">
|
||||
<span className="text-xs text-slate-500 mr-2">Markieren:</span>
|
||||
{ANNOTATION_TOOLS.map(({ type, label, shortcut }) => {
|
||||
const isSelected = selectedTool === type
|
||||
const count = annotationCounts[type] || 0
|
||||
const color = ANNOTATION_COLORS[type]
|
||||
|
||||
return (
|
||||
<button
|
||||
key={type}
|
||||
onClick={() => handleToolClick(type)}
|
||||
disabled={disabled}
|
||||
className={`
|
||||
relative px-2 py-1.5 text-xs rounded border-2 transition-all
|
||||
${disabled ? 'opacity-50 cursor-not-allowed' : 'hover:opacity-80'}
|
||||
${isSelected ? 'ring-2 ring-offset-1 ring-slate-400' : ''}
|
||||
`}
|
||||
style={{
|
||||
borderColor: color,
|
||||
color: isSelected ? 'white' : color,
|
||||
backgroundColor: isSelected ? color : 'transparent',
|
||||
}}
|
||||
title={`${label} (${shortcut})`}
|
||||
>
|
||||
<span className="font-medium">{shortcut}</span>
|
||||
{count > 0 && (
|
||||
<span
|
||||
className="absolute -top-2 -right-2 w-4 h-4 text-[10px] rounded-full flex items-center justify-center text-white"
|
||||
style={{ backgroundColor: color }}
|
||||
>
|
||||
{count > 99 ? '99+' : count}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
|
||||
{/* Clear selection button */}
|
||||
{selectedTool && (
|
||||
<button
|
||||
onClick={() => onSelectTool(null)}
|
||||
className="ml-2 px-2 py-1 text-xs text-slate-500 hover:text-slate-700 hover:bg-slate-200 rounded"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Mode indicator */}
|
||||
{selectedTool && (
|
||||
<div
|
||||
className="px-3 py-1 text-xs rounded-full text-white"
|
||||
style={{ backgroundColor: ANNOTATION_COLORS[selectedTool] }}
|
||||
>
|
||||
{ANNOTATION_TOOLS.find((t) => t.type === selectedTool)?.label || selectedTool}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Zoom controls */}
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => onZoomChange(Math.max(50, zoom - 10))}
|
||||
disabled={zoom <= 50}
|
||||
className="p-1 rounded hover:bg-slate-200 disabled:opacity-50"
|
||||
title="Verkleinern"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M20 12H4" />
|
||||
</svg>
|
||||
</button>
|
||||
<span className="text-sm w-12 text-center">{zoom}%</span>
|
||||
<button
|
||||
onClick={() => onZoomChange(Math.min(200, zoom + 10))}
|
||||
disabled={zoom >= 200}
|
||||
className="p-1 rounded hover:bg-slate-200 disabled:opacity-50"
|
||||
title="Vergroessern"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => onZoomChange(100)}
|
||||
className="px-2 py-1 text-xs rounded hover:bg-slate-200"
|
||||
title="Zuruecksetzen"
|
||||
>
|
||||
Fit
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,279 @@
|
||||
'use client'
|
||||
|
||||
/**
|
||||
* EHSuggestionPanel
|
||||
*
|
||||
* Panel for displaying Erwartungshorizont-based suggestions.
|
||||
* Uses RAG to find relevant passages from the linked EH.
|
||||
*/
|
||||
|
||||
import { useState, useCallback } from 'react'
|
||||
import type { AnnotationType } from '../types'
|
||||
import { ANNOTATION_COLORS } from '../types'
|
||||
|
||||
interface EHSuggestion {
|
||||
id: string
|
||||
eh_id: string
|
||||
eh_title: string
|
||||
text: string
|
||||
score: number
|
||||
criterion: string
|
||||
source_chunk_index: number
|
||||
decrypted: boolean
|
||||
}
|
||||
|
||||
interface EHSuggestionPanelProps {
|
||||
studentId: string
|
||||
klausurId: string
|
||||
hasEH: boolean
|
||||
apiBase: string
|
||||
onInsertSuggestion?: (text: string, criterion: string) => void
|
||||
}
|
||||
|
||||
const CRITERIA = [
|
||||
{ id: 'allgemein', label: 'Alle Kriterien' },
|
||||
{ id: 'inhalt', label: 'Inhalt', color: '#16a34a' },
|
||||
{ id: 'struktur', label: 'Struktur', color: '#9333ea' },
|
||||
{ id: 'stil', label: 'Stil', color: '#ea580c' },
|
||||
]
|
||||
|
||||
export default function EHSuggestionPanel({
|
||||
studentId,
|
||||
klausurId,
|
||||
hasEH,
|
||||
apiBase,
|
||||
onInsertSuggestion,
|
||||
}: EHSuggestionPanelProps) {
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [suggestions, setSuggestions] = useState<EHSuggestion[]>([])
|
||||
const [selectedCriterion, setSelectedCriterion] = useState<string>('allgemein')
|
||||
const [passphrase, setPassphrase] = useState('')
|
||||
const [needsPassphrase, setNeedsPassphrase] = useState(false)
|
||||
const [queryPreview, setQueryPreview] = useState<string | null>(null)
|
||||
|
||||
const fetchSuggestions = useCallback(async () => {
|
||||
try {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
|
||||
const res = await fetch(`${apiBase}/api/v1/students/${studentId}/eh-suggestions`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
criterion: selectedCriterion === 'allgemein' ? null : selectedCriterion,
|
||||
passphrase: passphrase || null,
|
||||
limit: 5,
|
||||
}),
|
||||
})
|
||||
|
||||
if (!res.ok) {
|
||||
const data = await res.json()
|
||||
throw new Error(data.detail || 'Fehler beim Laden der Vorschlaege')
|
||||
}
|
||||
|
||||
const data = await res.json()
|
||||
|
||||
if (data.needs_passphrase) {
|
||||
setNeedsPassphrase(true)
|
||||
setSuggestions([])
|
||||
setError(data.message)
|
||||
} else {
|
||||
setNeedsPassphrase(false)
|
||||
setSuggestions(data.suggestions || [])
|
||||
setQueryPreview(data.query_preview || null)
|
||||
|
||||
if (data.suggestions?.length === 0) {
|
||||
setError(data.message || 'Keine passenden Vorschlaege gefunden')
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch EH suggestions:', err)
|
||||
setError(err instanceof Error ? err.message : 'Unbekannter Fehler')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [apiBase, studentId, selectedCriterion, passphrase])
|
||||
|
||||
const handleInsert = (suggestion: EHSuggestion) => {
|
||||
if (onInsertSuggestion) {
|
||||
onInsertSuggestion(suggestion.text, suggestion.criterion)
|
||||
}
|
||||
}
|
||||
|
||||
if (!hasEH) {
|
||||
return (
|
||||
<div className="p-4 text-center">
|
||||
<div className="text-slate-400 mb-4">
|
||||
<svg className="w-12 h-12 mx-auto mb-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={1.5}
|
||||
d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"
|
||||
/>
|
||||
</svg>
|
||||
<p className="text-sm">Kein Erwartungshorizont verknuepft</p>
|
||||
<p className="text-xs mt-1">Laden Sie einen EH in der RAG-Verwaltung hoch</p>
|
||||
</div>
|
||||
<a
|
||||
href="/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">"{queryPreview}"</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Error message */}
|
||||
{error && !needsPassphrase && (
|
||||
<div className="p-3 bg-red-50 border-b border-red-200">
|
||||
<p className="text-sm text-red-700">{error}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Suggestions list */}
|
||||
<div className="flex-1 overflow-auto">
|
||||
{suggestions.length === 0 && !loading && !error && (
|
||||
<div className="p-4 text-center text-slate-400 text-sm">
|
||||
Klicken Sie auf "EH-Vorschlaege laden" um passende Stellen aus dem Erwartungshorizont zu
|
||||
finden.
|
||||
</div>
|
||||
)}
|
||||
|
||||
{suggestions.map((suggestion, idx) => (
|
||||
<div
|
||||
key={suggestion.id}
|
||||
className="p-3 border-b border-slate-100 hover:bg-slate-50 transition-colors"
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs font-medium text-slate-500">#{idx + 1}</span>
|
||||
<span
|
||||
className="px-1.5 py-0.5 text-[10px] rounded text-white"
|
||||
style={{
|
||||
backgroundColor:
|
||||
ANNOTATION_COLORS[suggestion.criterion as AnnotationType] || '#6366f1',
|
||||
}}
|
||||
>
|
||||
{suggestion.criterion}
|
||||
</span>
|
||||
<span className="text-[10px] text-slate-400">
|
||||
Relevanz: {Math.round(suggestion.score * 100)}%
|
||||
</span>
|
||||
</div>
|
||||
{!suggestion.decrypted && (
|
||||
<span className="text-[10px] text-yellow-600">Verschluesselt</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<p className="text-sm text-slate-700 mb-2 line-clamp-4">{suggestion.text}</p>
|
||||
|
||||
{/* Source */}
|
||||
<div className="flex items-center justify-between text-[10px] text-slate-400">
|
||||
<span>Quelle: {suggestion.eh_title}</span>
|
||||
{onInsertSuggestion && suggestion.decrypted && (
|
||||
<button
|
||||
onClick={() => handleInsert(suggestion)}
|
||||
className="px-2 py-1 bg-primary-100 text-primary-700 rounded hover:bg-primary-200"
|
||||
>
|
||||
Im Gutachten verwenden
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
1249
website/app/lehrer/klausur-korrektur/page.tsx
Normal file
1249
website/app/lehrer/klausur-korrektur/page.tsx
Normal file
File diff suppressed because it is too large
Load Diff
195
website/app/lehrer/klausur-korrektur/types.ts
Normal file
195
website/app/lehrer/klausur-korrektur/types.ts
Normal 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',
|
||||
}
|
||||
19
website/app/lehrer/layout.tsx
Normal file
19
website/app/lehrer/layout.tsx
Normal 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
136
website/app/lehrer/page.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user