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>
126 lines
9.5 KiB
TypeScript
126 lines
9.5 KiB
TypeScript
'use client'
|
|
|
|
import { useState, useEffect, useCallback, useMemo } 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 { korrekturApi } from '@/lib/korrektur/api'
|
|
import type { Klausur, StudentWork, FairnessAnalysis } from '../../types'
|
|
import { GlassCard, Histogram, CriteriaHeatmap, OutlierList, FairnessScore } from './_components/FairnessCharts'
|
|
|
|
export default function FairnessPage() {
|
|
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 [fairness, setFairness] = useState<FairnessAnalysis | null>(null)
|
|
const [isLoading, setIsLoading] = useState(true)
|
|
const [error, setError] = useState<string | null>(null)
|
|
|
|
const loadData = useCallback(async () => {
|
|
if (!klausurId) return
|
|
setIsLoading(true)
|
|
setError(null)
|
|
try {
|
|
const [klausurData, studentsData, fairnessData] = await Promise.all([
|
|
korrekturApi.getKlausur(klausurId),
|
|
korrekturApi.getStudents(klausurId),
|
|
korrekturApi.getFairnessAnalysis(klausurId),
|
|
])
|
|
setKlausur(klausurData)
|
|
setStudents(studentsData)
|
|
setFairness(fairnessData)
|
|
} 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 stats = useMemo(() => {
|
|
if (!fairness) return null
|
|
return {
|
|
studentCount: fairness.student_count, average: fairness.average_grade,
|
|
stdDev: fairness.std_deviation, spread: fairness.spread,
|
|
outlierCount: fairness.outliers.length, warningCount: fairness.warnings.length,
|
|
}
|
|
}, [fairness])
|
|
|
|
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/${klausurId}`)} 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'}`}>Fairness-Analyse</h1>
|
|
<p className={isDark ? 'text-white/50' : 'text-slate-500'}>{klausur?.title}</p>
|
|
</div>
|
|
</div>
|
|
<div className="flex items-center gap-3">
|
|
<a href={korrekturApi.getOverviewExportUrl(klausurId)} target="_blank" rel="noopener noreferrer" className={`px-4 py-2 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-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" /></svg>PDF Export
|
|
</a>
|
|
<ThemeToggle /><LanguageDropdown />
|
|
</div>
|
|
</div>
|
|
|
|
{error && (<GlassCard className="mb-6" 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 && fairness && (
|
|
<>
|
|
<div className="grid grid-cols-2 md:grid-cols-4 lg:grid-cols-6 gap-4 mb-6">
|
|
<GlassCard delay={100} isDark={isDark}><p className={`text-xs mb-1 ${isDark ? 'text-white/50' : 'text-slate-500'}`}>Arbeiten</p><p className={`text-2xl font-bold ${isDark ? 'text-white' : 'text-slate-900'}`}>{stats?.studentCount}</p></GlassCard>
|
|
<GlassCard delay={150} isDark={isDark}><p className={`text-xs mb-1 ${isDark ? 'text-white/50' : 'text-slate-500'}`}>Durchschnitt</p><p className={`text-2xl font-bold ${isDark ? 'text-white' : 'text-slate-900'}`}>{stats?.average.toFixed(1)} P</p></GlassCard>
|
|
<GlassCard delay={200} isDark={isDark}><p className={`text-xs mb-1 ${isDark ? 'text-white/50' : 'text-slate-500'}`}>Standardabw.</p><p className={`text-2xl font-bold ${isDark ? 'text-white' : 'text-slate-900'}`}>{stats?.stdDev.toFixed(2)}</p></GlassCard>
|
|
<GlassCard delay={250} isDark={isDark}><p className={`text-xs mb-1 ${isDark ? 'text-white/50' : 'text-slate-500'}`}>Spannweite</p><p className={`text-2xl font-bold ${isDark ? 'text-white' : 'text-slate-900'}`}>{stats?.spread} P</p></GlassCard>
|
|
<GlassCard delay={300} isDark={isDark}><p className={`text-xs mb-1 ${isDark ? 'text-white/50' : 'text-slate-500'}`}>Ausreisser</p><p className={`text-2xl font-bold ${stats?.outlierCount ? 'text-amber-400' : 'text-green-400'}`}>{stats?.outlierCount}</p></GlassCard>
|
|
<GlassCard delay={350} isDark={isDark}><p className={`text-xs mb-1 ${isDark ? 'text-white/50' : 'text-slate-500'}`}>Warnungen</p><p className={`text-2xl font-bold ${stats?.warningCount ? 'text-red-400' : 'text-green-400'}`}>{stats?.warningCount}</p></GlassCard>
|
|
</div>
|
|
|
|
{fairness.warnings.length > 0 && (
|
|
<GlassCard className="mb-6" delay={400} isDark={isDark}>
|
|
<h3 className={`font-semibold mb-3 flex items-center gap-2 ${isDark ? 'text-white' : 'text-slate-900'}`}><svg className="w-5 h-5 text-amber-400" 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>Warnungen</h3>
|
|
<ul className="space-y-2">{fairness.warnings.map((warning, index) => (<li key={index} className={`text-sm flex items-start gap-2 ${isDark ? 'text-white/70' : 'text-slate-600'}`}><span className="text-amber-400 mt-1">-</span>{warning}</li>))}</ul>
|
|
</GlassCard>
|
|
)}
|
|
|
|
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
|
<GlassCard delay={450} isDark={isDark}><FairnessScore fairness={fairness} isDark={isDark} /></GlassCard>
|
|
<GlassCard className="lg:col-span-2" delay={500} isDark={isDark}><Histogram students={students} isDark={isDark} /></GlassCard>
|
|
<GlassCard delay={550} isDark={isDark}><CriteriaHeatmap students={students} isDark={isDark} /></GlassCard>
|
|
<GlassCard className="lg:col-span-2" delay={600} isDark={isDark}><OutlierList fairness={fairness} onStudentClick={(studentId) => router.push(`/korrektur/${klausurId}/${studentId}`)} isDark={isDark} /></GlassCard>
|
|
</div>
|
|
</>
|
|
)}
|
|
|
|
{!isLoading && !fairness && !error && (
|
|
<GlassCard className="text-center py-12" isDark={isDark}>
|
|
<div className={`w-16 h-16 rounded-2xl flex items-center justify-center mx-auto mb-4 ${isDark ? 'bg-white/10' : 'bg-slate-200'}`}><svg className={`w-8 h-8 ${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 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></div>
|
|
<h3 className={`text-xl font-semibold mb-2 ${isDark ? 'text-white' : 'text-slate-900'}`}>Keine Daten verfuegbar</h3>
|
|
<p className={isDark ? 'text-white/50' : 'text-slate-500'}>Die Fairness-Analyse erfordert korrigierte Arbeiten.</p>
|
|
</GlassCard>
|
|
)}
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|