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>
242 lines
15 KiB
TypeScript
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>
|
|
)
|
|
}
|