[split-required] Split 58 monoliths across Python, Go, TypeScript (Phases 1-3)

Phase 1 — Python (klausur-service): 5 monoliths → 36 files
- dsfa_corpus_ingestion.py (1,828 LOC → 5 files)
- cv_ocr_engines.py (2,102 LOC → 7 files)
- cv_layout.py (3,653 LOC → 10 files)
- vocab_worksheet_api.py (2,783 LOC → 8 files)
- grid_build_core.py (1,958 LOC → 6 files)

Phase 2 — Go (edu-search-service, school-service): 8 monoliths → 19 files
- staff_crawler.go (1,402 → 4), policy/store.go (1,168 → 3)
- policy_handlers.go (700 → 2), repository.go (684 → 2)
- search.go (592 → 2), ai_extraction_handlers.go (554 → 2)
- seed_data.go (591 → 2), grade_service.go (646 → 2)

Phase 3 — TypeScript (admin-lehrer): 45 monoliths → 220+ files
- sdk/types.ts (2,108 → 16 domain files)
- ai/rag/page.tsx (2,686 → 14 files)
- 22 page.tsx files split into _components/ + _hooks/
- 11 component files split into sub-components
- 10 SDK data catalogs added to loc-exceptions
- Deleted dead backup index_original.ts (4,899 LOC)

All original public APIs preserved via re-export facades.
Zero new errors: Python imports verified, Go builds clean,
TypeScript tsc --noEmit shows only pre-existing errors.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Benjamin Admin
2026-04-24 17:28:57 +02:00
parent 9ba420fa91
commit b681ddb131
251 changed files with 30016 additions and 25037 deletions

View File

