[split-required] Split final batch of monoliths >1000 LOC
Python (6 files in klausur-service): - rbac.py (1,132 → 4), admin_api.py (1,012 → 4) - routes/eh.py (1,111 → 4), ocr_pipeline_geometry.py (1,105 → 5) Python (2 files in backend-lehrer): - unit_api.py (1,226 → 6), game_api.py (1,129 → 5) Website (6 page files): - 4x klausur-korrektur pages (1,249-1,328 LOC each) → shared components in website/components/klausur-korrektur/ (17 shared files) - companion (1,057 → 10), magic-help (1,017 → 8) All re-export barrels preserve backward compatibility. Zero import errors verified. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
471
website/components/klausur-korrektur/useKorrekturWorkspace.ts
Normal file
471
website/components/klausur-korrektur/useKorrekturWorkspace.ts
Normal file
@@ -0,0 +1,471 @@
|
||||
'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<Klausur | null>(null)
|
||||
const [student, setStudent] = useState<StudentWork | null>(null)
|
||||
const [students, setStudents] = useState<StudentWork[]>([])
|
||||
const [annotations, setAnnotations] = useState<Annotation[]>([])
|
||||
const [gradeInfo, setGradeInfo] = useState<GradeInfo | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [activeTab, setActiveTab] = useState<ActiveTab>('kriterien')
|
||||
const [currentPage, setCurrentPage] = useState(1)
|
||||
const [totalPages, setTotalPages] = useState(1)
|
||||
const [zoom, setZoom] = useState(100)
|
||||
const [documentUrl, setDocumentUrl] = useState<string | null>(null)
|
||||
const [generatingGutachten, setGeneratingGutachten] = useState(false)
|
||||
const [exporting, setExporting] = useState(false)
|
||||
|
||||
// Annotation state
|
||||
const [selectedTool, setSelectedTool] = useState<AnnotationType | null>(null)
|
||||
const [selectedAnnotation, setSelectedAnnotation] = useState<Annotation | null>(null)
|
||||
|
||||
// Form state
|
||||
const [criteriaScores, setCriteriaScores] = useState<CriteriaScores>({})
|
||||
const [gutachten, setGutachten] = useState('')
|
||||
|
||||
// Examiner workflow state
|
||||
const [workflow, setWorkflow] = useState<ExaminerWorkflow | null>(null)
|
||||
const [showEinigungModal, setShowEinigungModal] = useState(false)
|
||||
const [einigungGrade, setEinigungGrade] = useState<number>(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<AnnotationType, number> = {
|
||||
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<Annotation>) => {
|
||||
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,
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user