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>
472 lines
18 KiB
TypeScript
472 lines
18 KiB
TypeScript
'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,
|
|
}
|
|
}
|