'use client' /** * Custom hook for the Korrektur-Workspace. * Encapsulates all state, data fetching, and actions. */ import { useState, useEffect, useCallback, useMemo } from 'react' import type { Klausur, StudentWork, Annotation, CriteriaScores, GradeInfo, AnnotationType, AnnotationPosition, } from '../../app/admin/klausur-korrektur/types' import type { ExaminerWorkflow, ActiveTab } from './workspace-types' import { API_BASE } from './workspace-types' interface UseKorrekturWorkspaceArgs { klausurId: string studentId: string } export function useKorrekturWorkspace({ klausurId, studentId }: UseKorrekturWorkspaceArgs) { // Core 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) const klausurRes = await fetch(`${API_BASE}/api/v1/klausuren/${klausurId}`) if (klausurRes.ok) setKlausur(await klausurRes.json()) 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 || []) } 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 || '') } const gradeInfoRes = await fetch(`${API_BASE}/api/v1/grade-info`) if (gradeInfoRes.ok) setGradeInfo(await gradeInfoRes.json()) const workflowRes = await fetch(`${API_BASE}/api/v1/students/${studentId}/examiner-workflow`) if (workflowRes.ok) { const workflowData = await workflowRes.json() setWorkflow(workflowData) 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) } } 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 || []) } 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') } 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) setStudent(await res.json()) 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) setStudent(await res.json()) 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 helpers const downloadBlob = useCallback((blob: Blob, filename: string) => { const url = window.URL.createObjectURL(blob) const a = document.createElement('a') a.href = url a.download = filename document.body.appendChild(a) a.click() document.body.removeChild(a) window.URL.revokeObjectURL(url) }, []) 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() downloadBlob(blob, `Gutachten_${student?.anonym_id?.replace(/\s+/g, '_') || 'Student'}_${new Date().toISOString().split('T')[0]}.pdf`) } 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, downloadBlob]) 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() downloadBlob(blob, `Anmerkungen_${student?.anonym_id?.replace(/\s+/g, '_') || 'Student'}_${new Date().toISOString().split('T')[0]}.pdf`) } 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, downloadBlob]) // Handle criteria change const handleCriteriaChange = (criterion: string, value: number) => { const newScores = { ...criteriaScores, [criterion]: value } setCriteriaScores(newScores) saveCriteriaScores(newScores) } // Calculate total points 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 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 const submitErstkorrektur = useCallback(async () => { try { setSubmittingWorkflow(true) 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', examiner_role: 'first_examiner' }), }) if (!assignRes.ok && assignRes.status !== 400) { const error = await assignRes.json() throw new Error(error.detail || 'Fehler bei der Zuweisung') } 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) { 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() 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]) return { // Data klausur, student, students, annotations, gradeInfo, workflow, documentUrl, // UI state loading, saving, error, activeTab, currentPage, totalPages, zoom, generatingGutachten, exporting, selectedTool, selectedAnnotation, criteriaScores, gutachten, showEinigungModal, einigungGrade, einigungNotes, submittingWorkflow, currentIndex, annotationCounts, totals, // Setters setError, setActiveTab, setCurrentPage, setZoom, setSelectedTool, setSelectedAnnotation, setGutachten, setShowEinigungModal, setEinigungGrade, setEinigungNotes, setCriteriaScores, // Actions createAnnotation, updateAnnotation, deleteAnnotation, handleCriteriaChange, saveCriteriaScores, saveGutachten, generateGutachten, exportGutachtenPDF, exportAnnotationsPDF, submitErstkorrektur, startZweitkorrektur, submitZweitkorrektur, submitEinigung, fetchData, } }