Files
breakpilot-lehrer/studio-v2/app/korrektur/page.tsx
Benjamin Admin b6983ab1dc [split-required] Split 500-1000 LOC files across all services
backend-lehrer (5 files):
- alerts_agent/db/repository.py (992 → 5), abitur_docs_api.py (956 → 3)
- teacher_dashboard_api.py (951 → 3), services/pdf_service.py (916 → 3)
- mail/mail_db.py (987 → 6)

klausur-service (5 files):
- legal_templates_ingestion.py (942 → 3), ocr_pipeline_postprocess.py (929 → 4)
- ocr_pipeline_words.py (876 → 3), ocr_pipeline_ocr_merge.py (616 → 2)
- KorrekturPage.tsx (956 → 6)

website (5 pages):
- mail (985 → 9), edu-search (958 → 8), mac-mini (950 → 7)
- ocr-labeling (946 → 7), audit-workspace (871 → 4)

studio-v2 (5 files + 1 deleted):
- page.tsx (946 → 5), MessagesContext.tsx (925 → 4)
- korrektur (914 → 6), worksheet-cleanup (899 → 6)
- useVocabWorksheet.ts (888 → 3)
- Deleted dead page-original.tsx (934 LOC)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-24 23:35:37 +02:00

242 lines
15 KiB
TypeScript

