[split-required] Split remaining 500-680 LOC files (final batch)

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>
This commit is contained in:
Benjamin Admin
2026-04-25 08:56:45 +02:00
parent b4613e26f3
commit 451365a312
115 changed files with 10694 additions and 13839 deletions

View File

@@ -0,0 +1,202 @@
'use client'
import { useState, useEffect, useMemo } from 'react'
import type { 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
}
export 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 (
<div
className={`rounded-3xl p-5 ${className}`}
style={{
background: isDark ? 'rgba(255, 255, 255, 0.08)' : 'rgba(255, 255, 255, 0.7)',
backdropFilter: 'blur(24px) saturate(180%)',
WebkitBackdropFilter: 'blur(24px) saturate(180%)',
border: isDark ? '1px solid rgba(255, 255, 255, 0.1)' : '1px solid rgba(0, 0, 0, 0.1)',
boxShadow: isDark
? '0 4px 24px rgba(0, 0, 0, 0.15), inset 0 1px 0 rgba(255, 255, 255, 0.05)'
: '0 4px 24px rgba(0, 0, 0, 0.08), inset 0 1px 0 rgba(255, 255, 255, 0.5)',
opacity: isVisible ? 1 : 0,
transform: isVisible ? 'translateY(0)' : 'translateY(20px)',
transition: 'all 0.4s cubic-bezier(0.34, 1.56, 0.64, 1)',
}}
>
{children}
</div>
)
}
// =============================================================================
// HISTOGRAM
// =============================================================================
export function Histogram({ students, className = '', isDark = true }: { students: StudentWork[]; className?: string; isDark?: boolean }) {
const distribution = useMemo(() => {
const counts: Record<number, number> = {}
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 (
<div className={className}>
<h3 className={`font-semibold mb-4 ${isDark ? 'text-white' : 'text-slate-900'}`}>Notenverteilung</h3>
<div className="flex items-end gap-1 h-40">
{Array.from({ length: 16 }, (_, i) => 15 - i).map((grade) => {
const count = distribution[grade] || 0
const height = (count / maxCount) * 100
let color = '#22c55e'
if (grade <= 4) color = '#ef4444'
else if (grade <= 9) color = '#f97316'
return (
<div key={grade} className="flex-1 flex flex-col items-center gap-1">
<span className={`text-xs ${isDark ? 'text-white/40' : 'text-slate-400'}`}>{count || ''}</span>
<div className="w-full rounded-t transition-all hover:opacity-80" style={{ height: `${height}%`, minHeight: count > 0 ? '8px' : '0', backgroundColor: color }} title={`${grade} Punkte (${getGradeLabel(grade)}): ${count} Schueler`} />
<span className={`text-xs ${isDark ? 'text-white/50' : 'text-slate-500'}`}>{grade}</span>
</div>
)
})}
</div>
<p className={`text-xs text-center mt-2 ${isDark ? 'text-white/40' : 'text-slate-400'}`}>Punkte</p>
</div>
)
}
// =============================================================================
// CRITERIA HEATMAP
// =============================================================================
export function CriteriaHeatmap({ students, className = '', isDark = true }: { students: StudentWork[]; className?: string; isDark?: boolean }) {
const criteriaAverages = useMemo(() => {
const sums: Record<string, { sum: number; count: number }> = {}
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<string, number> = {}
for (const [criterion, data] of Object.entries(sums)) averages[criterion] = data.count > 0 ? Math.round(data.sum / data.count) : 0
return averages
}, [students])
return (
<div className={className}>
<h3 className={`font-semibold mb-4 ${isDark ? 'text-white' : 'text-slate-900'}`}>Kriterien-Durchschnitt</h3>
<div className="space-y-3">
{Object.entries(DEFAULT_CRITERIA).map(([criterion, config]) => {
const average = criteriaAverages[criterion] || 0
const color = ANNOTATION_COLORS[criterion as keyof typeof ANNOTATION_COLORS] || '#6b7280'
return (
<div key={criterion} className="space-y-1">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2"><div className="w-3 h-3 rounded-full" style={{ backgroundColor: color }} /><span className={`text-sm ${isDark ? 'text-white/70' : 'text-slate-600'}`}>{config.name}</span></div>
<span className={`text-sm font-medium ${isDark ? 'text-white' : 'text-slate-900'}`}>{average}%</span>
</div>
<div className={`h-2 rounded-full overflow-hidden ${isDark ? 'bg-white/10' : 'bg-slate-200'}`}><div className="h-full rounded-full transition-all" style={{ width: `${average}%`, backgroundColor: color }} /></div>
</div>
)
})}
</div>
</div>
)
}
// =============================================================================
// OUTLIER LIST
// =============================================================================
export function OutlierList({ fairness, onStudentClick, className = '', isDark = true }: { fairness: FairnessAnalysis | null; onStudentClick: (id: string) => void; className?: string; isDark?: boolean }) {
if (!fairness || fairness.outliers.length === 0) {
return (
<div className={`text-center py-8 ${className}`}>
<div className="w-12 h-12 rounded-full bg-green-500/20 flex items-center justify-center mx-auto mb-3"><svg className="w-6 h-6 text-green-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" /></svg></div>
<p className={`text-sm ${isDark ? 'text-white/60' : 'text-slate-600'}`}>Keine Ausreisser erkannt</p>
<p className={`text-xs mt-1 ${isDark ? 'text-white/40' : 'text-slate-400'}`}>Alle Bewertungen sind konsistent</p>
</div>
)
}
return (
<div className={className}>
<h3 className={`font-semibold mb-4 ${isDark ? 'text-white' : 'text-slate-900'}`}>Ausreisser ({fairness.outliers.length})</h3>
<div className="space-y-2 max-h-64 overflow-y-auto">
{fairness.outliers.map((outlier) => (
<button key={outlier.student_id} onClick={() => 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'}`}>
<div className="flex items-center justify-between mb-1">
<span className={`font-medium ${isDark ? 'text-white' : 'text-slate-900'}`}>{outlier.anonym_id}</span>
<span className={`px-2 py-0.5 rounded-full text-xs font-medium ${outlier.deviation > 0 ? 'bg-green-500/20 text-green-400' : 'bg-red-500/20 text-red-400'}`}>{outlier.deviation > 0 ? '+' : ''}{outlier.deviation.toFixed(1)} Punkte</span>
</div>
<div className="flex items-center justify-between">
<span className={`text-sm ${isDark ? 'text-white/50' : 'text-slate-500'}`}>{outlier.grade_points} Punkte ({getGradeLabel(outlier.grade_points)})</span>
<span className={`text-xs ${isDark ? 'text-white/40' : 'text-slate-400'}`}>{outlier.reason}</span>
</div>
</button>
))}
</div>
</div>
)
}
// =============================================================================
// FAIRNESS SCORE
// =============================================================================
export function FairnessScore({ fairness, className = '', isDark = true }: { fairness: FairnessAnalysis | null; className?: string; isDark?: boolean }) {
const score = fairness?.fairness_score || 0
const percentage = Math.round(score * 100)
let color = '#22c55e'
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' }
const size = 120
const strokeWidth = 10
const radius = (size - strokeWidth) / 2
const circumference = radius * 2 * Math.PI
const offset = circumference - (percentage / 100) * circumference
return (
<div className={`text-center ${className}`}>
<div className="relative inline-block" style={{ width: size, height: size }}>
<svg className="transform -rotate-90" width={size} height={size}>
<circle cx={size / 2} cy={size / 2} r={radius} fill="none" stroke={isDark ? 'rgba(255, 255, 255, 0.1)' : 'rgba(0, 0, 0, 0.1)'} strokeWidth={strokeWidth} />
<circle cx={size / 2} cy={size / 2} r={radius} fill="none" stroke={color} strokeWidth={strokeWidth} strokeDasharray={circumference} strokeDashoffset={offset} strokeLinecap="round" style={{ transition: 'stroke-dashoffset 1s ease-out' }} />
</svg>
<div className="absolute inset-0 flex flex-col items-center justify-center">
<span className={`text-3xl font-bold ${isDark ? 'text-white' : 'text-slate-900'}`}>{percentage}</span>
<span className={`text-xs ${isDark ? 'text-white/40' : 'text-slate-400'}`}>%</span>
</div>
</div>
<p className={`font-medium mt-3 ${isDark ? 'text-white' : 'text-slate-900'}`}>{label}</p>
<p className={`text-xs mt-1 ${isDark ? 'text-white/40' : 'text-slate-400'}`}>Fairness-Score</p>
</div>
)
}