'use client' /** * Korrektur-Workspace * * Main correction interface with 2/3 - 1/3 layout: * - Left (2/3): Document viewer with annotation overlay * - Right (1/3): Criteria scoring, Gutachten editor, Annotations */ import { useState, useEffect, useCallback, useMemo } from 'react' import { useParams, useRouter } from 'next/navigation' import AdminLayout from '@/components/admin/AdminLayout' import Link from 'next/link' import AnnotationLayer from '../../components/AnnotationLayer' import AnnotationToolbar from '../../components/AnnotationToolbar' import AnnotationPanel from '../../components/AnnotationPanel' import EHSuggestionPanel from '../../components/EHSuggestionPanel' import type { Klausur, StudentWork, Annotation, CriteriaScores, GradeInfo, AnnotationType, AnnotationPosition, } from '../../types' import { ANNOTATION_COLORS } from '../../types' // Examiner workflow types interface ExaminerInfo { id: string assigned_at: string notes?: string } interface ExaminerResult { grade_points: number criteria_scores?: CriteriaScores notes?: string submitted_at: string } interface ExaminerWorkflow { student_id: string workflow_status: string visibility_mode: string user_role: 'ek' | 'zk' | 'dk' | 'viewer' first_examiner?: ExaminerInfo second_examiner?: ExaminerInfo third_examiner?: ExaminerInfo first_result?: ExaminerResult first_result_visible?: boolean second_result?: ExaminerResult third_result?: ExaminerResult grade_difference?: number final_grade?: number consensus_reached?: boolean consensus_type?: string einigung?: { final_grade: number notes: string type: string submitted_by: string submitted_at: string ek_grade: number zk_grade: number } drittkorrektur_reason?: string } // Workflow status labels const WORKFLOW_STATUS_LABELS: Record = { not_started: { label: 'Nicht gestartet', color: 'bg-slate-100 text-slate-700' }, ek_in_progress: { label: 'EK in Arbeit', color: 'bg-blue-100 text-blue-700' }, ek_completed: { label: 'EK abgeschlossen', color: 'bg-blue-200 text-blue-800' }, zk_assigned: { label: 'ZK zugewiesen', color: 'bg-amber-100 text-amber-700' }, zk_in_progress: { label: 'ZK in Arbeit', color: 'bg-amber-200 text-amber-800' }, zk_completed: { label: 'ZK abgeschlossen', color: 'bg-amber-300 text-amber-900' }, einigung_required: { label: 'Einigung erforderlich', color: 'bg-orange-100 text-orange-700' }, einigung_completed: { label: 'Einigung abgeschlossen', color: 'bg-green-100 text-green-700' }, drittkorrektur_required: { label: 'DK erforderlich', color: 'bg-red-100 text-red-700' }, drittkorrektur_assigned: { label: 'DK zugewiesen', color: 'bg-red-200 text-red-800' }, drittkorrektur_in_progress: { label: 'DK in Arbeit', color: 'bg-red-300 text-red-900' }, completed: { label: 'Abgeschlossen', color: 'bg-green-200 text-green-800' }, } const ROLE_LABELS: Record = { ek: { label: 'Erstkorrektor', color: 'bg-blue-500' }, zk: { label: 'Zweitkorrektor', color: 'bg-amber-500' }, dk: { label: 'Drittkorrektor', color: 'bg-purple-500' }, viewer: { label: 'Betrachter', color: 'bg-slate-500' }, } const API_BASE = process.env.NEXT_PUBLIC_KLAUSUR_SERVICE_URL || 'http://localhost:8086' // Grade thresholds and labels const GRADE_LABELS: Record = { 15: '1+', 14: '1', 13: '1-', 12: '2+', 11: '2', 10: '2-', 9: '3+', 8: '3', 7: '3-', 6: '4+', 5: '4', 4: '4-', 3: '5+', 2: '5', 1: '5-', 0: '6' } type ActiveTab = 'kriterien' | 'gutachten' | 'annotationen' | 'eh-vorschlaege' export default function KorrekturWorkspacePage() { const params = useParams() const router = useRouter() const klausurId = params.klausurId as string const studentId = params.studentId as string // State const [klausur, setKlausur] = useState(null) const [student, setStudent] = useState(null) const [students, setStudents] = useState([]) const [annotations, setAnnotations] = useState([]) const [gradeInfo, setGradeInfo] = useState(null) const [loading, setLoading] = useState(true) const [saving, setSaving] = useState(false) const [error, setError] = useState(null) const [activeTab, setActiveTab] = useState('kriterien') const [currentPage, setCurrentPage] = useState(1) const [totalPages, setTotalPages] = useState(1) const [zoom, setZoom] = useState(100) const [documentUrl, setDocumentUrl] = useState(null) const [generatingGutachten, setGeneratingGutachten] = useState(false) const [exporting, setExporting] = useState(false) // Annotation state const [selectedTool, setSelectedTool] = useState(null) const [selectedAnnotation, setSelectedAnnotation] = useState(null) // Form state const [criteriaScores, setCriteriaScores] = useState({}) const [gutachten, setGutachten] = useState('') // Examiner workflow state const [workflow, setWorkflow] = useState(null) const [showEinigungModal, setShowEinigungModal] = useState(false) const [einigungGrade, setEinigungGrade] = useState(0) const [einigungNotes, setEinigungNotes] = useState('') const [submittingWorkflow, setSubmittingWorkflow] = useState(false) // Current student index const currentIndex = students.findIndex(s => s.id === studentId) // Annotation counts by type const annotationCounts = useMemo(() => { const counts: Record = { rechtschreibung: 0, grammatik: 0, inhalt: 0, struktur: 0, stil: 0, comment: 0, highlight: 0, } annotations.forEach((ann) => { counts[ann.type] = (counts[ann.type] || 0) + 1 }) return counts }, [annotations]) // Fetch all data const fetchData = useCallback(async () => { try { setLoading(true) // Fetch klausur const klausurRes = await fetch(`${API_BASE}/api/v1/klausuren/${klausurId}`) if (klausurRes.ok) { setKlausur(await klausurRes.json()) } // Fetch students list const studentsRes = await fetch(`${API_BASE}/api/v1/klausuren/${klausurId}/students`) if (studentsRes.ok) { const data = await studentsRes.json() setStudents(Array.isArray(data) ? data : data.students || []) } // Fetch current student const studentRes = await fetch(`${API_BASE}/api/v1/students/${studentId}`) if (studentRes.ok) { const studentData = await studentRes.json() setStudent(studentData) setCriteriaScores(studentData.criteria_scores || {}) setGutachten(studentData.gutachten || '') } // Fetch grade info const gradeInfoRes = await fetch(`${API_BASE}/api/v1/grade-info`) if (gradeInfoRes.ok) { setGradeInfo(await gradeInfoRes.json()) } // Fetch examiner workflow status const workflowRes = await fetch(`${API_BASE}/api/v1/students/${studentId}/examiner-workflow`) if (workflowRes.ok) { const workflowData = await workflowRes.json() setWorkflow(workflowData) // If Einigung is required, pre-populate the grade field if (workflowData.workflow_status === 'einigung_required' && workflowData.first_result && workflowData.second_result) { const avgGrade = Math.round((workflowData.first_result.grade_points + workflowData.second_result.grade_points) / 2) setEinigungGrade(avgGrade) } } // Fetch annotations (use filtered endpoint if we have a workflow) const annotationsEndpoint = workflow?.user_role === 'zk' ? `${API_BASE}/api/v1/students/${studentId}/annotations-filtered` : `${API_BASE}/api/v1/students/${studentId}/annotations` const annotationsRes = await fetch(annotationsEndpoint) if (annotationsRes.ok) { const annotationsData = await annotationsRes.json() setAnnotations(Array.isArray(annotationsData) ? annotationsData : annotationsData.annotations || []) } // Build document URL setDocumentUrl(`${API_BASE}/api/v1/students/${studentId}/file`) setError(null) } catch (err) { console.error('Failed to fetch data:', err) setError('Fehler beim Laden der Daten') } finally { setLoading(false) } }, [klausurId, studentId]) // Create annotation const createAnnotation = useCallback(async (position: AnnotationPosition, type: AnnotationType) => { try { const newAnnotation = { page: currentPage, position, type, text: '', severity: type === 'rechtschreibung' || type === 'grammatik' ? 'minor' : 'major', role: 'first_examiner', linked_criterion: ['rechtschreibung', 'grammatik', 'inhalt', 'struktur', 'stil'].includes(type) ? type : undefined, } const res = await fetch(`${API_BASE}/api/v1/students/${studentId}/annotations`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(newAnnotation), }) if (res.ok) { const created = await res.json() setAnnotations((prev) => [...prev, created]) setSelectedAnnotation(created) setActiveTab('annotationen') // Switch to annotations tab to edit } else { const errorData = await res.json() setError(errorData.detail || 'Fehler beim Erstellen der Annotation') } } catch (err) { console.error('Failed to create annotation:', err) setError('Fehler beim Erstellen der Annotation') } }, [studentId, currentPage]) // Update annotation const updateAnnotation = useCallback(async (id: string, updates: Partial) => { try { const res = await fetch(`${API_BASE}/api/v1/annotations/${id}`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(updates), }) if (res.ok) { const updated = await res.json() setAnnotations((prev) => prev.map((ann) => (ann.id === id ? updated : ann))) if (selectedAnnotation?.id === id) { setSelectedAnnotation(updated) } } else { const errorData = await res.json() setError(errorData.detail || 'Fehler beim Aktualisieren der Annotation') } } catch (err) { console.error('Failed to update annotation:', err) setError('Fehler beim Aktualisieren der Annotation') } }, [selectedAnnotation?.id]) // Delete annotation const deleteAnnotation = useCallback(async (id: string) => { try { const res = await fetch(`${API_BASE}/api/v1/annotations/${id}`, { method: 'DELETE', }) if (res.ok) { setAnnotations((prev) => prev.filter((ann) => ann.id !== id)) if (selectedAnnotation?.id === id) { setSelectedAnnotation(null) } } else { const errorData = await res.json() setError(errorData.detail || 'Fehler beim Loeschen der Annotation') } } catch (err) { console.error('Failed to delete annotation:', err) setError('Fehler beim Loeschen der Annotation') } }, [selectedAnnotation?.id]) useEffect(() => { fetchData() }, [fetchData]) // Save criteria scores const saveCriteriaScores = useCallback(async (newScores: CriteriaScores) => { try { setSaving(true) const res = await fetch(`${API_BASE}/api/v1/students/${studentId}/criteria`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ criteria_scores: newScores }), }) if (res.ok) { const updated = await res.json() setStudent(updated) } else { setError('Fehler beim Speichern') } } catch (err) { console.error('Failed to save criteria:', err) setError('Fehler beim Speichern') } finally { setSaving(false) } }, [studentId]) // Save gutachten const saveGutachten = useCallback(async () => { try { setSaving(true) const res = await fetch(`${API_BASE}/api/v1/students/${studentId}/gutachten`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ gutachten }), }) if (res.ok) { const updated = await res.json() setStudent(updated) } else { setError('Fehler beim Speichern') } } catch (err) { console.error('Failed to save gutachten:', err) setError('Fehler beim Speichern') } finally { setSaving(false) } }, [studentId, gutachten]) // Generate gutachten const generateGutachten = useCallback(async () => { try { setGeneratingGutachten(true) setError(null) const res = await fetch(`${API_BASE}/api/v1/students/${studentId}/gutachten/generate`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ criteria_scores: criteriaScores }), }) if (res.ok) { const data = await res.json() const generatedText = [ data.einleitung || '', '', data.hauptteil || '', '', data.fazit || '', ].filter(Boolean).join('\n\n') setGutachten(generatedText) setActiveTab('gutachten') } else { const errorData = await res.json() setError(errorData.detail || 'Fehler bei der Gutachten-Generierung') } } catch (err) { console.error('Failed to generate gutachten:', err) setError('Fehler bei der Gutachten-Generierung') } finally { setGeneratingGutachten(false) } }, [studentId, criteriaScores]) // Export PDF functions const exportGutachtenPDF = useCallback(async () => { try { setExporting(true) setError(null) const res = await fetch(`${API_BASE}/api/v1/students/${studentId}/export/gutachten`) if (res.ok) { const blob = await res.blob() const url = window.URL.createObjectURL(blob) const a = document.createElement('a') a.href = url a.download = `Gutachten_${student?.anonym_id?.replace(/\s+/g, '_') || 'Student'}_${new Date().toISOString().split('T')[0]}.pdf` document.body.appendChild(a) a.click() document.body.removeChild(a) window.URL.revokeObjectURL(url) } else { setError('Fehler beim PDF-Export') } } catch (err) { console.error('Failed to export PDF:', err) setError('Fehler beim PDF-Export') } finally { setExporting(false) } }, [studentId, student?.anonym_id]) const exportAnnotationsPDF = useCallback(async () => { try { setExporting(true) setError(null) const res = await fetch(`${API_BASE}/api/v1/students/${studentId}/export/annotations`) if (res.ok) { const blob = await res.blob() const url = window.URL.createObjectURL(blob) const a = document.createElement('a') a.href = url a.download = `Anmerkungen_${student?.anonym_id?.replace(/\s+/g, '_') || 'Student'}_${new Date().toISOString().split('T')[0]}.pdf` document.body.appendChild(a) a.click() document.body.removeChild(a) window.URL.revokeObjectURL(url) } else { setError('Fehler beim PDF-Export') } } catch (err) { console.error('Failed to export annotations PDF:', err) setError('Fehler beim PDF-Export') } finally { setExporting(false) } }, [studentId, student?.anonym_id]) // Handle criteria change const handleCriteriaChange = (criterion: string, value: number) => { const newScores = { ...criteriaScores, [criterion]: value } setCriteriaScores(newScores) // Auto-save after short delay saveCriteriaScores(newScores) } // Calculate total points - moved before workflow functions to avoid "used before declaration" error const calculateTotalPoints = useCallback(() => { if (!gradeInfo?.criteria) return { raw: 0, weighted: 0, gradePoints: 0 } let totalWeighted = 0 let totalWeight = 0 Object.entries(gradeInfo.criteria).forEach(([key, criterion]) => { const score = criteriaScores[key] || 0 totalWeighted += score * (criterion.weight / 100) totalWeight += criterion.weight }) const percentage = totalWeight > 0 ? (totalWeighted / totalWeight) * 100 : 0 // Calculate grade points from percentage let gradePoints = 0 const thresholds = [ { points: 15, min: 95 }, { points: 14, min: 90 }, { points: 13, min: 85 }, { points: 12, min: 80 }, { points: 11, min: 75 }, { points: 10, min: 70 }, { points: 9, min: 65 }, { points: 8, min: 60 }, { points: 7, min: 55 }, { points: 6, min: 50 }, { points: 5, min: 45 }, { points: 4, min: 40 }, { points: 3, min: 33 }, { points: 2, min: 27 }, { points: 1, min: 20 }, ] for (const t of thresholds) { if (percentage >= t.min) { gradePoints = t.points break } } return { raw: Math.round(totalWeighted), weighted: Math.round(percentage), gradePoints, } }, [gradeInfo, criteriaScores]) const totals = calculateTotalPoints() // Submit Erstkorrektur (EK completes their work) const submitErstkorrektur = useCallback(async () => { try { setSubmittingWorkflow(true) // First assign as EK if not already assigned const assignRes = await fetch(`${API_BASE}/api/v1/students/${studentId}/examiner`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ examiner_id: 'current-user', // In production, get from auth context examiner_role: 'first_examiner', }), }) if (!assignRes.ok && assignRes.status !== 400) { // 400 might mean already assigned, which is fine const error = await assignRes.json() throw new Error(error.detail || 'Fehler bei der Zuweisung') } // Submit result const submitRes = await fetch(`${API_BASE}/api/v1/students/${studentId}/examiner/result`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ grade_points: totals.gradePoints, notes: gutachten, }), }) if (submitRes.ok) { // Refresh workflow status fetchData() } else { const error = await submitRes.json() setError(error.detail || 'Fehler beim Abschliessen der Erstkorrektur') } } catch (err) { console.error('Failed to submit Erstkorrektur:', err) setError('Fehler beim Abschliessen der Erstkorrektur') } finally { setSubmittingWorkflow(false) } }, [studentId, totals.gradePoints, gutachten, fetchData]) // Start Zweitkorrektur const startZweitkorrektur = useCallback(async (zweitkorrektorId: string) => { try { setSubmittingWorkflow(true) const res = await fetch(`${API_BASE}/api/v1/students/${studentId}/start-zweitkorrektur`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ zweitkorrektor_id: zweitkorrektorId, }), }) if (res.ok) { fetchData() } else { const error = await res.json() setError(error.detail || 'Fehler beim Starten der Zweitkorrektur') } } catch (err) { console.error('Failed to start Zweitkorrektur:', err) setError('Fehler beim Starten der Zweitkorrektur') } finally { setSubmittingWorkflow(false) } }, [studentId, fetchData]) // Submit Zweitkorrektur const submitZweitkorrektur = useCallback(async () => { try { setSubmittingWorkflow(true) const res = await fetch(`${API_BASE}/api/v1/students/${studentId}/submit-zweitkorrektur`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ grade_points: totals.gradePoints, criteria_scores: criteriaScores, gutachten: gutachten ? { text: gutachten } : null, notes: '', }), }) if (res.ok) { const result = await res.json() // Show result message if (result.workflow_status === 'completed') { alert(`Auto-Konsens erreicht! Endnote: ${result.final_grade} Punkte`) } else if (result.workflow_status === 'einigung_required') { setShowEinigungModal(true) } else if (result.workflow_status === 'drittkorrektur_required') { alert(`Drittkorrektur erforderlich: Differenz ${result.grade_difference} Punkte`) } fetchData() } else { const error = await res.json() setError(error.detail || 'Fehler beim Abschliessen der Zweitkorrektur') } } catch (err) { console.error('Failed to submit Zweitkorrektur:', err) setError('Fehler beim Abschliessen der Zweitkorrektur') } finally { setSubmittingWorkflow(false) } }, [studentId, totals.gradePoints, criteriaScores, gutachten, fetchData]) // Submit Einigung const submitEinigung = useCallback(async (type: 'agreed' | 'split' | 'escalated') => { try { setSubmittingWorkflow(true) const res = await fetch(`${API_BASE}/api/v1/students/${studentId}/einigung`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ final_grade: einigungGrade, einigung_notes: einigungNotes, einigung_type: type, }), }) if (res.ok) { const result = await res.json() setShowEinigungModal(false) if (result.workflow_status === 'drittkorrektur_required') { alert('Eskaliert zu Drittkorrektur') } else { alert(`Einigung abgeschlossen: Endnote ${result.final_grade} Punkte`) } fetchData() } else { const error = await res.json() setError(error.detail || 'Fehler bei der Einigung') } } catch (err) { console.error('Failed to submit Einigung:', err) setError('Fehler bei der Einigung') } finally { setSubmittingWorkflow(false) } }, [studentId, einigungGrade, einigungNotes, fetchData]) // Navigation const goToStudent = (direction: 'prev' | 'next') => { const newIndex = direction === 'prev' ? currentIndex - 1 : currentIndex + 1 if (newIndex >= 0 && newIndex < students.length) { router.push(`/lehrer/klausur-korrektur/${klausurId}/${students[newIndex].id}`) } } if (loading) { return (
) } return ( {/* Top Navigation Bar */}
{/* Back link */} Zurück {/* Student navigation */}
{currentIndex + 1} / {students.length}
{/* Workflow status and role */}
{/* Role badge */} {workflow && (
{ROLE_LABELS[workflow.user_role]?.label || workflow.user_role} {/* Workflow status badge */} {WORKFLOW_STATUS_LABELS[workflow.workflow_status]?.label || workflow.workflow_status} {/* Visibility mode indicator for ZK */} {workflow.user_role === 'zk' && workflow.visibility_mode !== 'full' && ( {workflow.visibility_mode === 'blind' ? 'Blind-Modus' : 'Semi-Blind'} )}
)} {saving && (
Speichern...
)}
{totals.gradePoints} Punkte
Note: {GRADE_LABELS[totals.gradePoints] || '-'}
{/* Einigung Modal */} {showEinigungModal && workflow && (

Einigung erforderlich

{/* Grade comparison */}
Erstkorrektor
{workflow.first_result?.grade_points || '-'} P
Zweitkorrektor
{workflow.second_result?.grade_points || '-'} P
Differenz: {workflow.grade_difference} Punkte
{/* Final grade selection */}
setEinigungGrade(parseInt(e.target.value))} className="w-full" />
{einigungGrade} Punkte ({GRADE_LABELS[einigungGrade] || '-'})
{/* Notes */}