'use client'
import { useState, useEffect, useCallback } from 'react'
import { useRouter } from 'next/navigation'
import { useTheme } from '@/lib/ThemeContext'
import { Sidebar } from '@/components/Sidebar'
import { ThemeToggle } from '@/components/ThemeToggle'
import { LanguageDropdown } from '@/components/LanguageDropdown'
import type { UploadedFile } from '@/components/QRCodeUpload'
import { korrekturApi, getKorrekturStats, type KorrekturStats } from '@/lib/korrektur/api'
import type { Klausur, CreateKlausurData } from './types'
import { GlassCard } from './_components/GlassCard'
import { StatCard } from './_components/StatCard'
import { KlausurCard } from './_components/KlausurCard'
import { CreateKlausurModal } from './_components/CreateKlausurModal'
import { DirectUploadModal, EHUploadModal, QRCodeModal } from './_components/UploadModals'
const SESSION_ID_KEY = 'bp_korrektur_session'
export default function KorrekturPage() {
const { isDark } = useTheme()
const router = useRouter()
const [klausuren, setKlausuren] = useState<Klausur[]>([])
const [stats, setStats] = useState<KorrekturStats | null>(null)
const [isLoading, setIsLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [showCreateModal, setShowCreateModal] = useState(false)
const [isCreating, setIsCreating] = useState(false)
const [showQRModal, setShowQRModal] = useState(false)
const [uploadSessionId, setUploadSessionId] = useState('')
const [showDirectUpload, setShowDirectUpload] = useState(false)
const [showEHUpload, setShowEHUpload] = useState(false)
const [isDragging, setIsDragging] = useState(false)
const [uploadedFiles, setUploadedFiles] = useState<File[]>([])
const [ehFile, setEhFile] = useState<File | null>(null)
const [isUploading, setIsUploading] = useState(false)
useEffect(() => {
let sid = localStorage.getItem(SESSION_ID_KEY)
if (!sid) {
sid = `korrektur-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`
localStorage.setItem(SESSION_ID_KEY, sid)
}
setUploadSessionId(sid)
}, [])
const loadData = useCallback(async () => {
setIsLoading(true); setError(null)
try {
const [kd, sd] = await Promise.all([korrekturApi.getKlausuren(), getKorrekturStats()])
setKlausuren(kd); setStats(sd)
} catch (err) {
setError(err instanceof Error ? err.message : 'Laden fehlgeschlagen')
} finally { setIsLoading(false) }
}, [])
useEffect(() => { loadData() }, [loadData])
const handleCreateKlausur = async (data: CreateKlausurData) => {
setIsCreating(true)
try {
const nk = await korrekturApi.createKlausur(data)
setKlausuren(prev => [nk, ...prev]); setShowCreateModal(false)
router.push(`/korrektur/${nk.id}`)
} catch (err) {
setError(err instanceof Error ? err.message : 'Erstellung fehlgeschlagen')
} finally { setIsCreating(false) }
}
const handleMobileFileSelect = async (_: UploadedFile) => { setShowQRModal(false) }
const handleDragOver = (e: React.DragEvent) => { e.preventDefault(); setIsDragging(true) }
const handleDragLeave = (e: React.DragEvent) => { e.preventDefault(); setIsDragging(false) }
const handleDrop = (e: React.DragEvent, isEH = false) => {
e.preventDefault(); setIsDragging(false)
const files = Array.from(e.dataTransfer.files).filter(f => f.type === 'application/pdf' || f.type.startsWith('image/'))
if (isEH && files.length > 0) setEhFile(files[0])
else setUploadedFiles(prev => [...prev, ...files])
}
const handleFileSelect = (e: React.ChangeEvent<HTMLInputElement>, isEH = false) => {
if (!e.target.files) return
const files = Array.from(e.target.files)
if (isEH && files.length > 0) setEhFile(files[0])
else setUploadedFiles(prev => [...prev, ...files])
}
const handleDirectUpload = async () => {
if (uploadedFiles.length === 0) return
setIsUploading(true)
try {
const nk = await korrekturApi.createKlausur({
title: `Schnellstart ${new Date().toLocaleDateString('de-DE')}`,
subject: 'Deutsch', year: new Date().getFullYear(), semester: 'Abitur', modus: 'landes_abitur'
})
for (let i = 0; i < uploadedFiles.length; i++) {
await korrekturApi.uploadStudentWork(nk.id, uploadedFiles[i], `Arbeit-${i + 1}`)
}
setShowDirectUpload(false); setUploadedFiles([])
router.push(`/korrektur/${nk.id}`)
} catch (err) {
setError(err instanceof Error ? err.message : 'Upload fehlgeschlagen')
} finally { setIsUploading(false) }
}
const handleEHUpload = async () => {
if (!ehFile) return
setIsUploading(true)
try {
await korrekturApi.uploadEH(ehFile)
setShowEHUpload(false); setEhFile(null); loadData()
} catch (err) {
setError(err instanceof Error ? err.message : 'EH Upload fehlgeschlagen')
} finally { setIsUploading(false) }
}
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'
}`}>
{/* Background Blobs */}
<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">
{/* Header */}
<div className="flex items-center justify-between mb-8">
<div>
<h1 className={`text-3xl font-bold mb-2 ${isDark ? 'text-white' : 'text-slate-900'}`}>Korrekturplattform</h1>
<p className={isDark ? 'text-white/50' : 'text-slate-500'}>KI-gestuetzte Abiturklausur-Korrektur</p>
</div>
<div className="flex items-center gap-3"><ThemeToggle /><LanguageDropdown /></div>
</div>
{/* Stats */}
{stats && (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4 mb-8">
<StatCard label="Offene Korrekturen" value={stats.openCorrections}
icon={<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2" /></svg>}
color="#f97316" delay={100} isDark={isDark} />
<StatCard label="Erledigt (Woche)" value={stats.completedThisWeek}
icon={<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" /></svg>}
color="#22c55e" delay={200} isDark={isDark} />
<StatCard label="Durchschnitt" value={stats.averageGrade > 0 ? `${stats.averageGrade} P` : '-'}
icon={<svg className="w-6 h-6" 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>}
color="#3b82f6" delay={300} isDark={isDark} />
<StatCard label="Zeit gespart" value={`${stats.timeSavedHours}h`}
icon={<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" /></svg>}
color="#a78bfa" delay={400} isDark={isDark} />
</div>
)}
{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 items-center justify-between mb-4">
<h2 className={`text-xl font-semibold ${isDark ? 'text-white' : 'text-slate-900'}`}>Klausuren</h2>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4 mb-8">
{klausuren.map((k, i) => (
<KlausurCard key={k.id} klausur={k} onClick={() => router.push(`/korrektur/${k.id}`)} delay={500 + i * 50} isDark={isDark} />
))}
<GlassCard onClick={() => setShowCreateModal(true)} delay={500 + klausuren.length * 50}
className={`min-h-[180px] border-2 border-dashed ${isDark ? 'border-white/20 hover:border-purple-400/50' : 'border-slate-300 hover:border-purple-400'}`} isDark={isDark}>
<div className="flex flex-col items-center justify-center h-full text-center">
<div className="w-16 h-16 rounded-2xl bg-purple-500/20 flex items-center justify-center mb-4">
<svg className="w-8 h-8 text-purple-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 6v6m0 0v6m0-6h6m-6 0H6" /></svg>
</div>
<p className={`font-medium ${isDark ? 'text-white' : 'text-slate-900'}`}>Neue Klausur</p>
<p className={`text-sm mt-1 ${isDark ? 'text-white/50' : 'text-slate-500'}`}>Klausur erstellen</p>
</div>
</GlassCard>
</div>
<h2 className={`text-xl font-semibold mb-4 ${isDark ? 'text-white' : 'text-slate-900'}`}>Schnellaktionen</h2>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
{[
{ onClick: () => setShowQRModal(true), icon: '📱', title: 'QR Upload', sub: 'Mit Handy scannen', bg: 'blue', delay: 700 },
{ onClick: () => setShowDirectUpload(true), icon: <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="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12" /></svg>, title: 'Direkt hochladen', sub: 'Drag & Drop', bg: 'green', delay: 750 },
{ onClick: () => setShowCreateModal(true), icon: <svg className="w-6 h-6 text-purple-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 10V3L4 14h7v7l9-11h-7z" /></svg>, title: 'Schnellstart', sub: 'Direkt loslegen', bg: 'purple', delay: 800 },
{ onClick: () => setShowEHUpload(true), icon: <svg className="w-6 h-6 text-orange-400" 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>, title: 'EH hochladen', sub: 'Erwartungshorizont', bg: 'orange', delay: 850 },
{ onClick: () => router.push('/korrektur/archiv'), icon: <svg className="w-6 h-6 text-indigo-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10" /></svg>, title: 'Abitur-Archiv', sub: 'EH durchsuchen', bg: 'indigo', delay: 900 },
].map((action, i) => (
<GlassCard key={i} onClick={action.onClick} delay={action.delay} className="cursor-pointer" isDark={isDark}>
<div className="flex items-center gap-4">
<div className={`w-12 h-12 rounded-2xl bg-${action.bg}-500/20 flex items-center justify-center`}>
{typeof action.icon === 'string' ? <span className="text-2xl">{action.icon}</span> : action.icon}
</div>
<div>
<p className={`font-medium ${isDark ? 'text-white' : 'text-slate-900'}`}>{action.title}</p>
<p className={`text-sm ${isDark ? 'text-white/50' : 'text-slate-500'}`}>{action.sub}</p>
</div>
</div>
</GlassCard>
))}
</div>
</>
)}
</div>
<CreateKlausurModal isOpen={showCreateModal} onClose={() => setShowCreateModal(false)}
onSubmit={handleCreateKlausur} isLoading={isCreating} isDark={isDark} />
{showQRModal && <QRCodeModal isDark={isDark} sessionId={uploadSessionId}
onClose={() => setShowQRModal(false)} onFileUploaded={handleMobileFileSelect} />}
{showDirectUpload && <DirectUploadModal isDark={isDark} isDragging={isDragging}
uploadedFiles={uploadedFiles} isUploading={isUploading} error={error}
onDragOver={handleDragOver} onDragLeave={handleDragLeave}
onDrop={(e) => handleDrop(e, false)} onFileSelect={(e) => handleFileSelect(e, false)}
onRemoveFile={(idx) => setUploadedFiles(prev => prev.filter((_, i) => i !== idx))}
onUpload={handleDirectUpload} onClose={() => { setShowDirectUpload(false); setUploadedFiles([]) }} />}
{showEHUpload && <EHUploadModal isDark={isDark} isDragging={isDragging}
ehFile={ehFile} isUploading={isUploading}
onDragOver={handleDragOver} onDragLeave={handleDragLeave}
onDrop={(e) => handleDrop(e, true)} onFileSelect={(e) => handleFileSelect(e, true)}
onRemoveFile={() => setEhFile(null)} onUpload={handleEHUpload}
onClose={() => { setShowEHUpload(false); setEhFile(null) }} />}
</div>
)
}