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>
493 lines
18 KiB
TypeScript
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>
|
|
)
|
|
}
|