@@ -0,0 +1,252 @@
'use client'
/**
* Right panel (1/3 width): Tabs for Kriterien, Annotationen, Gutachten, EH-Vorschlaege.
*/
import { AnnotationPanel, EHSuggestionPanel } from '../../../components'
import type {
Klausur,
Annotation,
AnnotationType,
GradeInfo,
CriteriaScores,
} from '../../../types'
import type { ExaminerWorkflow, ActiveTab, GradeTotals } from './workspace-types'
import { API_BASE } from './workspace-types'
import { CriteriaTab } from './CriteriaTab'
interface CorrectionPanelProps {
// Data
klausur: Klausur | null
klausurId: string
studentId: string
annotations: Annotation[]
gradeInfo: GradeInfo | null
criteriaScores: CriteriaScores
gutachten: string
workflow: ExaminerWorkflow | null
totals: GradeTotals
selectedAnnotation: Annotation | null
// UI flags
activeTab: ActiveTab
generatingGutachten: boolean
saving: boolean
exporting: boolean
submittingWorkflow: boolean
// Actions
onSetActiveTab: (tab: ActiveTab) => void
onCriteriaChange: (criterion: string, value: number) => void
onSelectTool: (tool: AnnotationType) => void
onSelectAnnotation: (ann: Annotation | null) => void
onUpdateAnnotation: (id: string, updates: Partial<Annotation>) => Promise<void>
onDeleteAnnotation: (id: string) => Promise<void>
onSetGutachten: (text: string | ((prev: string) => string)) => void
onGenerateGutachten: () => Promise<void>
onSaveGutachten: () => Promise<void>
onExportGutachtenPDF: () => Promise<void>
onSubmitErstkorrektur: () => Promise<void>
onStartZweitkorrektur: (zkId: string) => Promise<void>
onSubmitZweitkorrektur: () => Promise<void>
onOpenEinigung: () => void
}
export function CorrectionPanel({
klausur,
klausurId,
studentId,
annotations,
gradeInfo,
criteriaScores,
gutachten,
workflow,
totals,
selectedAnnotation,
activeTab,
generatingGutachten,
saving,
exporting,
submittingWorkflow,
onSetActiveTab,
onCriteriaChange,
onSelectTool,
onSelectAnnotation,
onUpdateAnnotation,
onDeleteAnnotation,
onSetGutachten,
onGenerateGutachten,
onSaveGutachten,
onExportGutachtenPDF,
onSubmitErstkorrektur,
onStartZweitkorrektur,
onSubmitZweitkorrektur,
onOpenEinigung,
}: CorrectionPanelProps) {
return (
<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={() => onSetActiveTab(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 && (
<CriteriaTab
gradeInfo={gradeInfo}
criteriaScores={criteriaScores}
annotations={annotations}
totals={totals}
workflow={workflow}
generatingGutachten={generatingGutachten}
submittingWorkflow={submittingWorkflow}
gutachten={gutachten}
onCriteriaChange={onCriteriaChange}
onSelectTool={onSelectTool}
onGenerateGutachten={onGenerateGutachten}
onSubmitErstkorrektur={onSubmitErstkorrektur}
onStartZweitkorrektur={onStartZweitkorrektur}
onSubmitZweitkorrektur={onSubmitZweitkorrektur}
onOpenEinigung={onOpenEinigung}
/>
)}
{/* Annotationen Tab */}
{activeTab === 'annotationen' && (
<div className="h-full -m-4">
<AnnotationPanel
annotations={annotations}
selectedAnnotation={selectedAnnotation}
onSelectAnnotation={onSelectAnnotation}
onUpdateAnnotation={onUpdateAnnotation}
onDeleteAnnotation={onDeleteAnnotation}
/>
</div>
)}
{/* Gutachten Tab */}
{activeTab === 'gutachten' && (
<GutachtenTab
gutachten={gutachten}
generatingGutachten={generatingGutachten}
saving={saving}
exporting={exporting}
onSetGutachten={onSetGutachten}
onGenerateGutachten={onGenerateGutachten}
onSaveGutachten={onSaveGutachten}
onExportGutachtenPDF={onExportGutachtenPDF}
/>
)}
{/* EH-Vorschlaege Tab */}
{activeTab === 'eh-vorschlaege' && (
<div className="h-full -m-4">
<EHSuggestionPanel
studentId={studentId}
klausurId={klausurId}
hasEH={!!klausur?.eh_id || true}
apiBase={API_BASE}
onInsertSuggestion={(text, criterion) => {
onSetGutachten((prev: string) =>
prev
? `${prev}\n\n[${criterion.toUpperCase()}]: ${text}`
: `[${criterion.toUpperCase()}]: ${text}`,
)
onSetActiveTab('gutachten')
}}
/>
</div>
)}
</div>
</div>
)
}
// ---- Gutachten sub-component ----
interface GutachtenTabProps {
gutachten: string
generatingGutachten: boolean
saving: boolean
exporting: boolean
onSetGutachten: (text: string | ((prev: string) => string)) => void
onGenerateGutachten: () => void
onSaveGutachten: () => void
onExportGutachtenPDF: () => void
}
function GutachtenTab({
gutachten,
generatingGutachten,
saving,
exporting,
onSetGutachten,
onGenerateGutachten,
onSaveGutachten,
onExportGutachtenPDF,
}: GutachtenTabProps) {
return (
<div className="h-full flex flex-col">
<textarea
value={gutachten}
onChange={(e) => onSetGutachten(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={onGenerateGutachten}
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={onSaveGutachten}
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={onExportGutachtenPDF}
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>
)
}

View File

@@ -0,0 +1,384 @@
'use client'
/**
* Kriterien tab content: scoring sliders, annotation counts per criterion,
* totals summary, and workflow action buttons.
*/
import type { Annotation, AnnotationType, GradeInfo, CriteriaScores } from '../../../types'
import { ANNOTATION_COLORS } from '../../../types'
import type { ExaminerWorkflow, GradeTotals } from './workspace-types'
import { GRADE_LABELS } from './workspace-types'
interface CriteriaTabProps {
gradeInfo: GradeInfo
criteriaScores: CriteriaScores
annotations: Annotation[]
totals: GradeTotals
workflow: ExaminerWorkflow | null
generatingGutachten: boolean
submittingWorkflow: boolean
gutachten: string
onCriteriaChange: (criterion: string, value: number) => void
onSelectTool: (tool: AnnotationType) => void
onGenerateGutachten: () => void
onSubmitErstkorrektur: () => void
onStartZweitkorrektur: (zkId: string) => void
onSubmitZweitkorrektur: () => void
onOpenEinigung: () => void
}
export function CriteriaTab({
gradeInfo,
criteriaScores,
annotations,
totals,
workflow,
generatingGutachten,
submittingWorkflow,
gutachten,
onCriteriaChange,
onSelectTool,
onGenerateGutachten,
onSubmitErstkorrektur,
onStartZweitkorrektur,
onSubmitZweitkorrektur,
onOpenEinigung,
}: CriteriaTabProps) {
return (
<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) => onCriteriaChange(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={() => onCriteriaChange(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={() => onSelectTool(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 action buttons */}
<CriteriaTotals
totals={totals}
workflow={workflow}
generatingGutachten={generatingGutachten}
submittingWorkflow={submittingWorkflow}
gutachten={gutachten}
onGenerateGutachten={onGenerateGutachten}
onSubmitErstkorrektur={onSubmitErstkorrektur}
onStartZweitkorrektur={onStartZweitkorrektur}
onSubmitZweitkorrektur={onSubmitZweitkorrektur}
onOpenEinigung={onOpenEinigung}
/>
</div>
)
}
// ---- Sub-component: totals + workflow buttons ----
interface CriteriaTotalsProps {
totals: GradeTotals
workflow: ExaminerWorkflow | null
generatingGutachten: boolean
submittingWorkflow: boolean
gutachten: string
onGenerateGutachten: () => void
onSubmitErstkorrektur: () => void
onStartZweitkorrektur: (zkId: string) => void
onSubmitZweitkorrektur: () => void
onOpenEinigung: () => void
}
function CriteriaTotals({
totals,
workflow,
generatingGutachten,
submittingWorkflow,
gutachten,
onGenerateGutachten,
onSubmitErstkorrektur,
onStartZweitkorrektur,
onSubmitZweitkorrektur,
onOpenEinigung,
}: CriteriaTotalsProps) {
return (
<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>
<div className="space-y-2">
{/* Generate Gutachten */}
<button
onClick={onGenerateGutachten}
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 buttons */}
<WorkflowButtons
workflow={workflow}
submittingWorkflow={submittingWorkflow}
gutachten={gutachten}
totals={totals}
onSubmitErstkorrektur={onSubmitErstkorrektur}
onStartZweitkorrektur={onStartZweitkorrektur}
onSubmitZweitkorrektur={onSubmitZweitkorrektur}
onOpenEinigung={onOpenEinigung}
/>
</div>
</div>
)
}
// ---- Sub-component: workflow action buttons ----
interface WorkflowButtonsProps {
workflow: ExaminerWorkflow | null
submittingWorkflow: boolean
gutachten: string
totals: GradeTotals
onSubmitErstkorrektur: () => void
onStartZweitkorrektur: (zkId: string) => void
onSubmitZweitkorrektur: () => void
onOpenEinigung: () => void
}
function WorkflowButtons({
workflow,
submittingWorkflow,
gutachten,
totals,
onSubmitErstkorrektur,
onStartZweitkorrektur,
onSubmitZweitkorrektur,
onOpenEinigung,
}: WorkflowButtonsProps) {
return (
<>
{/* EK submit */}
{(!workflow || workflow.workflow_status === 'not_started' || workflow.workflow_status === 'ek_in_progress') && (
<button
onClick={onSubmitErstkorrektur}
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>
)}
{/* Forward to ZK */}
{workflow?.workflow_status === 'ek_completed' && workflow.user_role === 'ek' && (
<button
onClick={() => {
const zkId = prompt('Zweitkorrektor-ID eingeben:')
if (zkId) onStartZweitkorrektur(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>
)}
{/* ZK submit */}
{(workflow?.workflow_status === 'zk_assigned' || workflow?.workflow_status === 'zk_in_progress') &&
workflow?.user_role === 'zk' && (
<button
onClick={onSubmitZweitkorrektur}
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>
)}
{/* Einigung */}
{workflow?.workflow_status === 'einigung_required' && (
<button
onClick={onOpenEinigung}
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>
)}
{/* Completed */}
{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>
)}
{/* EK/ZK comparison */}
{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>
)}
</>
)
}

View File

@@ -0,0 +1,138 @@
'use client'
/**
* Left panel (2/3 width): Document viewer with annotation overlay,
* toolbar, page navigation, and collapsible OCR text.
*/
import { AnnotationLayer, AnnotationToolbar } from '../../../components'
import type { Annotation, AnnotationType, AnnotationPosition } from '../../../types'
interface DocumentViewerProps {
documentUrl: string | null
filePath?: string
ocrText?: string
zoom: number
currentPage: number
totalPages: number
annotations: Annotation[]
selectedTool: AnnotationType | null
selectedAnnotation: Annotation | null
annotationCounts: Record<AnnotationType, number>
onZoomChange: (zoom: number) => void
onSelectTool: (tool: AnnotationType | null) => void
onCurrentPageChange: (page: number | ((p: number) => number)) => void
onCreateAnnotation: (position: AnnotationPosition, type: AnnotationType) => void
onSelectAnnotation: (ann: Annotation) => void
}
export function DocumentViewer({
documentUrl,
filePath,
ocrText,
zoom,
currentPage,
totalPages,
annotations,
selectedTool,
selectedAnnotation,
annotationCounts,
onZoomChange,
onSelectTool,
onCurrentPageChange,
onCreateAnnotation,
onSelectAnnotation,
}: DocumentViewerProps) {
const pageAnnotations = annotations.filter((ann) => ann.page === currentPage)
return (
<div className="w-2/3 bg-white rounded-lg border border-slate-200 overflow-hidden flex flex-col">
{/* Toolbar */}
<AnnotationToolbar
selectedTool={selectedTool}
onSelectTool={onSelectTool}
zoom={zoom}
onZoomChange={onZoomChange}
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' }}
>
{filePath?.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={pageAnnotations}
selectedTool={selectedTool}
onCreateAnnotation={onCreateAnnotation}
onSelectAnnotation={onSelectAnnotation}
selectedAnnotationId={selectedAnnotation?.id}
/>
</div>
)}
</div>
) : (
<div className="flex items-center justify-center h-full text-slate-400">
Kein Dokument verfuegbar
</div>
)}
</div>
{/* Page navigation */}
<div className="border-t border-slate-200 p-2 flex items-center justify-center gap-2 bg-slate-50">
<button
onClick={() => onCurrentPageChange((p: number) => 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={() => onCurrentPageChange((p: number) => 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) */}
{ocrText && (
<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">{ocrText}</pre>
</div>
</details>
)}
</div>
)
}

View File

@@ -0,0 +1,115 @@
'use client'
/**
* Modal for the Einigung (agreement) process between EK and ZK.
*/
import type { ExaminerWorkflow } from './workspace-types'
import { GRADE_LABELS } from './workspace-types'
interface EinigungModalProps {
workflow: ExaminerWorkflow
einigungGrade: number
einigungNotes: string
submittingWorkflow: boolean
onGradeChange: (grade: number) => void
onNotesChange: (notes: string) => void
onSubmit: (type: 'agreed' | 'split' | 'escalated') => void
onClose: () => void
}
export function EinigungModal({
workflow,
einigungGrade,
einigungNotes,
submittingWorkflow,
onGradeChange,
onNotesChange,
onSubmit,
onClose,
}: EinigungModalProps) {
return (
<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) => onGradeChange(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) => onNotesChange(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={() => onSubmit('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={() => onSubmit('escalated')}
disabled={submittingWorkflow}
className="py-2 px-4 bg-red-100 text-red-700 rounded-lg hover:bg-red-200"
>
Eskalieren
</button>
<button
onClick={onClose}
className="py-2 px-4 bg-slate-100 text-slate-700 rounded-lg hover:bg-slate-200"
>
Abbrechen
</button>
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,114 @@
'use client'
/**
* Top navigation bar with back link, student navigation,
* workflow status badges, and grade display.
*/
import Link from 'next/link'
import type { ExaminerWorkflow, GradeTotals } from './workspace-types'
import { WORKFLOW_STATUS_LABELS, ROLE_LABELS, GRADE_LABELS } from './workspace-types'
interface TopNavigationBarProps {
klausurId: string
currentIndex: number
studentsCount: number
workflow: ExaminerWorkflow | null
saving: boolean
totals: GradeTotals
onGoToStudent: (direction: 'prev' | 'next') => void
}
export function TopNavigationBar({
klausurId,
currentIndex,
studentsCount,
workflow,
saving,
totals,
onGoToStudent,
}: TopNavigationBarProps) {
return (
<div className="bg-white border-b border-slate-200 px-6 py-3 flex items-center justify-between sticky top-0 z-10">
{/* Back link */}
<Link
href={`/education/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>
Zurueck
</Link>
{/* Student navigation */}
<div className="flex items-center gap-4">
<button
onClick={() => onGoToStudent('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} / {studentsCount}
</span>
<button
onClick={() => onGoToStudent('next')}
disabled={currentIndex >= studentsCount - 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">
{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>
<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>
{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>
)
}

View File

@@ -0,0 +1,8 @@
export { useKorrekturWorkspace } from './useKorrekturWorkspace'
export { TopNavigationBar } from './TopNavigationBar'
export { EinigungModal } from './EinigungModal'
export { DocumentViewer } from './DocumentViewer'
export { CorrectionPanel } from './CorrectionPanel'
export { CriteriaTab } from './CriteriaTab'
export type { ExaminerWorkflow, ActiveTab, GradeTotals } from './workspace-types'
export { API_BASE, GRADE_LABELS, WORKFLOW_STATUS_LABELS, ROLE_LABELS } from './workspace-types'

View File

@@ -0,0 +1,454 @@
'use client'
/**
* Custom hook encapsulating all state, data-fetching, CRUD operations,
* and workflow actions for the Korrektur-Workspace page.
*/
import { useState, useEffect, useCallback, useMemo } from 'react'
import type {
Klausur, StudentWork, Annotation, CriteriaScores,
GradeInfo, AnnotationType, AnnotationPosition,
} from '../../../types'
import type { ExaminerWorkflow, ActiveTab, GradeTotals } from './workspace-types'
import { API_BASE } from './workspace-types'
/** Download a blob from url and trigger browser download with given filename. */
async function downloadBlob(url: string, filename: string) {
const res = await fetch(url)
if (!res.ok) throw new Error('Download failed')
const blob = await res.blob()
const blobUrl = window.URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = blobUrl; a.download = filename
document.body.appendChild(a); a.click()
document.body.removeChild(a); window.URL.revokeObjectURL(blobUrl)
}
export function useKorrekturWorkspace(
klausurId: string,
studentId: string,
routerPush: (url: string) => void,
) {
// ---- Core data 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 [workflow, setWorkflow] = useState<ExaminerWorkflow | null>(null)
// ---- UI flags ----
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] = 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('')
// ---- Einigung state ----
const [showEinigungModal, setShowEinigungModal] = useState(false)
const [einigungGrade, setEinigungGrade] = useState<number>(0)
const [einigungNotes, setEinigungNotes] = useState('')
const [submittingWorkflow, setSubmittingWorkflow] = useState(false)
// ---- Derived ----
const currentIndex = students.findIndex(s => s.id === studentId)
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])
// ---- Grade calculation ----
const calculateTotalPoints = useCallback((): GradeTotals => {
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()
// ---- Data fetching ----
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])
useEffect(() => { fetchData() }, [fetchData])
// ---- Annotation CRUD ----
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])
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])
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])
// ---- Criteria ----
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])
const handleCriteriaChange = (criterion: string, value: number) => {
const newScores = { ...criteriaScores, [criterion]: value }
setCriteriaScores(newScores)
saveCriteriaScores(newScores)
}
// ---- 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])
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])
// ---- PDF Export ----
const exportGutachtenPDF = useCallback(async () => {
try {
setExporting(true); setError(null)
const name = student?.anonym_id?.replace(/\s+/g, '_') || 'Student'
await downloadBlob(
`${API_BASE}/api/v1/students/${studentId}/export/gutachten`,
`Gutachten_${name}_${new Date().toISOString().split('T')[0]}.pdf`,
)
} 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 name = student?.anonym_id?.replace(/\s+/g, '_') || 'Student'
await downloadBlob(
`${API_BASE}/api/v1/students/${studentId}/export/annotations`,
`Anmerkungen_${name}_${new Date().toISOString().split('T')[0]}.pdf`,
)
} catch (err) {
console.error('Failed to export annotations PDF:', err)
setError('Fehler beim PDF-Export')
} finally { setExporting(false) }
}, [studentId, student?.anonym_id])
// ---- Workflow actions ----
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 err = await assignRes.json()
throw new Error(err.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 err = await submitRes.json()
setError(err.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])
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 err = await res.json()
setError(err.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])
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 err = await res.json()
setError(err.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])
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 err = await res.json()
setError(err.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) {
routerPush(`/education/klausur-korrektur/${klausurId}/${students[newIndex].id}`)
}
}
return {
// State
klausur, student, students, annotations, gradeInfo, workflow,
loading, saving, error, activeTab, currentPage, totalPages,
zoom, documentUrl, generatingGutachten, exporting,
selectedTool, selectedAnnotation,
criteriaScores, gutachten,
showEinigungModal, einigungGrade, einigungNotes, submittingWorkflow,
currentIndex, annotationCounts, totals,
// Actions
setActiveTab, setCurrentPage, setZoom,
setSelectedTool, setSelectedAnnotation,
setGutachten, setError,
setShowEinigungModal, setEinigungGrade, setEinigungNotes,
createAnnotation, updateAnnotation, deleteAnnotation,
handleCriteriaChange, saveGutachten, generateGutachten,
exportGutachtenPDF, exportAnnotationsPDF,
submitErstkorrektur, startZweitkorrektur, submitZweitkorrektur, submitEinigung,
goToStudent,
}
}

View File

@@ -0,0 +1,93 @@
/**
* Types and constants for the Korrektur-Workspace page.
*/
import type { CriteriaScores } from '../../../types'
// ---- Examiner workflow types ----
export interface ExaminerInfo {
id: string
assigned_at: string
notes?: string
}
export interface ExaminerResult {
grade_points: number
criteria_scores?: CriteriaScores
notes?: string
submitted_at: string
}
export 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
}
// ---- Active tab ----
export type ActiveTab = 'kriterien' | 'gutachten' | 'annotationen' | 'eh-vorschlaege'
// ---- Totals from grade calculation ----
export interface GradeTotals {
raw: number
weighted: number
gradePoints: number
}
// ---- Constants ----
/** Same-origin proxy to avoid CORS issues */
export const API_BASE = '/klausur-api'
export 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',
}
export 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' },
}
export 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' },
}