[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:
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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'
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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' },
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user