Files
breakpilot-lehrer/studio-v2/app/korrektur/[klausurId]/[studentId]/page.tsx
Benjamin Boenisch 5a31f52310 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>
2026-02-11 23:47:26 +01:00

493 lines
18 KiB
TypeScript

'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>
)
}