'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 { DEFAULT_CRITERIA, ANNOTATION_COLORS, getGradeLabel } from '../../types'
// =============================================================================
// GLASS CARD
// =============================================================================
interface GlassCardProps {
children: React.ReactNode
className?: string
delay?: number
isDark?: boolean
}
function GlassCard({ children, className = '', delay = 0, isDark = true }: GlassCardProps) {
const [isVisible, setIsVisible] = useState(false)
useEffect(() => {
const timer = setTimeout(() => setIsVisible(true), delay)
return () => clearTimeout(timer)
}, [delay])
return (
{children}
)
}
// =============================================================================
// HISTOGRAM
// =============================================================================
interface HistogramProps {
students: StudentWork[]
className?: string
isDark?: boolean
}
function Histogram({ students, className = '', isDark = true }: HistogramProps) {
// Group students by grade points (0-15)
const distribution = useMemo(() => {
const counts: Record = {}
for (let i = 0; i <= 15; i++) {
counts[i] = 0
}
for (const student of students) {
if (student.grade_points !== undefined) {
counts[student.grade_points] = (counts[student.grade_points] || 0) + 1
}
}
return counts
}, [students])
const maxCount = Math.max(...Object.values(distribution), 1)
return (
Notenverteilung
{Array.from({ length: 16 }, (_, i) => 15 - i).map((grade) => {
const count = distribution[grade] || 0
const height = (count / maxCount) * 100
// Color based on grade
let color = '#22c55e' // Green for good grades
if (grade <= 4) color = '#ef4444' // Red for poor grades
else if (grade <= 9) color = '#f97316' // Orange for medium grades
return (
{count || ''}
0 ? '8px' : '0',
backgroundColor: color,
}}
title={`${grade} Punkte (${getGradeLabel(grade)}): ${count} Schueler`}
/>
{grade}
)
})}
Punkte
)
}
// =============================================================================
// CRITERIA HEATMAP
// =============================================================================
interface CriteriaHeatmapProps {
students: StudentWork[]
className?: string
isDark?: boolean
}
function CriteriaHeatmap({ students, className = '', isDark = true }: CriteriaHeatmapProps) {
// Calculate average for each criterion
const criteriaAverages = useMemo(() => {
const sums: Record
= {}
for (const criterion of Object.keys(DEFAULT_CRITERIA)) {
sums[criterion] = { sum: 0, count: 0 }
}
for (const student of students) {
if (student.criteria_scores) {
for (const [criterion, score] of Object.entries(student.criteria_scores)) {
if (score !== undefined && sums[criterion]) {
sums[criterion].sum += score
sums[criterion].count += 1
}
}
}
}
const averages: Record = {}
for (const [criterion, data] of Object.entries(sums)) {
averages[criterion] = data.count > 0 ? Math.round(data.sum / data.count) : 0
}
return averages
}, [students])
return (
Kriterien-Durchschnitt
{Object.entries(DEFAULT_CRITERIA).map(([criterion, config]) => {
const average = criteriaAverages[criterion] || 0
const color = ANNOTATION_COLORS[criterion as keyof typeof ANNOTATION_COLORS] || '#6b7280'
return (
)
})}
)
}
// =============================================================================
// OUTLIER LIST
// =============================================================================
interface OutlierListProps {
fairness: FairnessAnalysis | null
onStudentClick: (studentId: string) => void
className?: string
isDark?: boolean
}
function OutlierList({ fairness, onStudentClick, className = '', isDark = true }: OutlierListProps) {
if (!fairness || fairness.outliers.length === 0) {
return (
Keine Ausreisser erkannt
Alle Bewertungen sind konsistent
)
}
return (
Ausreisser ({fairness.outliers.length})
{fairness.outliers.map((outlier) => (
onStudentClick(outlier.student_id)}
className={`w-full p-3 rounded-xl border transition-colors text-left ${isDark ? 'bg-white/5 border-white/10 hover:bg-white/10' : 'bg-slate-100 border-slate-200 hover:bg-slate-200'}`}
>
{outlier.anonym_id}
0
? 'bg-green-500/20 text-green-400'
: 'bg-red-500/20 text-red-400'
}`}
>
{outlier.deviation > 0 ? '+' : ''}{outlier.deviation.toFixed(1)} Punkte
{outlier.grade_points} Punkte ({getGradeLabel(outlier.grade_points)})
{outlier.reason}
))}
)
}
// =============================================================================
// FAIRNESS SCORE
// =============================================================================
interface FairnessScoreProps {
fairness: FairnessAnalysis | null
className?: string
isDark?: boolean
}
function FairnessScore({ fairness, className = '', isDark = true }: FairnessScoreProps) {
const score = fairness?.fairness_score || 0
const percentage = Math.round(score * 100)
let color = '#22c55e' // Green
let label = 'Ausgezeichnet'
if (percentage < 70) {
color = '#ef4444'
label = 'Ueberpruefung empfohlen'
} else if (percentage < 85) {
color = '#f97316'
label = 'Akzeptabel'
} else if (percentage < 95) {
color = '#22c55e'
label = 'Gut'
}
// SVG ring
const size = 120
const strokeWidth = 10
const radius = (size - strokeWidth) / 2
const circumference = radius * 2 * Math.PI
const offset = circumference - (percentage / 100) * circumference
return (
)
}
// =============================================================================
// MAIN PAGE
// =============================================================================
export default function FairnessPage() {
const { isDark } = useTheme()
const router = useRouter()
const params = useParams()
const klausurId = params.klausurId as string
// State
const [klausur, setKlausur] = useState(null)
const [students, setStudents] = useState([])
const [fairness, setFairness] = useState(null)
const [isLoading, setIsLoading] = useState(true)
const [error, setError] = useState(null)
// Load data
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])
// Calculated stats
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 (
{/* Animated Background Blobs */}
{/* Sidebar */}
{/* Main Content */}
{/* Header */}
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'}`}
>
Fairness-Analyse
{klausur?.title}
{/* Error Display */}
{error && (
)}
{/* Loading */}
{isLoading && (
)}
{/* Content */}
{!isLoading && fairness && (
<>
{/* Stats Row */}
Arbeiten
{stats?.studentCount}
Durchschnitt
{stats?.average.toFixed(1)} P
Standardabw.
{stats?.stdDev.toFixed(2)}
Spannweite
{stats?.spread} P
Ausreisser
{stats?.outlierCount}
Warnungen
{stats?.warningCount}
{/* Warnings */}
{fairness.warnings.length > 0 && (
Warnungen
{fairness.warnings.map((warning, index) => (
-
{warning}
))}
)}
{/* Main Grid */}
{/* Fairness Score */}
{/* Histogram */}
{/* Criteria Heatmap */}
{/* Outlier List */}
router.push(`/korrektur/${klausurId}/${studentId}`)
}
isDark={isDark}
/>
>
)}
{/* No Data */}
{!isLoading && !fairness && !error && (
Keine Daten verfuegbar
Die Fairness-Analyse erfordert korrigierte Arbeiten.
)}
)
}