Initial commit: breakpilot-lehrer - Lehrer KI Platform
Services: Admin-Lehrer, Backend-Lehrer, Studio v2, Website, Klausur-Service, School-Service, Voice-Service, Geo-Service, BreakPilot Drive, Agent-Core Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
492
studio-v2/app/korrektur/[klausurId]/[studentId]/page.tsx
Normal file
492
studio-v2/app/korrektur/[klausurId]/[studentId]/page.tsx
Normal file
@@ -0,0 +1,492 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect, useCallback } 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 {
|
||||
DocumentViewer,
|
||||
AnnotationLayer,
|
||||
AnnotationToolbar,
|
||||
AnnotationLegend,
|
||||
CriteriaPanel,
|
||||
GutachtenEditor,
|
||||
EHSuggestionPanel,
|
||||
} from '@/components/korrektur'
|
||||
import { korrekturApi } from '@/lib/korrektur/api'
|
||||
import type {
|
||||
Klausur,
|
||||
StudentWork,
|
||||
Annotation,
|
||||
AnnotationType,
|
||||
AnnotationPosition,
|
||||
CriteriaScores,
|
||||
EHSuggestion,
|
||||
} from '../../types'
|
||||
|
||||
// =============================================================================
|
||||
// GLASS CARD
|
||||
// =============================================================================
|
||||
|
||||
interface GlassCardProps {
|
||||
children: React.ReactNode
|
||||
className?: string
|
||||
}
|
||||
|
||||
function GlassCard({ children, className = '' }: GlassCardProps) {
|
||||
return (
|
||||
<div
|
||||
className={`rounded-3xl p-4 ${className}`}
|
||||
style={{
|
||||
background: 'rgba(255, 255, 255, 0.08)',
|
||||
backdropFilter: 'blur(24px) saturate(180%)',
|
||||
WebkitBackdropFilter: 'blur(24px) saturate(180%)',
|
||||
border: '1px solid rgba(255, 255, 255, 0.1)',
|
||||
boxShadow: '0 4px 24px rgba(0, 0, 0, 0.15), inset 0 1px 0 rgba(255, 255, 255, 0.05)',
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// MAIN PAGE
|
||||
// =============================================================================
|
||||
|
||||
export default function StudentWorkspacePage() {
|
||||
const { isDark } = useTheme()
|
||||
const router = useRouter()
|
||||
const params = useParams()
|
||||
const klausurId = params.klausurId as string
|
||||
const studentId = params.studentId as string
|
||||
|
||||
// State
|
||||
const [klausur, setKlausur] = useState<Klausur | null>(null)
|
||||
const [student, setStudent] = useState<StudentWork | null>(null)
|
||||
const [students, setStudents] = useState<StudentWork[]>([])
|
||||
const [annotations, setAnnotations] = useState<Annotation[]>([])
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
// Editor state
|
||||
const [currentPage, setCurrentPage] = useState(1)
|
||||
const [totalPages, setTotalPages] = useState(1)
|
||||
const [selectedTool, setSelectedTool] = useState<AnnotationType | null>(null)
|
||||
const [selectedAnnotation, setSelectedAnnotation] = useState<string | null>(null)
|
||||
const [activeTab, setActiveTab] = useState<'kriterien' | 'gutachten' | 'eh'>('kriterien')
|
||||
|
||||
// Criteria and Gutachten state
|
||||
const [criteriaScores, setCriteriaScores] = useState<CriteriaScores>({})
|
||||
const [gutachten, setGutachten] = useState('')
|
||||
const [isGeneratingGutachten, setIsGeneratingGutachten] = useState(false)
|
||||
|
||||
// EH Suggestions state
|
||||
const [ehSuggestions, setEhSuggestions] = useState<EHSuggestion[]>([])
|
||||
const [isLoadingEH, setIsLoadingEH] = useState(false)
|
||||
|
||||
// Saving state
|
||||
const [isSaving, setIsSaving] = useState(false)
|
||||
const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false)
|
||||
|
||||
// Load data
|
||||
const loadData = useCallback(async () => {
|
||||
if (!klausurId || !studentId) return
|
||||
|
||||
setIsLoading(true)
|
||||
setError(null)
|
||||
|
||||
try {
|
||||
const [klausurData, studentData, studentsData, annotationsData] = await Promise.all([
|
||||
korrekturApi.getKlausur(klausurId),
|
||||
korrekturApi.getStudent(studentId),
|
||||
korrekturApi.getStudents(klausurId),
|
||||
korrekturApi.getAnnotations(studentId),
|
||||
])
|
||||
|
||||
setKlausur(klausurData)
|
||||
setStudent(studentData)
|
||||
setStudents(studentsData)
|
||||
setAnnotations(annotationsData)
|
||||
|
||||
// Initialize editor state from student data
|
||||
setCriteriaScores(studentData.criteria_scores || {})
|
||||
setGutachten(studentData.gutachten || '')
|
||||
|
||||
// Estimate total pages (for images, usually 1; for PDFs, would need backend info)
|
||||
setTotalPages(studentData.file_type === 'pdf' ? 5 : 1)
|
||||
} catch (err) {
|
||||
console.error('Failed to load data:', err)
|
||||
setError(err instanceof Error ? err.message : 'Laden fehlgeschlagen')
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}, [klausurId, studentId])
|
||||
|
||||
useEffect(() => {
|
||||
loadData()
|
||||
}, [loadData])
|
||||
|
||||
// Get current student index
|
||||
const currentIndex = students.findIndex((s) => s.id === studentId)
|
||||
const prevStudent = currentIndex > 0 ? students[currentIndex - 1] : null
|
||||
const nextStudent = currentIndex < students.length - 1 ? students[currentIndex + 1] : null
|
||||
|
||||
// Navigation
|
||||
const goToStudent = (id: string) => {
|
||||
if (hasUnsavedChanges) {
|
||||
if (!confirm('Sie haben ungespeicherte Aenderungen. Trotzdem wechseln?')) {
|
||||
return
|
||||
}
|
||||
}
|
||||
router.push(`/korrektur/${klausurId}/${id}`)
|
||||
}
|
||||
|
||||
// Handle criteria change
|
||||
const handleCriteriaChange = (criterion: string, value: number) => {
|
||||
setCriteriaScores((prev) => ({
|
||||
...prev,
|
||||
[criterion]: value,
|
||||
}))
|
||||
setHasUnsavedChanges(true)
|
||||
}
|
||||
|
||||
// Handle gutachten change
|
||||
const handleGutachtenChange = (value: string) => {
|
||||
setGutachten(value)
|
||||
setHasUnsavedChanges(true)
|
||||
}
|
||||
|
||||
// Generate gutachten
|
||||
const handleGenerateGutachten = async () => {
|
||||
setIsGeneratingGutachten(true)
|
||||
try {
|
||||
const result = await korrekturApi.generateGutachten(studentId)
|
||||
setGutachten(result.gutachten)
|
||||
setHasUnsavedChanges(true)
|
||||
} catch (err) {
|
||||
console.error('Failed to generate gutachten:', err)
|
||||
setError('Gutachten-Generierung fehlgeschlagen')
|
||||
} finally {
|
||||
setIsGeneratingGutachten(false)
|
||||
}
|
||||
}
|
||||
|
||||
// Load EH suggestions
|
||||
const handleLoadEHSuggestions = async (criterion?: string) => {
|
||||
setIsLoadingEH(true)
|
||||
try {
|
||||
const suggestions = await korrekturApi.getEHSuggestions(studentId, criterion)
|
||||
setEhSuggestions(suggestions)
|
||||
} catch (err) {
|
||||
console.error('Failed to load EH suggestions:', err)
|
||||
setError('EH-Vorschlaege konnten nicht geladen werden')
|
||||
} finally {
|
||||
setIsLoadingEH(false)
|
||||
}
|
||||
}
|
||||
|
||||
// Create annotation
|
||||
const handleAnnotationCreate = async (position: AnnotationPosition, type: AnnotationType) => {
|
||||
try {
|
||||
const newAnnotation = await korrekturApi.createAnnotation(studentId, {
|
||||
page: currentPage,
|
||||
position,
|
||||
type,
|
||||
text: '',
|
||||
severity: 'minor',
|
||||
})
|
||||
setAnnotations((prev) => [...prev, newAnnotation])
|
||||
setSelectedAnnotation(newAnnotation.id)
|
||||
setSelectedTool(null)
|
||||
} catch (err) {
|
||||
console.error('Failed to create annotation:', err)
|
||||
}
|
||||
}
|
||||
|
||||
// Delete annotation
|
||||
const handleAnnotationDelete = async (id: string) => {
|
||||
try {
|
||||
await korrekturApi.deleteAnnotation(id)
|
||||
setAnnotations((prev) => prev.filter((a) => a.id !== id))
|
||||
setSelectedAnnotation(null)
|
||||
} catch (err) {
|
||||
console.error('Failed to delete annotation:', err)
|
||||
}
|
||||
}
|
||||
|
||||
// Save all changes
|
||||
const handleSave = async () => {
|
||||
setIsSaving(true)
|
||||
try {
|
||||
await Promise.all([
|
||||
korrekturApi.updateCriteria(studentId, criteriaScores),
|
||||
korrekturApi.updateGutachten(studentId, gutachten),
|
||||
])
|
||||
setHasUnsavedChanges(false)
|
||||
} catch (err) {
|
||||
console.error('Failed to save:', err)
|
||||
setError('Speichern fehlgeschlagen')
|
||||
} finally {
|
||||
setIsSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
// Insert EH suggestion into gutachten
|
||||
const handleInsertSuggestion = (text: string) => {
|
||||
setGutachten((prev) => prev + '\n\n' + text)
|
||||
setHasUnsavedChanges(true)
|
||||
setActiveTab('gutachten')
|
||||
}
|
||||
|
||||
// Keyboard shortcuts
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
// Don't trigger shortcuts when typing in inputs
|
||||
if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) {
|
||||
return
|
||||
}
|
||||
|
||||
if (e.key === 'Escape') {
|
||||
setSelectedTool(null)
|
||||
setSelectedAnnotation(null)
|
||||
} else if (e.key === 'r' || e.key === 'R') {
|
||||
setSelectedTool('rechtschreibung')
|
||||
} else if (e.key === 'g' || e.key === 'G') {
|
||||
setSelectedTool('grammatik')
|
||||
} else if (e.key === 'i' || e.key === 'I') {
|
||||
setSelectedTool('inhalt')
|
||||
} else if (e.key === 's' && e.metaKey) {
|
||||
e.preventDefault()
|
||||
handleSave()
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener('keydown', handleKeyDown)
|
||||
return () => window.removeEventListener('keydown', handleKeyDown)
|
||||
}, [criteriaScores, gutachten])
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-slate-900 via-purple-900/30 to-slate-900">
|
||||
<div className="w-12 h-12 border-4 border-purple-500 border-t-transparent rounded-full animate-spin" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex relative overflow-hidden bg-gradient-to-br from-slate-900 via-purple-900/30 to-slate-900">
|
||||
{/* Animated Background Blobs */}
|
||||
<div className="absolute -top-40 -right-40 w-96 h-96 rounded-full mix-blend-multiply filter blur-3xl animate-blob bg-purple-500 opacity-30" />
|
||||
<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 bg-blue-500 opacity-30" />
|
||||
|
||||
{/* Sidebar */}
|
||||
<div className="relative z-10 p-4">
|
||||
<Sidebar />
|
||||
</div>
|
||||
|
||||
{/* Main Content */}
|
||||
<div className="flex-1 flex flex-col relative z-10 p-4 overflow-hidden">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="flex items-center gap-4">
|
||||
<button
|
||||
onClick={() => router.push(`/korrektur/${klausurId}`)}
|
||||
className="p-2 rounded-xl bg-white/10 hover:bg-white/20 text-white transition-colors"
|
||||
>
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 19l-7-7m0 0l7-7m-7 7h18" />
|
||||
</svg>
|
||||
</button>
|
||||
<div>
|
||||
<h1 className="text-xl font-bold text-white">
|
||||
{student?.anonym_id || 'Student'}
|
||||
</h1>
|
||||
<p className="text-white/50 text-sm">{klausur?.title}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Navigation */}
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => prevStudent && goToStudent(prevStudent.id)}
|
||||
disabled={!prevStudent}
|
||||
className="p-2 rounded-xl bg-white/10 hover:bg-white/20 text-white transition-colors disabled:opacity-30"
|
||||
>
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
|
||||
</svg>
|
||||
</button>
|
||||
<span className="text-white/60 text-sm px-3">
|
||||
{currentIndex + 1} / {students.length}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => nextStudent && goToStudent(nextStudent.id)}
|
||||
disabled={!nextStudent}
|
||||
className="p-2 rounded-xl bg-white/10 hover:bg-white/20 text-white transition-colors disabled:opacity-30"
|
||||
>
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
{hasUnsavedChanges && (
|
||||
<span className="text-amber-400 text-sm">Ungespeicherte Aenderungen</span>
|
||||
)}
|
||||
<button
|
||||
onClick={handleSave}
|
||||
disabled={isSaving || !hasUnsavedChanges}
|
||||
className="px-4 py-2 rounded-xl bg-gradient-to-r from-green-500 to-emerald-500 text-white font-medium hover:shadow-lg hover:shadow-green-500/30 transition-all disabled:opacity-50 flex items-center gap-2"
|
||||
>
|
||||
{isSaving ? (
|
||||
<>
|
||||
<div className="w-4 h-4 border-2 border-white border-t-transparent rounded-full animate-spin" />
|
||||
Speichern...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
Speichern
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
<ThemeToggle />
|
||||
<LanguageDropdown />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Error Display */}
|
||||
{error && (
|
||||
<GlassCard className="mb-4">
|
||||
<div className="flex items-center gap-3 text-red-400">
|
||||
<svg className="w-5 h-5" 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 className="text-sm">{error}</span>
|
||||
<button
|
||||
onClick={() => setError(null)}
|
||||
className="ml-auto text-white/60 hover:text-white"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</GlassCard>
|
||||
)}
|
||||
|
||||
{/* Main Workspace - 2/3 - 1/3 Layout */}
|
||||
<div className="flex-1 flex gap-4 overflow-hidden">
|
||||
{/* Left: Document Viewer (2/3) */}
|
||||
<div className="w-2/3 flex flex-col">
|
||||
<GlassCard className="flex-1 flex flex-col overflow-hidden">
|
||||
<DocumentViewer
|
||||
fileUrl={korrekturApi.getStudentFileUrl(studentId)}
|
||||
fileType={student?.file_type || 'image'}
|
||||
currentPage={currentPage}
|
||||
totalPages={totalPages}
|
||||
onPageChange={setCurrentPage}
|
||||
>
|
||||
<AnnotationLayer
|
||||
annotations={annotations.filter((a) => a.page === currentPage)}
|
||||
selectedAnnotation={selectedAnnotation}
|
||||
currentTool={selectedTool}
|
||||
onAnnotationCreate={handleAnnotationCreate}
|
||||
onAnnotationSelect={setSelectedAnnotation}
|
||||
onAnnotationDelete={handleAnnotationDelete}
|
||||
/>
|
||||
</DocumentViewer>
|
||||
</GlassCard>
|
||||
|
||||
{/* Annotation Toolbar */}
|
||||
<div className="mt-3 flex items-center justify-between">
|
||||
<AnnotationToolbar
|
||||
selectedTool={selectedTool}
|
||||
onToolSelect={setSelectedTool}
|
||||
/>
|
||||
<AnnotationLegend className="hidden lg:flex" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right: Criteria/Gutachten Panel (1/3) */}
|
||||
<div className="w-1/3 flex flex-col">
|
||||
<GlassCard className="flex-1 flex flex-col overflow-hidden">
|
||||
{/* Tabs */}
|
||||
<div className="flex border-b border-white/10 mb-4">
|
||||
<button
|
||||
onClick={() => setActiveTab('kriterien')}
|
||||
className={`flex-1 py-2 text-sm font-medium transition-colors ${
|
||||
activeTab === 'kriterien'
|
||||
? 'text-white border-b-2 border-purple-500'
|
||||
: 'text-white/50 hover:text-white'
|
||||
}`}
|
||||
>
|
||||
Kriterien
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab('gutachten')}
|
||||
className={`flex-1 py-2 text-sm font-medium transition-colors ${
|
||||
activeTab === 'gutachten'
|
||||
? 'text-white border-b-2 border-purple-500'
|
||||
: 'text-white/50 hover:text-white'
|
||||
}`}
|
||||
>
|
||||
Gutachten
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab('eh')}
|
||||
className={`flex-1 py-2 text-sm font-medium transition-colors ${
|
||||
activeTab === 'eh'
|
||||
? 'text-white border-b-2 border-purple-500'
|
||||
: 'text-white/50 hover:text-white'
|
||||
}`}
|
||||
>
|
||||
EH
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Tab Content */}
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
{activeTab === 'kriterien' && (
|
||||
<CriteriaPanel
|
||||
scores={criteriaScores}
|
||||
annotations={annotations}
|
||||
onScoreChange={handleCriteriaChange}
|
||||
onLoadEHSuggestions={(criterion) => {
|
||||
handleLoadEHSuggestions(criterion)
|
||||
setActiveTab('eh')
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{activeTab === 'gutachten' && (
|
||||
<GutachtenEditor
|
||||
value={gutachten}
|
||||
onChange={handleGutachtenChange}
|
||||
onGenerate={handleGenerateGutachten}
|
||||
isGenerating={isGeneratingGutachten}
|
||||
/>
|
||||
)}
|
||||
|
||||
{activeTab === 'eh' && (
|
||||
<EHSuggestionPanel
|
||||
suggestions={ehSuggestions}
|
||||
isLoading={isLoadingEH}
|
||||
onLoadSuggestions={handleLoadEHSuggestions}
|
||||
onInsertSuggestion={handleInsertSuggestion}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</GlassCard>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user