[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:
@@ -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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user