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>
1329 lines
53 KiB
TypeScript
1329 lines
53 KiB
TypeScript
'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<string, { label: string; color: string }> = {
|
|
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<string, { label: string; color: string }> = {
|
|
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<number, string> = {
|
|
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<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)
|
|
|
|
// 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<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) {
|
|
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(`/admin/klausur-korrektur/${klausurId}/${students[newIndex].id}`)
|
|
}
|
|
}
|
|
|
|
if (loading) {
|
|
return (
|
|
<AdminLayout title="Lädt..." description="">
|
|
<div className="flex items-center justify-center h-[calc(100vh-200px)]">
|
|
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary-600"></div>
|
|
</div>
|
|
</AdminLayout>
|
|
)
|
|
}
|
|
|
|
return (
|
|
<AdminLayout
|
|
title={`Korrektur: ${student?.anonym_id || 'Student'}`}
|
|
description={klausur?.title || ''}
|
|
>
|
|
{/* Top Navigation Bar */}
|
|
<div className="bg-white border-b border-slate-200 -mx-6 -mt-6 px-6 py-3 mb-4 flex items-center justify-between sticky top-0 z-10">
|
|
{/* Back link */}
|
|
<Link
|
|
href={`/admin/klausur-korrektur/${klausurId}`}
|
|
className="text-primary-600 hover:text-primary-800 flex items-center gap-1 text-sm"
|
|
>
|
|
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
|
|
</svg>
|
|
Zurück
|
|
</Link>
|
|
|
|
{/* Student navigation */}
|
|
<div className="flex items-center gap-4">
|
|
<button
|
|
onClick={() => goToStudent('prev')}
|
|
disabled={currentIndex <= 0}
|
|
className="p-2 rounded-lg hover:bg-slate-100 disabled:opacity-50 disabled:cursor-not-allowed"
|
|
>
|
|
<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-sm font-medium">
|
|
{currentIndex + 1} / {students.length}
|
|
</span>
|
|
<button
|
|
onClick={() => goToStudent('next')}
|
|
disabled={currentIndex >= students.length - 1}
|
|
className="p-2 rounded-lg hover:bg-slate-100 disabled:opacity-50 disabled:cursor-not-allowed"
|
|
>
|
|
<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>
|
|
|
|
{/* Workflow status and role */}
|
|
<div className="flex items-center gap-3">
|
|
{/* Role badge */}
|
|
{workflow && (
|
|
<div className="flex items-center gap-2">
|
|
<span
|
|
className={`px-2 py-1 text-xs font-medium rounded-full text-white ${
|
|
ROLE_LABELS[workflow.user_role]?.color || 'bg-slate-500'
|
|
}`}
|
|
>
|
|
{ROLE_LABELS[workflow.user_role]?.label || workflow.user_role}
|
|
</span>
|
|
|
|
{/* Workflow status badge */}
|
|
<span
|
|
className={`px-2 py-1 text-xs font-medium rounded-full ${
|
|
WORKFLOW_STATUS_LABELS[workflow.workflow_status]?.color || 'bg-slate-100'
|
|
}`}
|
|
>
|
|
{WORKFLOW_STATUS_LABELS[workflow.workflow_status]?.label || workflow.workflow_status}
|
|
</span>
|
|
|
|
{/* Visibility mode indicator for ZK */}
|
|
{workflow.user_role === 'zk' && workflow.visibility_mode !== 'full' && (
|
|
<span className="px-2 py-1 text-xs bg-purple-100 text-purple-700 rounded-full">
|
|
{workflow.visibility_mode === 'blind' ? 'Blind-Modus' : 'Semi-Blind'}
|
|
</span>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{saving && (
|
|
<span className="text-sm text-slate-500 flex items-center gap-1">
|
|
<div className="animate-spin rounded-full h-3 w-3 border-b-2 border-primary-600"></div>
|
|
Speichern...
|
|
</span>
|
|
)}
|
|
<div className="text-right">
|
|
<div className="text-lg font-bold text-slate-800">
|
|
{totals.gradePoints} Punkte
|
|
</div>
|
|
<div className="text-sm text-slate-500">
|
|
Note: {GRADE_LABELS[totals.gradePoints] || '-'}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Einigung Modal */}
|
|
{showEinigungModal && workflow && (
|
|
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
|
|
<div className="bg-white rounded-lg shadow-xl p-6 w-full max-w-lg mx-4">
|
|
<h3 className="text-lg font-semibold mb-4">Einigung erforderlich</h3>
|
|
|
|
{/* Grade comparison */}
|
|
<div className="bg-slate-50 rounded-lg p-4 mb-4">
|
|
<div className="grid grid-cols-2 gap-4 text-center">
|
|
<div>
|
|
<div className="text-sm text-slate-500">Erstkorrektor</div>
|
|
<div className="text-2xl font-bold text-blue-600">
|
|
{workflow.first_result?.grade_points || '-'} P
|
|
</div>
|
|
</div>
|
|
<div>
|
|
<div className="text-sm text-slate-500">Zweitkorrektor</div>
|
|
<div className="text-2xl font-bold text-amber-600">
|
|
{workflow.second_result?.grade_points || '-'} P
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div className="text-center mt-2 text-sm text-slate-500">
|
|
Differenz: {workflow.grade_difference} Punkte
|
|
</div>
|
|
</div>
|
|
|
|
{/* Final grade selection */}
|
|
<div className="mb-4">
|
|
<label className="block text-sm font-medium text-slate-700 mb-2">
|
|
Endnote festlegen
|
|
</label>
|
|
<input
|
|
type="range"
|
|
min={Math.min(workflow.first_result?.grade_points || 0, workflow.second_result?.grade_points || 0) - 1}
|
|
max={Math.max(workflow.first_result?.grade_points || 15, workflow.second_result?.grade_points || 15) + 1}
|
|
value={einigungGrade}
|
|
onChange={(e) => setEinigungGrade(parseInt(e.target.value))}
|
|
className="w-full"
|
|
/>
|
|
<div className="text-center text-2xl font-bold mt-2">
|
|
{einigungGrade} Punkte ({GRADE_LABELS[einigungGrade] || '-'})
|
|
</div>
|
|
</div>
|
|
|
|
{/* Notes */}
|
|
<div className="mb-4">
|
|
<label className="block text-sm font-medium text-slate-700 mb-2">
|
|
Begruendung
|
|
</label>
|
|
<textarea
|
|
value={einigungNotes}
|
|
onChange={(e) => setEinigungNotes(e.target.value)}
|
|
placeholder="Begruendung fuer die Einigung..."
|
|
className="w-full p-2 border border-slate-300 rounded-lg text-sm"
|
|
rows={3}
|
|
/>
|
|
</div>
|
|
|
|
{/* Actions */}
|
|
<div className="flex gap-2">
|
|
<button
|
|
onClick={() => submitEinigung('agreed')}
|
|
disabled={submittingWorkflow || !einigungNotes}
|
|
className="flex-1 py-2 bg-green-600 text-white rounded-lg hover:bg-green-700 disabled:opacity-50"
|
|
>
|
|
Einigung bestaetigen
|
|
</button>
|
|
<button
|
|
onClick={() => submitEinigung('escalated')}
|
|
disabled={submittingWorkflow}
|
|
className="py-2 px-4 bg-red-100 text-red-700 rounded-lg hover:bg-red-200"
|
|
>
|
|
Eskalieren
|
|
</button>
|
|
<button
|
|
onClick={() => setShowEinigungModal(false)}
|
|
className="py-2 px-4 bg-slate-100 text-slate-700 rounded-lg hover:bg-slate-200"
|
|
>
|
|
Abbrechen
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Error display */}
|
|
{error && (
|
|
<div className="mb-4 p-4 bg-red-50 border border-red-200 rounded-lg flex items-center gap-3">
|
|
<svg className="w-5 h-5 text-red-600" 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-red-800">{error}</span>
|
|
<button onClick={() => setError(null)} className="ml-auto">
|
|
<svg className="w-5 h-5 text-red-600" 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>
|
|
)}
|
|
|
|
{/* Main Layout: 2/3 - 1/3 */}
|
|
<div className="flex gap-6 h-[calc(100vh-280px)]">
|
|
{/* Left: Document Viewer (2/3) */}
|
|
<div className="w-2/3 bg-white rounded-lg border border-slate-200 overflow-hidden flex flex-col">
|
|
{/* Toolbar */}
|
|
<AnnotationToolbar
|
|
selectedTool={selectedTool}
|
|
onSelectTool={setSelectedTool}
|
|
zoom={zoom}
|
|
onZoomChange={setZoom}
|
|
annotationCounts={annotationCounts}
|
|
/>
|
|
|
|
{/* Document display with annotation overlay */}
|
|
<div className="flex-1 overflow-auto p-4 bg-slate-100">
|
|
{documentUrl ? (
|
|
<div
|
|
className="mx-auto bg-white shadow-lg relative"
|
|
style={{ transform: `scale(${zoom / 100})`, transformOrigin: 'top center' }}
|
|
>
|
|
{/* Document */}
|
|
{student?.file_path?.endsWith('.pdf') ? (
|
|
<iframe
|
|
src={documentUrl}
|
|
className="w-full h-[800px] border-0"
|
|
title="Studentenarbeit"
|
|
/>
|
|
) : (
|
|
<div className="relative">
|
|
<img
|
|
src={documentUrl}
|
|
alt="Studentenarbeit"
|
|
className="max-w-full"
|
|
onError={(e) => {
|
|
(e.target as HTMLImageElement).src = '/placeholder-document.png'
|
|
}}
|
|
/>
|
|
{/* Annotation Layer Overlay */}
|
|
<AnnotationLayer
|
|
annotations={annotations.filter((ann) => ann.page === currentPage)}
|
|
selectedTool={selectedTool}
|
|
onCreateAnnotation={createAnnotation}
|
|
onSelectAnnotation={(ann) => {
|
|
setSelectedAnnotation(ann)
|
|
setActiveTab('annotationen')
|
|
}}
|
|
selectedAnnotationId={selectedAnnotation?.id}
|
|
/>
|
|
</div>
|
|
)}
|
|
</div>
|
|
) : (
|
|
<div className="flex items-center justify-center h-full text-slate-400">
|
|
Kein Dokument verfuegbar
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Page navigation (for multi-page documents) */}
|
|
<div className="border-t border-slate-200 p-2 flex items-center justify-center gap-2 bg-slate-50">
|
|
<button
|
|
onClick={() => setCurrentPage((p) => Math.max(1, p - 1))}
|
|
disabled={currentPage <= 1}
|
|
className="p-1 rounded hover:bg-slate-200 disabled:opacity-50"
|
|
>
|
|
<svg className="w-4 h-4" 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-sm">
|
|
Seite {currentPage} / {totalPages}
|
|
</span>
|
|
<button
|
|
onClick={() => setCurrentPage((p) => Math.min(totalPages, p + 1))}
|
|
disabled={currentPage >= totalPages}
|
|
className="p-1 rounded hover:bg-slate-200 disabled:opacity-50"
|
|
>
|
|
<svg className="w-4 h-4" 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>
|
|
|
|
{/* OCR Text (collapsible) */}
|
|
{student?.ocr_text && (
|
|
<details className="border-t border-slate-200">
|
|
<summary className="p-3 bg-slate-50 cursor-pointer text-sm font-medium text-slate-600 hover:bg-slate-100">
|
|
OCR-Text anzeigen
|
|
</summary>
|
|
<div className="p-4 max-h-48 overflow-auto text-sm text-slate-700 bg-slate-50">
|
|
<pre className="whitespace-pre-wrap font-sans">{student.ocr_text}</pre>
|
|
</div>
|
|
</details>
|
|
)}
|
|
</div>
|
|
|
|
{/* Right: Correction Panel (1/3) */}
|
|
<div className="w-1/3 bg-white rounded-lg border border-slate-200 overflow-hidden flex flex-col">
|
|
{/* Tabs */}
|
|
<div className="border-b border-slate-200">
|
|
<nav className="flex">
|
|
{[
|
|
{ id: 'kriterien' as const, label: 'Kriterien' },
|
|
{ id: 'annotationen' as const, label: `Notizen (${annotations.length})` },
|
|
{ id: 'gutachten' as const, label: 'Gutachten' },
|
|
{ id: 'eh-vorschlaege' as const, label: 'EH' },
|
|
].map(tab => (
|
|
<button
|
|
key={tab.id}
|
|
onClick={() => setActiveTab(tab.id)}
|
|
className={`flex-1 px-2 py-3 text-xs font-medium border-b-2 transition-colors ${
|
|
activeTab === tab.id
|
|
? 'border-primary-500 text-primary-600'
|
|
: 'border-transparent text-slate-500 hover:text-slate-700'
|
|
}`}
|
|
>
|
|
{tab.label}
|
|
</button>
|
|
))}
|
|
</nav>
|
|
</div>
|
|
|
|
{/* Tab content */}
|
|
<div className="flex-1 overflow-auto p-4">
|
|
{/* Kriterien Tab */}
|
|
{activeTab === 'kriterien' && gradeInfo && (
|
|
<div className="space-y-4">
|
|
{Object.entries(gradeInfo.criteria || {}).map(([key, criterion]) => {
|
|
const score = criteriaScores[key] || 0
|
|
const linkedAnnotations = annotations.filter(
|
|
(a) => a.linked_criterion === key || a.type === key
|
|
)
|
|
const errorCount = linkedAnnotations.length
|
|
const severityCounts = {
|
|
minor: linkedAnnotations.filter((a) => a.severity === 'minor').length,
|
|
major: linkedAnnotations.filter((a) => a.severity === 'major').length,
|
|
critical: linkedAnnotations.filter((a) => a.severity === 'critical').length,
|
|
}
|
|
const criterionColor = ANNOTATION_COLORS[key as AnnotationType] || '#6b7280'
|
|
|
|
return (
|
|
<div key={key} className="bg-slate-50 rounded-lg p-4">
|
|
<div className="flex justify-between items-center mb-2">
|
|
<div className="flex items-center gap-2">
|
|
<div
|
|
className="w-3 h-3 rounded-full"
|
|
style={{ backgroundColor: criterionColor }}
|
|
/>
|
|
<span className="font-medium text-slate-800">{criterion.name}</span>
|
|
<span className="text-xs text-slate-500">({criterion.weight}%)</span>
|
|
</div>
|
|
<div className="text-lg font-bold text-slate-800">{score}%</div>
|
|
</div>
|
|
|
|
{/* Annotation count for this criterion */}
|
|
{errorCount > 0 && (
|
|
<div className="flex items-center gap-2 mb-2 text-xs">
|
|
<span className="text-slate-500">{errorCount} Markierungen:</span>
|
|
{severityCounts.minor > 0 && (
|
|
<span className="px-1.5 py-0.5 bg-yellow-100 text-yellow-700 rounded">
|
|
{severityCounts.minor} leicht
|
|
</span>
|
|
)}
|
|
{severityCounts.major > 0 && (
|
|
<span className="px-1.5 py-0.5 bg-orange-100 text-orange-700 rounded">
|
|
{severityCounts.major} mittel
|
|
</span>
|
|
)}
|
|
{severityCounts.critical > 0 && (
|
|
<span className="px-1.5 py-0.5 bg-red-100 text-red-700 rounded">
|
|
{severityCounts.critical} schwer
|
|
</span>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{/* Slider */}
|
|
<input
|
|
type="range"
|
|
min="0"
|
|
max="100"
|
|
value={score}
|
|
onChange={(e) => handleCriteriaChange(key, parseInt(e.target.value))}
|
|
className="w-full h-2 bg-slate-200 rounded-lg appearance-none cursor-pointer"
|
|
style={{ accentColor: criterionColor }}
|
|
/>
|
|
|
|
{/* Quick buttons */}
|
|
<div className="flex gap-1 mt-2">
|
|
{[0, 25, 50, 75, 100].map((val) => (
|
|
<button
|
|
key={val}
|
|
onClick={() => handleCriteriaChange(key, val)}
|
|
className={`flex-1 py-1 text-xs rounded transition-colors ${
|
|
score === val
|
|
? 'text-white'
|
|
: 'bg-slate-200 text-slate-600 hover:bg-slate-300'
|
|
}`}
|
|
style={score === val ? { backgroundColor: criterionColor } : undefined}
|
|
>
|
|
{val}%
|
|
</button>
|
|
))}
|
|
</div>
|
|
|
|
{/* Quick add annotation button for RS/Grammatik */}
|
|
{(key === 'rechtschreibung' || key === 'grammatik') && (
|
|
<button
|
|
onClick={() => {
|
|
setSelectedTool(key as AnnotationType)
|
|
}}
|
|
className="mt-2 w-full py-1 text-xs border rounded hover:bg-slate-100 flex items-center justify-center gap-1"
|
|
style={{ borderColor: criterionColor, color: criterionColor }}
|
|
>
|
|
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path
|
|
strokeLinecap="round"
|
|
strokeLinejoin="round"
|
|
strokeWidth={2}
|
|
d="M12 4v16m8-8H4"
|
|
/>
|
|
</svg>
|
|
{key === 'rechtschreibung' ? 'RS-Fehler' : 'Grammatik-Fehler'} markieren
|
|
</button>
|
|
)}
|
|
</div>
|
|
)
|
|
})}
|
|
|
|
{/* Total and Generate button */}
|
|
<div className="border-t border-slate-200 pt-4 mt-4">
|
|
<div className="flex justify-between items-center mb-4">
|
|
<span className="font-semibold text-slate-800">Gesamtergebnis</span>
|
|
<div className="text-right">
|
|
<div className="text-2xl font-bold text-primary-600">
|
|
{totals.gradePoints} Punkte
|
|
</div>
|
|
<div className="text-sm text-slate-500">
|
|
({totals.weighted}%) - Note {GRADE_LABELS[totals.gradePoints]}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Workflow-aware action buttons */}
|
|
<div className="space-y-2">
|
|
{/* Generate Gutachten button */}
|
|
<button
|
|
onClick={generateGutachten}
|
|
disabled={generatingGutachten}
|
|
className="w-full py-2 bg-slate-100 text-slate-700 rounded-lg hover:bg-slate-200 disabled:opacity-50 flex items-center justify-center gap-2 text-sm"
|
|
>
|
|
{generatingGutachten ? (
|
|
<>
|
|
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-slate-700"></div>
|
|
Generiere Gutachten...
|
|
</>
|
|
) : (
|
|
<>
|
|
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
|
|
</svg>
|
|
Gutachten generieren
|
|
</>
|
|
)}
|
|
</button>
|
|
|
|
{/* Workflow action buttons based on status */}
|
|
{(!workflow || workflow.workflow_status === 'not_started' || workflow.workflow_status === 'ek_in_progress') && (
|
|
<button
|
|
onClick={submitErstkorrektur}
|
|
disabled={submittingWorkflow || !gutachten}
|
|
className="w-full py-3 bg-blue-600 text-white rounded-lg hover:bg-blue-700 disabled:opacity-50 flex items-center justify-center gap-2"
|
|
>
|
|
{submittingWorkflow ? (
|
|
<>
|
|
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white"></div>
|
|
Wird abgeschlossen...
|
|
</>
|
|
) : (
|
|
<>
|
|
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
|
|
</svg>
|
|
Erstkorrektur abschliessen
|
|
</>
|
|
)}
|
|
</button>
|
|
)}
|
|
|
|
{workflow?.workflow_status === 'ek_completed' && workflow.user_role === 'ek' && (
|
|
<button
|
|
onClick={() => {
|
|
const zkId = prompt('Zweitkorrektor-ID eingeben:')
|
|
if (zkId) startZweitkorrektur(zkId)
|
|
}}
|
|
disabled={submittingWorkflow}
|
|
className="w-full py-3 bg-amber-600 text-white rounded-lg hover:bg-amber-700 disabled:opacity-50 flex items-center justify-center gap-2"
|
|
>
|
|
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M17 8l4 4m0 0l-4 4m4-4H3" />
|
|
</svg>
|
|
Zur Zweitkorrektur weiterleiten
|
|
</button>
|
|
)}
|
|
|
|
{(workflow?.workflow_status === 'zk_assigned' || workflow?.workflow_status === 'zk_in_progress') &&
|
|
workflow?.user_role === 'zk' && (
|
|
<button
|
|
onClick={submitZweitkorrektur}
|
|
disabled={submittingWorkflow || !gutachten}
|
|
className="w-full py-3 bg-amber-600 text-white rounded-lg hover:bg-amber-700 disabled:opacity-50 flex items-center justify-center gap-2"
|
|
>
|
|
{submittingWorkflow ? (
|
|
<>
|
|
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white"></div>
|
|
Wird abgeschlossen...
|
|
</>
|
|
) : (
|
|
<>
|
|
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
|
|
</svg>
|
|
Zweitkorrektur abschliessen
|
|
</>
|
|
)}
|
|
</button>
|
|
)}
|
|
|
|
{workflow?.workflow_status === 'einigung_required' && (
|
|
<button
|
|
onClick={() => setShowEinigungModal(true)}
|
|
className="w-full py-3 bg-orange-600 text-white rounded-lg hover:bg-orange-700 flex items-center justify-center gap-2"
|
|
>
|
|
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z" />
|
|
</svg>
|
|
Einigung starten
|
|
</button>
|
|
)}
|
|
|
|
{workflow?.workflow_status === 'completed' && (
|
|
<div className="bg-green-100 text-green-800 p-4 rounded-lg text-center">
|
|
<div className="text-2xl font-bold">
|
|
Endnote: {workflow.final_grade} Punkte
|
|
</div>
|
|
<div className="text-sm mt-1">
|
|
({GRADE_LABELS[workflow.final_grade || 0]}) - {workflow.consensus_type === 'auto' ? 'Auto-Konsens' : workflow.consensus_type === 'drittkorrektur' ? 'Drittkorrektur' : 'Einigung'}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Show EK/ZK comparison when both results exist */}
|
|
{workflow?.first_result && workflow?.second_result && workflow?.workflow_status !== 'completed' && (
|
|
<div className="bg-slate-50 rounded-lg p-3 mt-2">
|
|
<div className="text-xs text-slate-500 mb-2">Notenvergleich</div>
|
|
<div className="flex justify-between">
|
|
<div className="text-center">
|
|
<div className="text-sm text-slate-500">EK</div>
|
|
<div className="font-bold text-blue-600">{workflow.first_result.grade_points}P</div>
|
|
</div>
|
|
<div className="text-center">
|
|
<div className="text-sm text-slate-500">ZK</div>
|
|
<div className="font-bold text-amber-600">{workflow.second_result.grade_points}P</div>
|
|
</div>
|
|
<div className="text-center">
|
|
<div className="text-sm text-slate-500">Diff</div>
|
|
<div className={`font-bold ${(workflow.grade_difference || 0) >= 4 ? 'text-red-600' : 'text-slate-700'}`}>
|
|
{workflow.grade_difference}P
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Annotationen Tab */}
|
|
{activeTab === 'annotationen' && (
|
|
<div className="h-full -m-4">
|
|
<AnnotationPanel
|
|
annotations={annotations}
|
|
selectedAnnotation={selectedAnnotation}
|
|
onSelectAnnotation={setSelectedAnnotation}
|
|
onUpdateAnnotation={updateAnnotation}
|
|
onDeleteAnnotation={deleteAnnotation}
|
|
/>
|
|
</div>
|
|
)}
|
|
|
|
{/* Gutachten Tab */}
|
|
{activeTab === 'gutachten' && (
|
|
<div className="h-full flex flex-col">
|
|
<textarea
|
|
value={gutachten}
|
|
onChange={(e) => setGutachten(e.target.value)}
|
|
placeholder="Gutachten hier eingeben oder generieren lassen..."
|
|
className="flex-1 w-full p-3 border border-slate-300 rounded-lg resize-none focus:ring-2 focus:ring-primary-500 focus:border-transparent text-sm"
|
|
/>
|
|
<div className="flex gap-2 mt-4">
|
|
<button
|
|
onClick={generateGutachten}
|
|
disabled={generatingGutachten}
|
|
className="flex-1 py-2 border border-primary-600 text-primary-600 rounded-lg hover:bg-primary-50 disabled:opacity-50"
|
|
>
|
|
{generatingGutachten ? 'Generiere...' : 'Neu generieren'}
|
|
</button>
|
|
<button
|
|
onClick={saveGutachten}
|
|
disabled={saving}
|
|
className="flex-1 py-2 bg-primary-600 text-white rounded-lg hover:bg-primary-700 disabled:opacity-50"
|
|
>
|
|
{saving ? 'Speichern...' : 'Speichern'}
|
|
</button>
|
|
</div>
|
|
|
|
{/* PDF Export */}
|
|
{gutachten && (
|
|
<div className="flex gap-2 mt-2 pt-2 border-t border-slate-200">
|
|
<button
|
|
onClick={exportGutachtenPDF}
|
|
disabled={exporting}
|
|
className="flex-1 py-2 border border-slate-300 text-slate-600 rounded-lg hover:bg-slate-50 disabled:opacity-50 flex items-center justify-center gap-2 text-sm"
|
|
>
|
|
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 10v6m0 0l-3-3m3 3l3-3m2 8H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
|
|
</svg>
|
|
{exporting ? 'Exportiere...' : 'Als PDF exportieren'}
|
|
</button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{/* EH-Vorschlaege Tab */}
|
|
{activeTab === 'eh-vorschlaege' && (
|
|
<div className="h-full -m-4">
|
|
<EHSuggestionPanel
|
|
studentId={studentId}
|
|
klausurId={klausurId}
|
|
hasEH={!!klausur?.eh_id || true} // Allow fetching even without linked EH (tenant-wide search)
|
|
apiBase={API_BASE}
|
|
onInsertSuggestion={(text, criterion) => {
|
|
// Append suggestion to gutachten
|
|
setGutachten((prev) =>
|
|
prev
|
|
? `${prev}\n\n[${criterion.toUpperCase()}]: ${text}`
|
|
: `[${criterion.toUpperCase()}]: ${text}`
|
|
)
|
|
setActiveTab('gutachten')
|
|
}}
|
|
/>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</AdminLayout>
|
|
)
|
|
}
|