website (17 pages + 3 components): - multiplayer/wizard, middleware/wizard+test-wizard, communication - builds/wizard, staff-search, voice, sbom/wizard - foerderantrag, mail/tasks, tools/communication, sbom - compliance/evidence, uni-crawler, brandbook (already done) - CollectionsTab, IngestionTab, RiskHeatmap backend-lehrer (5 files): - letters_api (641 → 2), certificates_api (636 → 2) - alerts_agent/db/models (636 → 3) - llm_gateway/communication_service (614 → 2) - game/database already done in prior batch klausur-service (2 files): - hybrid_vocab_extractor (664 → 2) - klausur-service/frontend: api.ts (620 → 3), EHUploadWizard (591 → 2) voice-service (3 files): - bqas/rag_judge (618 → 3), runner (529 → 2) - enhanced_task_orchestrator (519 → 2) studio-v2 (6 files): - korrektur/[klausurId] (578 → 4), fairness (569 → 2) - AlertsWizard (552 → 2), OnboardingWizard (513 → 2) - korrektur/api.ts (506 → 3), geo-lernwelt (501 → 2) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
188 lines
12 KiB
TypeScript
188 lines
12 KiB
TypeScript
'use client'
|
|
|
|
import { useState, useEffect, useCallback } from 'react'
|
|
import { useRouter, useParams } from 'next/navigation'
|
|
import { useTheme } from '@/lib/ThemeContext'
|
|
import { Sidebar } from '@/components/Sidebar'
|
|
import { ThemeToggle } from '@/components/ThemeToggle'
|
|
import { LanguageDropdown } from '@/components/LanguageDropdown'
|
|
import { QRCodeUpload } from '@/components/QRCodeUpload'
|
|
import { korrekturApi } from '@/lib/korrektur/api'
|
|
import type { Klausur, StudentWork } from '../types'
|
|
import { GlassCard } from './_components/GlassCard'
|
|
import { StudentCard } from './_components/StudentCard'
|
|
import { UploadModal } from './_components/UploadModal'
|
|
|
|
const SESSION_ID_KEY = 'bp_korrektur_student_session'
|
|
|
|
export default function KlausurDetailPage() {
|
|
const { isDark } = useTheme()
|
|
const router = useRouter()
|
|
const params = useParams()
|
|
const klausurId = params.klausurId as string
|
|
|
|
const [klausur, setKlausur] = useState<Klausur | null>(null)
|
|
const [students, setStudents] = useState<StudentWork[]>([])
|
|
const [isLoading, setIsLoading] = useState(true)
|
|
const [error, setError] = useState<string | null>(null)
|
|
const [showUploadModal, setShowUploadModal] = useState(false)
|
|
const [showQRModal, setShowQRModal] = useState(false)
|
|
const [isUploading, setIsUploading] = useState(false)
|
|
const [uploadSessionId, setUploadSessionId] = useState('')
|
|
|
|
useEffect(() => {
|
|
let storedSessionId = localStorage.getItem(SESSION_ID_KEY)
|
|
if (!storedSessionId) {
|
|
storedSessionId = `student-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`
|
|
localStorage.setItem(SESSION_ID_KEY, storedSessionId)
|
|
}
|
|
setUploadSessionId(storedSessionId)
|
|
}, [])
|
|
|
|
const loadData = useCallback(async () => {
|
|
if (!klausurId) return
|
|
setIsLoading(true)
|
|
setError(null)
|
|
try {
|
|
const [klausurData, studentsData] = await Promise.all([
|
|
korrekturApi.getKlausur(klausurId),
|
|
korrekturApi.getStudents(klausurId),
|
|
])
|
|
setKlausur(klausurData)
|
|
setStudents(studentsData)
|
|
} catch (err) {
|
|
console.error('Failed to load data:', err)
|
|
setError(err instanceof Error ? err.message : 'Laden fehlgeschlagen')
|
|
} finally {
|
|
setIsLoading(false)
|
|
}
|
|
}, [klausurId])
|
|
|
|
useEffect(() => { loadData() }, [loadData])
|
|
|
|
const handleUpload = async (files: File[], anonymIds: string[]) => {
|
|
setIsUploading(true)
|
|
try {
|
|
for (let i = 0; i < files.length; i++) {
|
|
await korrekturApi.uploadStudentWork(klausurId, files[i], anonymIds[i])
|
|
}
|
|
setShowUploadModal(false)
|
|
loadData()
|
|
} catch (err) {
|
|
console.error('Upload failed:', err)
|
|
setError(err instanceof Error ? err.message : 'Upload fehlgeschlagen')
|
|
} finally {
|
|
setIsUploading(false)
|
|
}
|
|
}
|
|
|
|
const completedCount = students.filter(s => s.status === 'COMPLETED').length
|
|
const progress = students.length > 0 ? Math.round((completedCount / students.length) * 100) : 0
|
|
|
|
return (
|
|
<div className={`min-h-screen flex relative overflow-hidden ${isDark ? 'bg-gradient-to-br from-indigo-900 via-purple-900 to-pink-800' : 'bg-gradient-to-br from-slate-100 via-blue-50 to-indigo-100'}`}>
|
|
<div className={`absolute -top-40 -right-40 w-96 h-96 rounded-full mix-blend-multiply filter blur-3xl animate-blob ${isDark ? 'bg-purple-500 opacity-30' : 'bg-purple-300 opacity-40'}`} />
|
|
<div className={`absolute top-1/2 -left-40 w-96 h-96 rounded-full mix-blend-multiply filter blur-3xl animate-blob animation-delay-2000 ${isDark ? 'bg-pink-500 opacity-30' : 'bg-pink-300 opacity-40'}`} />
|
|
<div className={`absolute -bottom-40 right-1/3 w-96 h-96 rounded-full mix-blend-multiply filter blur-3xl animate-blob animation-delay-4000 ${isDark ? 'bg-blue-500 opacity-30' : 'bg-blue-300 opacity-40'}`} />
|
|
|
|
<div className="relative z-10 p-4"><Sidebar /></div>
|
|
|
|
<div className="flex-1 flex flex-col relative z-10 p-6 overflow-y-auto">
|
|
<div className="flex items-center justify-between mb-8">
|
|
<div className="flex items-center gap-4">
|
|
<button onClick={() => router.push('/korrektur')} className={`p-2 rounded-xl transition-colors ${isDark ? 'bg-white/10 hover:bg-white/20 text-white' : 'bg-slate-200 hover:bg-slate-300 text-slate-700'}`}>
|
|
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 19l-7-7m0 0l7-7m-7 7h18" /></svg>
|
|
</button>
|
|
<div>
|
|
<h1 className={`text-3xl font-bold mb-1 ${isDark ? 'text-white' : 'text-slate-900'}`}>{klausur?.title || 'Klausur'}</h1>
|
|
<p className={isDark ? 'text-white/50' : 'text-slate-500'}>{klausur ? `${klausur.subject} ${klausur.semester} ${klausur.year}` : ''}</p>
|
|
</div>
|
|
</div>
|
|
<div className="flex items-center gap-3"><ThemeToggle /><LanguageDropdown /></div>
|
|
</div>
|
|
|
|
{!isLoading && klausur && (
|
|
<div className="grid grid-cols-1 sm:grid-cols-4 gap-4 mb-8">
|
|
<GlassCard size="sm" delay={100} isDark={isDark}><div className="text-center"><p className={`text-3xl font-bold ${isDark ? 'text-white' : 'text-slate-900'}`}>{students.length}</p><p className={`text-sm ${isDark ? 'text-white/50' : 'text-slate-500'}`}>Arbeiten</p></div></GlassCard>
|
|
<GlassCard size="sm" delay={150} isDark={isDark}><div className="text-center"><p className="text-3xl font-bold text-green-400">{completedCount}</p><p className={`text-sm ${isDark ? 'text-white/50' : 'text-slate-500'}`}>Abgeschlossen</p></div></GlassCard>
|
|
<GlassCard size="sm" delay={200} isDark={isDark}><div className="text-center"><p className="text-3xl font-bold text-orange-400">{students.length - completedCount}</p><p className={`text-sm ${isDark ? 'text-white/50' : 'text-slate-500'}`}>Offen</p></div></GlassCard>
|
|
<GlassCard size="sm" delay={250} isDark={isDark}><div className="text-center"><p className="text-3xl font-bold text-purple-400">{progress}%</p><p className={`text-sm ${isDark ? 'text-white/50' : 'text-slate-500'}`}>Fortschritt</p></div></GlassCard>
|
|
</div>
|
|
)}
|
|
|
|
{!isLoading && students.length > 0 && (
|
|
<GlassCard size="sm" className="mb-6" delay={300} isDark={isDark}>
|
|
<div className="flex items-center justify-between text-sm mb-2">
|
|
<span className={isDark ? 'text-white/50' : 'text-slate-500'}>Gesamtfortschritt</span>
|
|
<span className={isDark ? 'text-white' : 'text-slate-900'}>{completedCount}/{students.length} korrigiert</span>
|
|
</div>
|
|
<div className={`h-3 rounded-full overflow-hidden ${isDark ? 'bg-white/10' : 'bg-slate-200'}`}>
|
|
<div className="h-full rounded-full transition-all duration-500 bg-gradient-to-r from-green-500 to-emerald-400" style={{ width: `${progress}%` }} />
|
|
</div>
|
|
</GlassCard>
|
|
)}
|
|
|
|
{error && (
|
|
<GlassCard className="mb-6" size="sm" isDark={isDark}>
|
|
<div className="flex items-center gap-3 text-red-400">
|
|
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" /></svg>
|
|
<span>{error}</span>
|
|
<button onClick={loadData} className={`ml-auto px-3 py-1 rounded-lg transition-colors text-sm ${isDark ? 'bg-white/10 hover:bg-white/20' : 'bg-slate-200 hover:bg-slate-300'}`}>Erneut versuchen</button>
|
|
</div>
|
|
</GlassCard>
|
|
)}
|
|
|
|
{isLoading && (<div className="flex-1 flex items-center justify-center"><div className="w-12 h-12 border-4 border-purple-500 border-t-transparent rounded-full animate-spin" /></div>)}
|
|
|
|
{!isLoading && (
|
|
<div className="flex gap-3 mb-6">
|
|
<button onClick={() => setShowUploadModal(true)} className="px-6 py-3 rounded-xl bg-gradient-to-r from-purple-500 to-pink-500 text-white font-semibold hover:shadow-lg hover:shadow-purple-500/30 transition-all flex items-center gap-2">
|
|
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-8l-4-4m0 0L8 8m4-4v12" /></svg>
|
|
Arbeiten hochladen
|
|
</button>
|
|
<button onClick={() => setShowQRModal(true)} className={`px-6 py-3 rounded-xl transition-colors flex items-center gap-2 ${isDark ? 'bg-white/10 text-white hover:bg-white/20' : 'bg-slate-200 text-slate-700 hover:bg-slate-300'}`}>
|
|
<span className="text-xl">📱</span>QR Upload
|
|
</button>
|
|
{students.length > 0 && (
|
|
<button onClick={() => router.push(`/korrektur/${klausurId}/fairness`)} className={`px-6 py-3 rounded-xl transition-colors flex items-center gap-2 ${isDark ? 'bg-white/10 text-white hover:bg-white/20' : 'bg-slate-200 text-slate-700 hover:bg-slate-300'}`}>
|
|
<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
|
|
</button>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{!isLoading && students.length === 0 && (
|
|
<GlassCard className="text-center py-12" delay={350} isDark={isDark}>
|
|
<div className={`w-20 h-20 mx-auto mb-4 rounded-2xl flex items-center justify-center ${isDark ? 'bg-white/10' : 'bg-slate-200'}`}>
|
|
<svg className={`w-10 h-10 ${isDark ? 'text-white/30' : 'text-slate-400'}`} 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>
|
|
</div>
|
|
<h3 className={`text-xl font-semibold mb-2 ${isDark ? 'text-white' : 'text-slate-900'}`}>Keine Arbeiten vorhanden</h3>
|
|
<p className={`mb-6 ${isDark ? 'text-white/50' : 'text-slate-500'}`}>Laden Sie Schuelerarbeiten hoch, um mit der Korrektur zu beginnen.</p>
|
|
<button onClick={() => setShowUploadModal(true)} className="px-6 py-3 rounded-xl bg-gradient-to-r from-purple-500 to-pink-500 text-white font-semibold hover:shadow-lg hover:shadow-purple-500/30 transition-all">Arbeiten hochladen</button>
|
|
</GlassCard>
|
|
)}
|
|
|
|
{!isLoading && students.length > 0 && (
|
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
|
{students.map((student, index) => (
|
|
<StudentCard key={student.id} student={student} index={index} onClick={() => router.push(`/korrektur/${klausurId}/${student.id}`)} delay={350 + index * 30} isDark={isDark} />
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
<UploadModal isOpen={showUploadModal} onClose={() => setShowUploadModal(false)} onUpload={handleUpload} isUploading={isUploading} />
|
|
|
|
{showQRModal && (
|
|
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
|
|
<div className="absolute inset-0 bg-black/50 backdrop-blur-sm" onClick={() => setShowQRModal(false)} />
|
|
<div className={`relative w-full max-w-md rounded-3xl ${isDark ? 'bg-slate-900' : 'bg-white'}`}>
|
|
<QRCodeUpload sessionId={uploadSessionId} onClose={() => setShowQRModal(false)} onFilesChanged={() => {}} />
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|