[split-required] Split final batch of monoliths >1000 LOC
Python (6 files in klausur-service): - rbac.py (1,132 → 4), admin_api.py (1,012 → 4) - routes/eh.py (1,111 → 4), ocr_pipeline_geometry.py (1,105 → 5) Python (2 files in backend-lehrer): - unit_api.py (1,226 → 6), game_api.py (1,129 → 5) Website (6 page files): - 4x klausur-korrektur pages (1,249-1,328 LOC each) → shared components in website/components/klausur-korrektur/ (17 shared files) - companion (1,057 → 10), magic-help (1,017 → 8) All re-export barrels preserve backward compatibility. Zero import errors verified. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
227
website/components/klausur-korrektur/CorrectionPanel.tsx
Normal file
227
website/components/klausur-korrektur/CorrectionPanel.tsx
Normal file
@@ -0,0 +1,227 @@
|
||||
'use client'
|
||||
|
||||
/**
|
||||
* Right panel (1/3 width) for the Korrektur-Workspace.
|
||||
* Contains tabs: Kriterien, Annotationen, Gutachten, EH-Vorschlaege.
|
||||
*/
|
||||
|
||||
import type {
|
||||
Annotation, CriteriaScores, GradeInfo, AnnotationType,
|
||||
} from '../../app/admin/klausur-korrektur/types'
|
||||
import { ANNOTATION_COLORS } from '../../app/admin/klausur-korrektur/types'
|
||||
import type { ExaminerWorkflow, ActiveTab } from './workspace-types'
|
||||
import { GRADE_LABELS } from './workspace-types'
|
||||
import CriteriaTab from './CriteriaTab'
|
||||
import WorkflowActions from './WorkflowActions'
|
||||
|
||||
interface CorrectionPanelProps {
|
||||
activeTab: ActiveTab
|
||||
onTabChange: (tab: ActiveTab) => void
|
||||
annotations: Annotation[]
|
||||
gradeInfo: GradeInfo | null
|
||||
criteriaScores: CriteriaScores
|
||||
gutachten: string
|
||||
totals: { gradePoints: number; weighted: number }
|
||||
workflow: ExaminerWorkflow | null
|
||||
saving: boolean
|
||||
generatingGutachten: boolean
|
||||
exporting: boolean
|
||||
submittingWorkflow: boolean
|
||||
selectedAnnotation: Annotation | null
|
||||
studentId: string
|
||||
klausurId: string
|
||||
klausurEhId?: string
|
||||
onCriteriaChange: (criterion: string, value: number) => void
|
||||
onGutachtenChange: (text: string) => void
|
||||
onSaveGutachten: () => void
|
||||
onGenerateGutachten: () => void
|
||||
onExportGutachtenPDF: () => void
|
||||
onSelectAnnotation: (ann: Annotation | null) => void
|
||||
onUpdateAnnotation: (id: string, updates: Partial<Annotation>) => void
|
||||
onDeleteAnnotation: (id: string) => void
|
||||
onSelectTool: (tool: AnnotationType) => void
|
||||
onSetActiveTab: (tab: ActiveTab) => void
|
||||
onSubmitErstkorrektur: () => void
|
||||
onStartZweitkorrektur: (id: string) => void
|
||||
onSubmitZweitkorrektur: () => void
|
||||
onShowEinigungModal: () => void
|
||||
// Render props for route-specific components
|
||||
AnnotationPanelComponent: React.ComponentType<{
|
||||
annotations: Annotation[]
|
||||
selectedAnnotation: Annotation | null
|
||||
onSelectAnnotation: (ann: Annotation | null) => void
|
||||
onUpdateAnnotation: (id: string, updates: Partial<Annotation>) => void
|
||||
onDeleteAnnotation: (id: string) => void
|
||||
}>
|
||||
EHSuggestionPanelComponent: React.ComponentType<{
|
||||
studentId: string
|
||||
klausurId: string
|
||||
hasEH: boolean
|
||||
apiBase: string
|
||||
onInsertSuggestion: (text: string, criterion: string) => void
|
||||
}>
|
||||
}
|
||||
|
||||
export default function CorrectionPanel(props: CorrectionPanelProps) {
|
||||
const {
|
||||
activeTab, onTabChange, annotations, gradeInfo, criteriaScores, gutachten,
|
||||
totals, workflow, saving, generatingGutachten, exporting, submittingWorkflow,
|
||||
selectedAnnotation, studentId, klausurId, klausurEhId,
|
||||
onCriteriaChange, onGutachtenChange, onSaveGutachten, onGenerateGutachten,
|
||||
onExportGutachtenPDF, onSelectAnnotation, onUpdateAnnotation, onDeleteAnnotation,
|
||||
onSelectTool, onSetActiveTab, onSubmitErstkorrektur, onStartZweitkorrektur,
|
||||
onSubmitZweitkorrektur, onShowEinigungModal,
|
||||
AnnotationPanelComponent, EHSuggestionPanelComponent,
|
||||
} = props
|
||||
|
||||
const apiBase = process.env.NEXT_PUBLIC_KLAUSUR_SERVICE_URL || 'http://localhost:8086'
|
||||
|
||||
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={() => onTabChange(tab.id)}
|
||||
className={`flex-1 px-2 py-3 text-xs font-medium border-b-2 transition-colors ${
|
||||
activeTab === tab.id
|
||||
? 'border-primary-500 text-primary-600'
|
||||
: 'border-transparent text-slate-500 hover:text-slate-700'
|
||||
}`}
|
||||
>
|
||||
{tab.label}
|
||||
</button>
|
||||
))}
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
{/* Tab content */}
|
||||
<div className="flex-1 overflow-auto p-4">
|
||||
{/* Kriterien Tab */}
|
||||
{activeTab === 'kriterien' && gradeInfo && (
|
||||
<div className="space-y-4">
|
||||
<CriteriaTab
|
||||
gradeInfo={gradeInfo}
|
||||
criteriaScores={criteriaScores}
|
||||
annotations={annotations}
|
||||
onCriteriaChange={onCriteriaChange}
|
||||
onSelectTool={onSelectTool}
|
||||
/>
|
||||
|
||||
{/* Total and workflow actions */}
|
||||
<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>
|
||||
|
||||
<WorkflowActions
|
||||
workflow={workflow}
|
||||
gutachten={gutachten}
|
||||
generatingGutachten={generatingGutachten}
|
||||
submittingWorkflow={submittingWorkflow}
|
||||
totals={totals}
|
||||
onGenerateGutachten={onGenerateGutachten}
|
||||
onSubmitErstkorrektur={onSubmitErstkorrektur}
|
||||
onStartZweitkorrektur={onStartZweitkorrektur}
|
||||
onSubmitZweitkorrektur={onSubmitZweitkorrektur}
|
||||
onShowEinigungModal={onShowEinigungModal}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Annotationen Tab */}
|
||||
{activeTab === 'annotationen' && (
|
||||
<div className="h-full -m-4">
|
||||
<AnnotationPanelComponent
|
||||
annotations={annotations}
|
||||
selectedAnnotation={selectedAnnotation}
|
||||
onSelectAnnotation={onSelectAnnotation}
|
||||
onUpdateAnnotation={onUpdateAnnotation}
|
||||
onDeleteAnnotation={onDeleteAnnotation}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Gutachten Tab */}
|
||||
{activeTab === 'gutachten' && (
|
||||
<div className="h-full flex flex-col">
|
||||
<textarea
|
||||
value={gutachten}
|
||||
onChange={(e) => onGutachtenChange(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>
|
||||
)}
|
||||
|
||||
{/* EH-Vorschlaege Tab */}
|
||||
{activeTab === 'eh-vorschlaege' && (
|
||||
<div className="h-full -m-4">
|
||||
<EHSuggestionPanelComponent
|
||||
studentId={studentId}
|
||||
klausurId={klausurId}
|
||||
hasEH={!!klausurEhId || true}
|
||||
apiBase={apiBase}
|
||||
onInsertSuggestion={(text, criterion) => {
|
||||
onGutachtenChange(
|
||||
gutachten
|
||||
? `${gutachten}\n\n[${criterion.toUpperCase()}]: ${text}`
|
||||
: `[${criterion.toUpperCase()}]: ${text}`
|
||||
)
|
||||
onSetActiveTab('gutachten')
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
120
website/components/klausur-korrektur/CriteriaTab.tsx
Normal file
120
website/components/klausur-korrektur/CriteriaTab.tsx
Normal file
@@ -0,0 +1,120 @@
|
||||
'use client'
|
||||
|
||||
/**
|
||||
* Criteria scoring tab content.
|
||||
* Shows sliders and annotation counts for each grading criterion.
|
||||
*/
|
||||
|
||||
import type { Annotation, GradeInfo, CriteriaScores, AnnotationType } from '../../app/admin/klausur-korrektur/types'
|
||||
import { ANNOTATION_COLORS } from '../../app/admin/klausur-korrektur/types'
|
||||
|
||||
interface CriteriaTabProps {
|
||||
gradeInfo: GradeInfo
|
||||
criteriaScores: CriteriaScores
|
||||
annotations: Annotation[]
|
||||
onCriteriaChange: (criterion: string, value: number) => void
|
||||
onSelectTool: (tool: AnnotationType) => void
|
||||
}
|
||||
|
||||
export default function CriteriaTab({
|
||||
gradeInfo, criteriaScores, annotations, onCriteriaChange, onSelectTool,
|
||||
}: CriteriaTabProps) {
|
||||
return (
|
||||
<>
|
||||
{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>
|
||||
)
|
||||
})}
|
||||
</>
|
||||
)
|
||||
}
|
||||
285
website/components/klausur-korrektur/DirektuploadTab.tsx
Normal file
285
website/components/klausur-korrektur/DirektuploadTab.tsx
Normal file
@@ -0,0 +1,285 @@
|
||||
'use client'
|
||||
|
||||
/**
|
||||
* Direct upload wizard tab (3 steps).
|
||||
* Allows quick upload of student work files without creating a klausur first.
|
||||
*/
|
||||
|
||||
import type { DirektuploadForm, TabId } from './list-types'
|
||||
|
||||
interface DirektuploadTabProps {
|
||||
direktForm: DirektuploadForm
|
||||
direktStep: 1 | 2 | 3
|
||||
uploading: boolean
|
||||
onFormChange: (form: DirektuploadForm) => void
|
||||
onStepChange: (step: 1 | 2 | 3) => void
|
||||
onUpload: () => void
|
||||
onCancel: () => void
|
||||
}
|
||||
|
||||
export default function DirektuploadTab({
|
||||
direktForm, direktStep, uploading,
|
||||
onFormChange, onStepChange, onUpload, onCancel,
|
||||
}: DirektuploadTabProps) {
|
||||
return (
|
||||
<div className="max-w-3xl mx-auto">
|
||||
<div className="bg-white rounded-xl border border-slate-200 shadow-sm overflow-hidden">
|
||||
{/* Progress Header */}
|
||||
<div className="bg-slate-50 border-b border-slate-200 px-6 py-4">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<h2 className="text-lg font-semibold text-slate-800">Schnellstart - Direkt Korrigieren</h2>
|
||||
<button onClick={onCancel} className="text-sm text-slate-500 hover:text-slate-700">
|
||||
Abbrechen
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{[1, 2, 3].map((step) => (
|
||||
<div key={step} className="flex items-center gap-2 flex-1">
|
||||
<div className={`w-8 h-8 rounded-full flex items-center justify-center text-sm font-medium ${
|
||||
direktStep >= step ? 'bg-blue-600 text-white' : 'bg-slate-200 text-slate-500'
|
||||
}`}>
|
||||
{step}
|
||||
</div>
|
||||
<span className={`text-sm ${direktStep >= step ? 'text-slate-800' : 'text-slate-400'}`}>
|
||||
{step === 1 ? 'Arbeiten' : step === 2 ? 'Erwartungshorizont' : 'Starten'}
|
||||
</span>
|
||||
{step < 3 && <div className={`flex-1 h-1 rounded ${direktStep > step ? 'bg-blue-600' : 'bg-slate-200'}`} />}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-6">
|
||||
{/* Step 1: Upload Files */}
|
||||
{direktStep === 1 && (
|
||||
<Step1Files
|
||||
files={direktForm.files}
|
||||
onFilesChange={(files) => onFormChange({ ...direktForm, files })}
|
||||
onNext={() => onStepChange(2)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Step 2: EH */}
|
||||
{direktStep === 2 && (
|
||||
<Step2EH
|
||||
aufgabentyp={direktForm.aufgabentyp}
|
||||
ehText={direktForm.ehText}
|
||||
onAufgabentypChange={(v) => onFormChange({ ...direktForm, aufgabentyp: v })}
|
||||
onEhTextChange={(v) => onFormChange({ ...direktForm, ehText: v })}
|
||||
onBack={() => onStepChange(1)}
|
||||
onNext={() => onStepChange(3)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Step 3: Confirm */}
|
||||
{direktStep === 3 && (
|
||||
<Step3Confirm
|
||||
direktForm={direktForm}
|
||||
uploading={uploading}
|
||||
onTitleChange={(v) => onFormChange({ ...direktForm, klausurTitle: v })}
|
||||
onBack={() => onStepChange(2)}
|
||||
onUpload={onUpload}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// --- Sub-components for each step ---
|
||||
|
||||
function Step1Files({ files, onFilesChange, onNext }: {
|
||||
files: File[]; onFilesChange: (f: File[]) => void; onNext: () => void
|
||||
}) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h3 className="font-medium text-slate-800 mb-2">Schuelerarbeiten hochladen</h3>
|
||||
<p className="text-sm text-slate-500 mb-4">
|
||||
Laden Sie die eingescannten Klausuren hoch. Unterstuetzte Formate: PDF, JPG, PNG.
|
||||
</p>
|
||||
<div
|
||||
className={`border-2 border-dashed rounded-xl p-8 text-center transition-colors ${
|
||||
files.length > 0 ? 'border-green-300 bg-green-50' : 'border-slate-300 hover:border-blue-400 hover:bg-blue-50'
|
||||
}`}
|
||||
onDrop={(e) => {
|
||||
e.preventDefault()
|
||||
onFilesChange([...files, ...Array.from(e.dataTransfer.files)])
|
||||
}}
|
||||
onDragOver={(e) => e.preventDefault()}
|
||||
>
|
||||
<svg className="w-12 h-12 mx-auto text-slate-400 mb-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12" />
|
||||
</svg>
|
||||
<p className="text-slate-600 mb-2">Dateien hier ablegen oder</p>
|
||||
<label className="inline-block px-4 py-2 bg-blue-600 text-white rounded-lg cursor-pointer hover:bg-blue-700">
|
||||
Dateien auswaehlen
|
||||
<input type="file" multiple accept=".pdf,.jpg,.jpeg,.png" className="hidden"
|
||||
onChange={(e) => onFilesChange([...files, ...Array.from(e.target.files || [])])}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{files.length > 0 && (
|
||||
<div className="mt-4 space-y-2">
|
||||
<div className="flex items-center justify-between text-sm text-slate-600">
|
||||
<span>{files.length} Datei{files.length !== 1 ? 'en' : ''} ausgewaehlt</span>
|
||||
<button onClick={() => onFilesChange([])} className="text-red-600 hover:text-red-700">Alle entfernen</button>
|
||||
</div>
|
||||
<div className="max-h-40 overflow-y-auto space-y-1">
|
||||
{files.map((file, idx) => (
|
||||
<div key={idx} className="flex items-center justify-between bg-slate-50 px-3 py-2 rounded-lg text-sm">
|
||||
<span className="truncate">{file.name}</span>
|
||||
<button
|
||||
onClick={() => onFilesChange(files.filter((_, i) => i !== idx))}
|
||||
className="text-slate-400 hover:text-red-600"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex justify-end">
|
||||
<button
|
||||
onClick={onNext}
|
||||
disabled={files.length === 0}
|
||||
className="px-6 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
Weiter
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function Step2EH({ aufgabentyp, ehText, onAufgabentypChange, onEhTextChange, onBack, onNext }: {
|
||||
aufgabentyp: string; ehText: string
|
||||
onAufgabentypChange: (v: string) => void; onEhTextChange: (v: string) => void
|
||||
onBack: () => void; onNext: () => void
|
||||
}) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h3 className="font-medium text-slate-800 mb-2">Erwartungshorizont (optional)</h3>
|
||||
<p className="text-sm text-slate-500 mb-4">
|
||||
Laden Sie Ihren eigenen Erwartungshorizont hoch oder beschreiben Sie die Aufgabenstellung.
|
||||
Dies hilft der KI, passendere Bewertungen vorzuschlagen.
|
||||
</p>
|
||||
<div className="mb-4">
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1">Aufgabentyp</label>
|
||||
<select
|
||||
value={aufgabentyp}
|
||||
onChange={(e) => onAufgabentypChange(e.target.value)}
|
||||
className="w-full px-3 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||
>
|
||||
<option value="">-- Waehlen Sie einen Aufgabentyp --</option>
|
||||
<option value="textanalyse_pragmatisch">Textanalyse (Sachtexte)</option>
|
||||
<option value="gedichtanalyse">Gedichtanalyse</option>
|
||||
<option value="prosaanalyse">Prosaanalyse</option>
|
||||
<option value="dramenanalyse">Dramenanalyse</option>
|
||||
<option value="eroerterung_textgebunden">Textgebundene Eroerterung</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1">Aufgabenstellung / Erwartungshorizont</label>
|
||||
<textarea
|
||||
value={ehText}
|
||||
onChange={(e) => onEhTextChange(e.target.value)}
|
||||
placeholder="Beschreiben Sie hier die Aufgabenstellung und Ihre Erwartungen an eine gute Loesung..."
|
||||
rows={6}
|
||||
className="w-full px-3 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent resize-none"
|
||||
/>
|
||||
<p className="mt-1 text-xs text-slate-500">
|
||||
Je detaillierter Sie die Erwartungen beschreiben, desto besser werden die KI-Vorschlaege.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<button onClick={onBack} className="px-6 py-2 text-slate-600 hover:bg-slate-100 rounded-lg">Zurueck</button>
|
||||
<button onClick={onNext} className="px-6 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700">Weiter</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function Step3Confirm({ direktForm, uploading, onTitleChange, onBack, onUpload }: {
|
||||
direktForm: DirektuploadForm; uploading: boolean
|
||||
onTitleChange: (v: string) => void; onBack: () => void; onUpload: () => void
|
||||
}) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h3 className="font-medium text-slate-800 mb-2">Zusammenfassung</h3>
|
||||
<p className="text-sm text-slate-500 mb-4">Pruefen Sie Ihre Eingaben und starten Sie die Korrektur.</p>
|
||||
<div className="bg-slate-50 rounded-lg p-4 space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm text-slate-600">Titel</span>
|
||||
<input
|
||||
type="text" value={direktForm.klausurTitle}
|
||||
onChange={(e) => onTitleChange(e.target.value)}
|
||||
className="text-sm font-medium text-slate-800 bg-white border border-slate-200 rounded px-2 py-1 text-right"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm text-slate-600">Anzahl Arbeiten</span>
|
||||
<span className="text-sm font-medium text-slate-800">{direktForm.files.length}</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm text-slate-600">Aufgabentyp</span>
|
||||
<span className="text-sm font-medium text-slate-800">{direktForm.aufgabentyp || 'Nicht angegeben'}</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm text-slate-600">Erwartungshorizont</span>
|
||||
<span className="text-sm font-medium text-slate-800">{direktForm.ehText ? 'Vorhanden' : 'Nicht angegeben'}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 p-4 bg-blue-50 border border-blue-200 rounded-lg">
|
||||
<div className="flex items-start gap-3">
|
||||
<svg className="w-5 h-5 text-blue-600 mt-0.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
<div className="text-sm text-blue-800">
|
||||
<p className="font-medium">Was passiert jetzt?</p>
|
||||
<ol className="list-decimal list-inside mt-1 space-y-1 text-blue-700">
|
||||
<li>Eine neue Klausur wird automatisch erstellt</li>
|
||||
<li>Alle {direktForm.files.length} Arbeiten werden hochgeladen</li>
|
||||
<li>OCR-Erkennung der Handschrift startet automatisch</li>
|
||||
<li>Sie werden zur Korrektur-Ansicht weitergeleitet</li>
|
||||
</ol>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-between">
|
||||
<button onClick={onBack} className="px-6 py-2 text-slate-600 hover:bg-slate-100 rounded-lg">Zurueck</button>
|
||||
<button
|
||||
onClick={onUpload}
|
||||
disabled={uploading}
|
||||
className="px-6 py-2 bg-green-600 text-white rounded-lg hover:bg-green-700 disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-2"
|
||||
>
|
||||
{uploading ? (
|
||||
<>
|
||||
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white"></div>
|
||||
Wird hochgeladen...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 10V3L4 14h7v7l9-11h-7z" />
|
||||
</svg>
|
||||
Korrektur starten
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
139
website/components/klausur-korrektur/DocumentViewer.tsx
Normal file
139
website/components/klausur-korrektur/DocumentViewer.tsx
Normal file
@@ -0,0 +1,139 @@
|
||||
'use client'
|
||||
|
||||
/**
|
||||
* Document Viewer with annotation overlay and page navigation.
|
||||
* Left panel (2/3 width) in the Korrektur-Workspace.
|
||||
*/
|
||||
|
||||
import type { Annotation, AnnotationType, AnnotationPosition, StudentWork } from '../../app/admin/klausur-korrektur/types'
|
||||
|
||||
// Re-use existing annotation components from the klausur-korrektur route
|
||||
interface DocumentViewerProps {
|
||||
student: StudentWork | null
|
||||
documentUrl: string | null
|
||||
zoom: number
|
||||
currentPage: number
|
||||
totalPages: number
|
||||
annotations: Annotation[]
|
||||
selectedTool: AnnotationType | null
|
||||
selectedAnnotation: Annotation | null
|
||||
annotationCounts: Record<AnnotationType, number>
|
||||
onZoomChange: (zoom: number) => void
|
||||
onPageChange: (page: number) => void
|
||||
onSelectTool: (tool: AnnotationType | null) => void
|
||||
onCreateAnnotation: (position: AnnotationPosition, type: AnnotationType) => void
|
||||
onSelectAnnotation: (ann: Annotation) => void
|
||||
// Render props for toolbar and annotation layer since they are imported from route-local components
|
||||
AnnotationToolbarComponent: React.ComponentType<{
|
||||
selectedTool: AnnotationType | null
|
||||
onSelectTool: (tool: AnnotationType | null) => void
|
||||
zoom: number
|
||||
onZoomChange: (zoom: number) => void
|
||||
annotationCounts: Record<AnnotationType, number>
|
||||
}>
|
||||
AnnotationLayerComponent: React.ComponentType<{
|
||||
annotations: Annotation[]
|
||||
selectedTool: AnnotationType | null
|
||||
onCreateAnnotation: (position: AnnotationPosition, type: AnnotationType) => void
|
||||
onSelectAnnotation: (ann: Annotation) => void
|
||||
selectedAnnotationId?: string
|
||||
}>
|
||||
}
|
||||
|
||||
export default function DocumentViewer({
|
||||
student, documentUrl, zoom, currentPage, totalPages,
|
||||
annotations, selectedTool, selectedAnnotation, annotationCounts,
|
||||
onZoomChange, onPageChange, onSelectTool,
|
||||
onCreateAnnotation, onSelectAnnotation,
|
||||
AnnotationToolbarComponent, AnnotationLayerComponent,
|
||||
}: DocumentViewerProps) {
|
||||
return (
|
||||
<div className="w-2/3 bg-white rounded-lg border border-slate-200 overflow-hidden flex flex-col">
|
||||
{/* Toolbar */}
|
||||
<AnnotationToolbarComponent
|
||||
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' }}
|
||||
>
|
||||
{student?.file_path?.endsWith('.pdf') ? (
|
||||
<iframe
|
||||
src={documentUrl}
|
||||
className="w-full h-[800px] border-0"
|
||||
title="Studentenarbeit"
|
||||
/>
|
||||
) : (
|
||||
<div className="relative">
|
||||
<img
|
||||
src={documentUrl}
|
||||
alt="Studentenarbeit"
|
||||
className="max-w-full"
|
||||
onError={(e) => {
|
||||
(e.target as HTMLImageElement).src = '/placeholder-document.png'
|
||||
}}
|
||||
/>
|
||||
<AnnotationLayerComponent
|
||||
annotations={annotations.filter((ann) => ann.page === currentPage)}
|
||||
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={() => onPageChange(Math.max(1, currentPage - 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={() => onPageChange(Math.min(totalPages, currentPage + 1))}
|
||||
disabled={currentPage >= totalPages}
|
||||
className="p-1 rounded hover:bg-slate-200 disabled:opacity-50"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* OCR Text (collapsible) */}
|
||||
{student?.ocr_text && (
|
||||
<details className="border-t border-slate-200">
|
||||
<summary className="p-3 bg-slate-50 cursor-pointer text-sm font-medium text-slate-600 hover:bg-slate-100">
|
||||
OCR-Text anzeigen
|
||||
</summary>
|
||||
<div className="p-4 max-h-48 overflow-auto text-sm text-slate-700 bg-slate-50">
|
||||
<pre className="whitespace-pre-wrap font-sans">{student.ocr_text}</pre>
|
||||
</div>
|
||||
</details>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
110
website/components/klausur-korrektur/EinigungModal.tsx
Normal file
110
website/components/klausur-korrektur/EinigungModal.tsx
Normal file
@@ -0,0 +1,110 @@
|
||||
'use client'
|
||||
|
||||
/**
|
||||
* Einigung (Consensus) Modal.
|
||||
* Shown when first and second examiner grade difference requires manual resolution.
|
||||
*/
|
||||
|
||||
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 default 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>
|
||||
)
|
||||
}
|
||||
26
website/components/klausur-korrektur/ErrorBanner.tsx
Normal file
26
website/components/klausur-korrektur/ErrorBanner.tsx
Normal file
@@ -0,0 +1,26 @@
|
||||
'use client'
|
||||
|
||||
/**
|
||||
* Error banner component for displaying dismissible error messages.
|
||||
*/
|
||||
|
||||
interface ErrorBannerProps {
|
||||
error: string
|
||||
onDismiss: () => void
|
||||
}
|
||||
|
||||
export default function ErrorBanner({ error, onDismiss }: ErrorBannerProps) {
|
||||
return (
|
||||
<div className="mb-4 p-4 bg-red-50 border border-red-200 rounded-lg flex items-center gap-3">
|
||||
<svg className="w-5 h-5 text-red-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
<span className="text-red-800">{error}</span>
|
||||
<button onClick={onDismiss} className="ml-auto text-red-600 hover:text-red-800">
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
208
website/components/klausur-korrektur/ErstellenTab.tsx
Normal file
208
website/components/klausur-korrektur/ErstellenTab.tsx
Normal file
@@ -0,0 +1,208 @@
|
||||
'use client'
|
||||
|
||||
/**
|
||||
* Create new Klausur form tab.
|
||||
* Supports both Abitur and Vorabitur modes with EH template selection.
|
||||
*/
|
||||
|
||||
import type { TabId, CreateKlausurForm, VorabiturEHForm, EHTemplate } from './list-types'
|
||||
|
||||
interface ErstellenTabProps {
|
||||
form: CreateKlausurForm
|
||||
ehForm: VorabiturEHForm
|
||||
templates: EHTemplate[]
|
||||
creating: boolean
|
||||
loadingTemplates: boolean
|
||||
onFormChange: (form: CreateKlausurForm) => void
|
||||
onEhFormChange: (form: VorabiturEHForm) => void
|
||||
onSubmit: (e: React.FormEvent) => void
|
||||
onCancel: () => void
|
||||
}
|
||||
|
||||
export default function ErstellenTab({
|
||||
form, ehForm, templates, creating, loadingTemplates,
|
||||
onFormChange, onEhFormChange, onSubmit, onCancel,
|
||||
}: ErstellenTabProps) {
|
||||
return (
|
||||
<div className="max-w-2xl mx-auto">
|
||||
<div className="bg-white rounded-lg border border-slate-200 shadow-sm p-6">
|
||||
<h2 className="text-lg font-semibold text-slate-800 mb-6">Neue Klausur erstellen</h2>
|
||||
|
||||
<form onSubmit={onSubmit} className="space-y-4">
|
||||
{/* Title */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1">Titel der Klausur *</label>
|
||||
<input
|
||||
type="text"
|
||||
value={form.title}
|
||||
onChange={(e) => onFormChange({ ...form, title: e.target.value })}
|
||||
placeholder="z.B. Deutsch LK Abitur 2025 - Kurs D1"
|
||||
className="w-full px-3 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-transparent"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Subject + Year */}
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1">Fach</label>
|
||||
<select
|
||||
value={form.subject}
|
||||
onChange={(e) => onFormChange({ ...form, subject: e.target.value })}
|
||||
className="w-full px-3 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-transparent"
|
||||
>
|
||||
<option value="Deutsch">Deutsch</option>
|
||||
<option value="Englisch">Englisch</option>
|
||||
<option value="Mathematik">Mathematik</option>
|
||||
<option value="Geschichte">Geschichte</option>
|
||||
<option value="Biologie">Biologie</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1">Jahr</label>
|
||||
<input
|
||||
type="number"
|
||||
value={form.year}
|
||||
onChange={(e) => onFormChange({ ...form, year: parseInt(e.target.value) })}
|
||||
min={2020} max={2030}
|
||||
className="w-full px-3 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-transparent"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Semester + Modus */}
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1">Semester / Pruefung</label>
|
||||
<select
|
||||
value={form.semester}
|
||||
onChange={(e) => onFormChange({ ...form, semester: e.target.value })}
|
||||
className="w-full px-3 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-transparent"
|
||||
>
|
||||
<option value="Abitur">Abitur</option>
|
||||
<option value="Q1">Q1 (11/1)</option>
|
||||
<option value="Q2">Q2 (11/2)</option>
|
||||
<option value="Q3">Q3 (12/1)</option>
|
||||
<option value="Q4">Q4 (12/2)</option>
|
||||
<option value="Vorabitur">Vorabitur</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1">Modus</label>
|
||||
<select
|
||||
value={form.modus}
|
||||
onChange={(e) => onFormChange({ ...form, modus: e.target.value as 'abitur' | 'vorabitur' })}
|
||||
className="w-full px-3 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-transparent"
|
||||
>
|
||||
<option value="abitur">Abitur (mit offiziellem EH)</option>
|
||||
<option value="vorabitur">Vorabitur (eigener EH)</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Vorabitur EH Form */}
|
||||
{form.modus === 'vorabitur' && (
|
||||
<div className="p-4 bg-blue-50 border border-blue-200 rounded-lg space-y-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<svg className="w-5 h-5 text-blue-600 mt-0.5" 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>
|
||||
<div className="text-sm text-blue-800">
|
||||
<p className="font-medium mb-1">Eigenen Erwartungshorizont erstellen</p>
|
||||
<p>Waehlen Sie einen Aufgabentyp und beschreiben Sie die Aufgabenstellung. Der EH wird automatisch mit Ihrer Klausur verknuepft.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Aufgabentyp */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1">Aufgabentyp *</label>
|
||||
{loadingTemplates ? (
|
||||
<div className="flex items-center gap-2 text-sm text-slate-500">
|
||||
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-blue-600"></div>
|
||||
Lade Vorlagen...
|
||||
</div>
|
||||
) : (
|
||||
<select
|
||||
value={ehForm.aufgabentyp}
|
||||
onChange={(e) => onEhFormChange({ ...ehForm, aufgabentyp: e.target.value })}
|
||||
className="w-full px-3 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-transparent bg-white"
|
||||
>
|
||||
<option value="">-- Aufgabentyp waehlen --</option>
|
||||
{templates.map(t => (
|
||||
<option key={t.aufgabentyp} value={t.aufgabentyp}>{t.name}</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
{ehForm.aufgabentyp && templates.find(t => t.aufgabentyp === ehForm.aufgabentyp) && (
|
||||
<p className="mt-1 text-xs text-slate-500">
|
||||
{templates.find(t => t.aufgabentyp === ehForm.aufgabentyp)?.description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Text Details */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1">Texttitel (optional)</label>
|
||||
<input
|
||||
type="text"
|
||||
value={ehForm.text_titel}
|
||||
onChange={(e) => onEhFormChange({ ...ehForm, text_titel: e.target.value })}
|
||||
placeholder="z.B. 'Die Verwandlung'"
|
||||
className="w-full px-3 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-transparent"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1">Autor (optional)</label>
|
||||
<input
|
||||
type="text"
|
||||
value={ehForm.text_autor}
|
||||
onChange={(e) => onEhFormChange({ ...ehForm, text_autor: e.target.value })}
|
||||
placeholder="z.B. 'Franz Kafka'"
|
||||
className="w-full px-3 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-transparent"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Aufgabenstellung */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1">Aufgabenstellung *</label>
|
||||
<textarea
|
||||
value={ehForm.aufgabenstellung}
|
||||
onChange={(e) => onEhFormChange({ ...ehForm, aufgabenstellung: e.target.value })}
|
||||
placeholder="Beschreiben Sie hier die konkrete Aufgabenstellung fuer die Schueler..."
|
||||
rows={4}
|
||||
className="w-full px-3 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-transparent resize-none"
|
||||
/>
|
||||
<p className="mt-1 text-xs text-slate-500">
|
||||
Die Aufgabenstellung wird zusammen mit dem Template in den Erwartungshorizont eingebunden.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Submit */}
|
||||
<div className="flex gap-3 pt-4">
|
||||
<button type="button" onClick={onCancel} className="px-4 py-2 text-slate-600 hover:bg-slate-100 rounded-lg">
|
||||
Abbrechen
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={creating}
|
||||
className="flex-1 px-4 py-2 bg-primary-600 text-white rounded-lg hover:bg-primary-700 disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2"
|
||||
>
|
||||
{creating ? (
|
||||
<>
|
||||
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white"></div>
|
||||
Erstelle...
|
||||
</>
|
||||
) : (
|
||||
'Klausur erstellen'
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
131
website/components/klausur-korrektur/KlausurenTab.tsx
Normal file
131
website/components/klausur-korrektur/KlausurenTab.tsx
Normal file
@@ -0,0 +1,131 @@
|
||||
'use client'
|
||||
|
||||
/**
|
||||
* Klausuren list tab - shows all exams in a grid with progress bars.
|
||||
*/
|
||||
|
||||
import Link from 'next/link'
|
||||
import type { Klausur } from '../../app/admin/klausur-korrektur/types'
|
||||
import type { TabId } from './list-types'
|
||||
|
||||
interface KlausurenTabProps {
|
||||
klausuren: Klausur[]
|
||||
loading: boolean
|
||||
basePath: string
|
||||
onNavigate: (tab: TabId) => void
|
||||
onDelete: (id: string) => void
|
||||
}
|
||||
|
||||
export default function KlausurenTab({
|
||||
klausuren, loading, basePath, onNavigate, onDelete,
|
||||
}: KlausurenTabProps) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Header */}
|
||||
<div className="flex justify-between items-center">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-slate-800">Alle Klausuren</h2>
|
||||
<p className="text-sm text-slate-500">{klausuren.length} Klausuren insgesamt</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => onNavigate('erstellen')}
|
||||
className="px-4 py-2 bg-primary-600 text-white rounded-lg hover:bg-primary-700 flex items-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="M12 4v16m8-8H4" />
|
||||
</svg>
|
||||
Neue Klausur
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Klausuren Grid */}
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary-600"></div>
|
||||
</div>
|
||||
) : klausuren.length === 0 ? (
|
||||
<div className="text-center py-12 bg-white rounded-lg border border-slate-200">
|
||||
<svg className="mx-auto h-12 w-12 text-slate-400" 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>
|
||||
<h3 className="mt-2 text-sm font-medium text-slate-900">Keine Klausuren</h3>
|
||||
<p className="mt-1 text-sm text-slate-500">Erstellen Sie Ihre erste Klausur zum Korrigieren.</p>
|
||||
<button
|
||||
onClick={() => onNavigate('erstellen')}
|
||||
className="mt-4 px-4 py-2 bg-primary-600 text-white rounded-lg hover:bg-primary-700"
|
||||
>
|
||||
Klausur erstellen
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{klausuren.map((klausur) => (
|
||||
<div key={klausur.id} className="bg-white rounded-lg border border-slate-200 shadow-sm hover:shadow-md transition-shadow">
|
||||
<div className="p-4">
|
||||
<div className="flex justify-between items-start mb-3">
|
||||
<div>
|
||||
<h3 className="font-semibold text-slate-800 truncate">{klausur.title}</h3>
|
||||
<p className="text-sm text-slate-500">{klausur.subject} - {klausur.year}</p>
|
||||
</div>
|
||||
<span className={`px-2 py-1 text-xs font-medium rounded-full ${
|
||||
klausur.modus === 'abitur' ? 'bg-purple-100 text-purple-800' : 'bg-blue-100 text-blue-800'
|
||||
}`}>
|
||||
{klausur.modus === 'abitur' ? 'Abitur' : 'Vorabitur'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-4 text-sm text-slate-600 mb-4">
|
||||
<div className="flex items-center gap-1">
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0zm6 3a2 2 0 11-4 0 2 2 0 014 0zM7 10a2 2 0 11-4 0 2 2 0 014 0z" />
|
||||
</svg>
|
||||
<span>{klausur.student_count || 0} Arbeiten</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
<span>{klausur.completed_count || 0} fertig</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{(klausur.student_count || 0) > 0 && (
|
||||
<div className="mb-4">
|
||||
<div className="flex justify-between text-xs text-slate-500 mb-1">
|
||||
<span>Fortschritt</span>
|
||||
<span>{Math.round(((klausur.completed_count || 0) / (klausur.student_count || 1)) * 100)}%</span>
|
||||
</div>
|
||||
<div className="h-2 bg-slate-100 rounded-full overflow-hidden">
|
||||
<div
|
||||
className="h-full bg-green-500 rounded-full transition-all"
|
||||
style={{ width: `${((klausur.completed_count || 0) / (klausur.student_count || 1)) * 100}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex gap-2">
|
||||
<Link
|
||||
href={`${basePath}/${klausur.id}`}
|
||||
className="flex-1 px-3 py-2 bg-primary-600 text-white text-sm text-center rounded-lg hover:bg-primary-700"
|
||||
>
|
||||
Korrigieren
|
||||
</Link>
|
||||
<button
|
||||
onClick={() => onDelete(klausur.id)}
|
||||
className="px-3 py-2 text-red-600 hover:bg-red-50 rounded-lg"
|
||||
title="Loeschen"
|
||||
>
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
69
website/components/klausur-korrektur/ListTabNav.tsx
Normal file
69
website/components/klausur-korrektur/ListTabNav.tsx
Normal file
@@ -0,0 +1,69 @@
|
||||
'use client'
|
||||
|
||||
/**
|
||||
* Tab navigation bar for the Klausur-Korrektur list page.
|
||||
*/
|
||||
|
||||
import type { TabId } from './list-types'
|
||||
|
||||
interface TabDef {
|
||||
id: TabId
|
||||
name: string
|
||||
icon: JSX.Element
|
||||
hidden?: boolean
|
||||
}
|
||||
|
||||
const tabs: TabDef[] = [
|
||||
{
|
||||
id: 'willkommen', name: 'Start',
|
||||
icon: <svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6" /></svg>,
|
||||
},
|
||||
{
|
||||
id: 'klausuren', name: 'Klausuren',
|
||||
icon: <svg className="w-5 h-5" 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>,
|
||||
},
|
||||
{
|
||||
id: 'erstellen', name: 'Neue Klausur',
|
||||
icon: <svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" /></svg>,
|
||||
},
|
||||
{
|
||||
id: 'direktupload', name: 'Schnellstart', hidden: true,
|
||||
icon: <svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12" /></svg>,
|
||||
},
|
||||
{
|
||||
id: 'statistiken', name: 'Statistiken',
|
||||
icon: <svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z" /></svg>,
|
||||
},
|
||||
]
|
||||
|
||||
interface ListTabNavProps {
|
||||
activeTab: TabId
|
||||
onTabChange: (tab: TabId) => void
|
||||
markAsVisited: () => void
|
||||
}
|
||||
|
||||
export default function ListTabNav({ activeTab, onTabChange, markAsVisited }: ListTabNavProps) {
|
||||
return (
|
||||
<div className="border-b border-slate-200 mb-6">
|
||||
<nav className="flex gap-4">
|
||||
{tabs.filter(tab => !tab.hidden).map((tab) => (
|
||||
<button
|
||||
key={tab.id}
|
||||
onClick={() => {
|
||||
if (tab.id !== 'willkommen') markAsVisited()
|
||||
onTabChange(tab.id)
|
||||
}}
|
||||
className={`flex items-center gap-2 px-4 py-3 text-sm 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 hover:border-slate-300'
|
||||
}`}
|
||||
>
|
||||
{tab.icon}
|
||||
{tab.name}
|
||||
</button>
|
||||
))}
|
||||
</nav>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
62
website/components/klausur-korrektur/StatistikenTab.tsx
Normal file
62
website/components/klausur-korrektur/StatistikenTab.tsx
Normal file
@@ -0,0 +1,62 @@
|
||||
'use client'
|
||||
|
||||
/**
|
||||
* Statistics tab for the Klausur-Korrektur page.
|
||||
* Shows summary cards and grade criteria info.
|
||||
*/
|
||||
|
||||
import type { Klausur, GradeInfo } from '../../app/admin/klausur-korrektur/types'
|
||||
|
||||
interface StatistikenTabProps {
|
||||
klausuren: Klausur[]
|
||||
gradeInfo: GradeInfo | null
|
||||
}
|
||||
|
||||
export default function StatistikenTab({ klausuren, gradeInfo }: StatistikenTabProps) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<h2 className="text-lg font-semibold text-slate-800">Korrektur-Statistiken</h2>
|
||||
|
||||
{/* Summary cards */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
|
||||
<div className="bg-white rounded-lg border border-slate-200 p-4">
|
||||
<div className="text-2xl font-bold text-slate-800">{klausuren.length}</div>
|
||||
<div className="text-sm text-slate-500">Klausuren</div>
|
||||
</div>
|
||||
<div className="bg-white rounded-lg border border-slate-200 p-4">
|
||||
<div className="text-2xl font-bold text-slate-800">
|
||||
{klausuren.reduce((sum, k) => sum + (k.student_count || 0), 0)}
|
||||
</div>
|
||||
<div className="text-sm text-slate-500">Studentenarbeiten</div>
|
||||
</div>
|
||||
<div className="bg-white rounded-lg border border-slate-200 p-4">
|
||||
<div className="text-2xl font-bold text-green-600">
|
||||
{klausuren.reduce((sum, k) => sum + (k.completed_count || 0), 0)}
|
||||
</div>
|
||||
<div className="text-sm text-slate-500">Abgeschlossen</div>
|
||||
</div>
|
||||
<div className="bg-white rounded-lg border border-slate-200 p-4">
|
||||
<div className="text-2xl font-bold text-orange-600">
|
||||
{klausuren.reduce((sum, k) => sum + ((k.student_count || 0) - (k.completed_count || 0)), 0)}
|
||||
</div>
|
||||
<div className="text-sm text-slate-500">Ausstehend</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Grade Info */}
|
||||
{gradeInfo && (
|
||||
<div className="bg-white rounded-lg border border-slate-200 p-6">
|
||||
<h3 className="text-sm font-semibold text-slate-800 mb-4">Bewertungskriterien (Niedersachsen)</h3>
|
||||
<div className="grid grid-cols-2 md:grid-cols-5 gap-4">
|
||||
{Object.entries(gradeInfo.criteria || {}).map(([key, criterion]) => (
|
||||
<div key={key} className="text-center p-3 bg-slate-50 rounded-lg">
|
||||
<div className="text-lg font-semibold text-slate-700">{criterion.weight}%</div>
|
||||
<div className="text-sm text-slate-500">{criterion.name}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
151
website/components/klausur-korrektur/WillkommenTab.tsx
Normal file
151
website/components/klausur-korrektur/WillkommenTab.tsx
Normal file
@@ -0,0 +1,151 @@
|
||||
'use client'
|
||||
|
||||
/**
|
||||
* Welcome/Onboarding tab for the Klausur-Korrektur page.
|
||||
* Shows hero, workflow explanation, and action cards.
|
||||
*/
|
||||
|
||||
import type { Klausur } from '../../app/admin/klausur-korrektur/types'
|
||||
import type { TabId } from './list-types'
|
||||
|
||||
interface WillkommenTabProps {
|
||||
klausuren: Klausur[]
|
||||
onNavigate: (tab: TabId) => void
|
||||
markAsVisited: () => void
|
||||
}
|
||||
|
||||
export default function WillkommenTab({ klausuren, onNavigate, markAsVisited }: WillkommenTabProps) {
|
||||
const goTo = (tab: TabId) => { markAsVisited(); onNavigate(tab) }
|
||||
|
||||
return (
|
||||
<div className="max-w-4xl mx-auto space-y-8">
|
||||
{/* Hero Section */}
|
||||
<div className="text-center py-8">
|
||||
<div className="inline-flex items-center justify-center w-20 h-20 bg-gradient-to-br from-purple-500 to-purple-700 rounded-2xl mb-6">
|
||||
<svg className="w-10 h-10 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
</div>
|
||||
<h1 className="text-3xl font-bold text-slate-800 mb-3">Willkommen zur Abiturklausur-Korrektur</h1>
|
||||
<p className="text-lg text-slate-600 max-w-2xl mx-auto">
|
||||
KI-gestuetzte Korrektur fuer Deutsch-Abiturklausuren nach dem 15-Punkte-System.
|
||||
Sparen Sie bis zu 80% Zeit bei der Erstkorrektur.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Workflow Explanation */}
|
||||
<div className="bg-gradient-to-r from-blue-50 to-purple-50 border border-blue-200 rounded-xl p-6">
|
||||
<h2 className="text-lg font-semibold text-slate-800 mb-4 flex items-center gap-2">
|
||||
<svg className="w-5 h-5 text-blue-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2" />
|
||||
</svg>
|
||||
So funktioniert es
|
||||
</h2>
|
||||
<div className="grid md:grid-cols-4 gap-4">
|
||||
{[
|
||||
{ step: 1, title: 'Arbeiten hochladen', desc: 'Scans der Schuelerarbeiten als PDF oder Bilder hochladen' },
|
||||
{ step: 2, title: 'EH bereitstellen', desc: 'Erwartungshorizont hochladen oder aus Vorlage erstellen' },
|
||||
{ step: 3, title: 'KI korrigiert', desc: 'Automatische Bewertung und Gutachten-Vorschlaege erhalten' },
|
||||
{ step: 4, title: 'Pruefen & Anpassen', desc: 'Vorschlaege pruefen, anpassen und finalisieren' },
|
||||
].map(({ step, title, desc }) => (
|
||||
<div key={step} className="text-center">
|
||||
<div className="text-xs text-blue-600 font-medium mb-1">Schritt {step}</div>
|
||||
<div className="font-medium text-slate-800 text-sm">{title}</div>
|
||||
<div className="text-xs text-slate-500 mt-1">{desc}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Action Cards */}
|
||||
<div className="grid md:grid-cols-2 gap-6">
|
||||
{/* Option 1: Standard Flow */}
|
||||
<div className="bg-white border-2 border-slate-200 rounded-xl p-6 hover:border-purple-300 hover:shadow-lg transition-all cursor-pointer"
|
||||
onClick={() => goTo('erstellen')}>
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="w-12 h-12 bg-purple-100 rounded-xl flex items-center justify-center flex-shrink-0">
|
||||
<svg className="w-6 h-6 text-purple-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 6v6m0 0v6m0-6h6m-6 0H6" />
|
||||
</svg>
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<h3 className="font-semibold text-slate-800 mb-1">Neue Klausur erstellen</h3>
|
||||
<p className="text-sm text-slate-600 mb-3">
|
||||
Empfohlen fuer regelmaessige Nutzung. Erstellen Sie eine Klausur mit allen Metadaten,
|
||||
laden Sie dann die Arbeiten hoch.
|
||||
</p>
|
||||
<ul className="text-xs text-slate-500 space-y-1">
|
||||
{['Volle Metadaten (Fach, Jahr, Kurs)', 'Zweitkorrektur-Workflow', 'Fairness-Analyse'].map(text => (
|
||||
<li key={text} className="flex items-center gap-1">
|
||||
<svg className="w-3 h-3 text-green-500" fill="currentColor" viewBox="0 0 20 20"><path fillRule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clipRule="evenodd" /></svg>
|
||||
{text}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<div className="mt-4 text-sm text-purple-600 font-medium flex items-center gap-1">
|
||||
Klausur erstellen
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Option 2: Quick Upload */}
|
||||
<div className="bg-white border-2 border-slate-200 rounded-xl p-6 hover:border-blue-300 hover:shadow-lg transition-all cursor-pointer"
|
||||
onClick={() => goTo('direktupload')}>
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="w-12 h-12 bg-blue-100 rounded-xl flex items-center justify-center flex-shrink-0">
|
||||
<svg className="w-6 h-6 text-blue-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12" />
|
||||
</svg>
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<h3 className="font-semibold text-slate-800 mb-1">Schnellstart - Direkt hochladen</h3>
|
||||
<p className="text-sm text-slate-600 mb-3">
|
||||
Ideal wenn Sie sofort loslegen moechten. Laden Sie Arbeiten und EH direkt hoch,
|
||||
wir erstellen die Klausur automatisch.
|
||||
</p>
|
||||
<ul className="text-xs text-slate-500 space-y-1">
|
||||
{['Schnellster Weg zum Korrigieren', 'Drag & Drop Upload', 'Sofort einsatzbereit'].map(text => (
|
||||
<li key={text} className="flex items-center gap-1">
|
||||
<svg className="w-3 h-3 text-blue-500" fill="currentColor" viewBox="0 0 20 20"><path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm1-12a1 1 0 10-2 0v4a1 1 0 00.293.707l2.828 2.829a1 1 0 101.415-1.415L11 9.586V6z" clipRule="evenodd" /></svg>
|
||||
{text}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<div className="mt-4 text-sm text-blue-600 font-medium flex items-center gap-1">
|
||||
Schnellstart
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Already have klausuren? */}
|
||||
{klausuren.length > 0 && (
|
||||
<div className="bg-slate-50 border border-slate-200 rounded-xl p-4 flex items-center justify-between">
|
||||
<div>
|
||||
<p className="font-medium text-slate-800">Sie haben {klausuren.length} Klausur{klausuren.length !== 1 ? 'en' : ''}</p>
|
||||
<p className="text-sm text-slate-500">Setzen Sie Ihre Arbeit fort oder starten Sie eine neue Korrektur.</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => goTo('klausuren')}
|
||||
className="px-4 py-2 bg-slate-800 text-white rounded-lg hover:bg-slate-700 text-sm"
|
||||
>
|
||||
Zu meinen Klausuren
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Help Links */}
|
||||
<div className="text-center text-sm text-slate-500">
|
||||
<p>Fragen? Lesen Sie unsere <button className="text-purple-600 hover:underline">Dokumentation</button> oder kontaktieren Sie den <button className="text-purple-600 hover:underline">Support</button>.</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
165
website/components/klausur-korrektur/WorkflowActions.tsx
Normal file
165
website/components/klausur-korrektur/WorkflowActions.tsx
Normal file
@@ -0,0 +1,165 @@
|
||||
'use client'
|
||||
|
||||
/**
|
||||
* Workflow-aware action buttons for the criteria panel.
|
||||
* Handles Erstkorrektur, Zweitkorrektur, Einigung, and completed states.
|
||||
*/
|
||||
|
||||
import type { ExaminerWorkflow } from './workspace-types'
|
||||
import { GRADE_LABELS } from './workspace-types'
|
||||
|
||||
interface WorkflowActionsProps {
|
||||
workflow: ExaminerWorkflow | null
|
||||
gutachten: string
|
||||
generatingGutachten: boolean
|
||||
submittingWorkflow: boolean
|
||||
totals: { gradePoints: number }
|
||||
onGenerateGutachten: () => void
|
||||
onSubmitErstkorrektur: () => void
|
||||
onStartZweitkorrektur: (id: string) => void
|
||||
onSubmitZweitkorrektur: () => void
|
||||
onShowEinigungModal: () => void
|
||||
}
|
||||
|
||||
export default function WorkflowActions({
|
||||
workflow, gutachten, generatingGutachten, submittingWorkflow, totals,
|
||||
onGenerateGutachten, onSubmitErstkorrektur, onStartZweitkorrektur,
|
||||
onSubmitZweitkorrektur, onShowEinigungModal,
|
||||
}: WorkflowActionsProps) {
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{/* Generate Gutachten button */}
|
||||
<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>
|
||||
|
||||
{/* Erstkorrektur */}
|
||||
{(!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>
|
||||
)}
|
||||
|
||||
{/* Start Zweitkorrektur */}
|
||||
{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>
|
||||
)}
|
||||
|
||||
{/* Submit Zweitkorrektur */}
|
||||
{(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={onShowEinigungModal}
|
||||
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>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
108
website/components/klausur-korrektur/WorkspaceTopBar.tsx
Normal file
108
website/components/klausur-korrektur/WorkspaceTopBar.tsx
Normal file
@@ -0,0 +1,108 @@
|
||||
'use client'
|
||||
|
||||
/**
|
||||
* Top navigation bar for the Korrektur-Workspace.
|
||||
* Shows back link, student navigation, workflow status, and grade.
|
||||
*/
|
||||
|
||||
import Link from 'next/link'
|
||||
import type { ExaminerWorkflow } from './workspace-types'
|
||||
import { WORKFLOW_STATUS_LABELS, ROLE_LABELS, GRADE_LABELS } from './workspace-types'
|
||||
|
||||
interface WorkspaceTopBarProps {
|
||||
klausurId: string
|
||||
backPath: string
|
||||
currentIndex: number
|
||||
studentCount: number
|
||||
workflow: ExaminerWorkflow | null
|
||||
saving: boolean
|
||||
totals: { gradePoints: number; weighted: number }
|
||||
onGoToStudent: (direction: 'prev' | 'next') => void
|
||||
}
|
||||
|
||||
export default function WorkspaceTopBar({
|
||||
klausurId, backPath, currentIndex, studentCount,
|
||||
workflow, saving, totals, onGoToStudent,
|
||||
}: WorkspaceTopBarProps) {
|
||||
return (
|
||||
<div className="bg-white border-b border-slate-200 -mx-6 -mt-6 px-6 py-3 mb-4 flex items-center justify-between sticky top-0 z-10">
|
||||
{/* Back link */}
|
||||
<Link
|
||||
href={backPath}
|
||||
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} / {studentCount}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => onGoToStudent('next')}
|
||||
disabled={currentIndex >= studentCount - 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>
|
||||
)
|
||||
}
|
||||
39
website/components/klausur-korrektur/list-types.ts
Normal file
39
website/components/klausur-korrektur/list-types.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
/**
|
||||
* Types and constants for the Klausur-Korrektur list page.
|
||||
* Shared between admin and lehrer routes.
|
||||
*/
|
||||
|
||||
export const API_BASE = process.env.NEXT_PUBLIC_KLAUSUR_SERVICE_URL || 'http://localhost:8086'
|
||||
|
||||
export type TabId = 'willkommen' | 'klausuren' | 'erstellen' | 'direktupload' | 'statistiken'
|
||||
|
||||
export interface CreateKlausurForm {
|
||||
title: string
|
||||
subject: string
|
||||
year: number
|
||||
semester: string
|
||||
modus: 'abitur' | 'vorabitur'
|
||||
}
|
||||
|
||||
export interface VorabiturEHForm {
|
||||
aufgabentyp: string
|
||||
titel: string
|
||||
text_titel: string
|
||||
text_autor: string
|
||||
aufgabenstellung: string
|
||||
}
|
||||
|
||||
export interface EHTemplate {
|
||||
aufgabentyp: string
|
||||
name: string
|
||||
description: string
|
||||
category: string
|
||||
}
|
||||
|
||||
export interface DirektuploadForm {
|
||||
files: File[]
|
||||
ehFile: File | null
|
||||
ehText: string
|
||||
aufgabentyp: string
|
||||
klausurTitle: string
|
||||
}
|
||||
239
website/components/klausur-korrektur/useKlausurList.ts
Normal file
239
website/components/klausur-korrektur/useKlausurList.ts
Normal file
@@ -0,0 +1,239 @@
|
||||
'use client'
|
||||
|
||||
/**
|
||||
* Custom hook for the Klausur-Korrektur list page.
|
||||
* Encapsulates all state and data fetching logic.
|
||||
*/
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import type { Klausur, GradeInfo } from '../../app/admin/klausur-korrektur/types'
|
||||
import type {
|
||||
TabId, CreateKlausurForm, VorabiturEHForm, EHTemplate, DirektuploadForm,
|
||||
} from './list-types'
|
||||
import { API_BASE } from './list-types'
|
||||
|
||||
interface UseKlausurListArgs {
|
||||
/** Base route path for navigation, e.g. '/admin/klausur-korrektur' or '/lehrer/klausur-korrektur' */
|
||||
basePath: string
|
||||
}
|
||||
|
||||
export function useKlausurList({ basePath }: UseKlausurListArgs) {
|
||||
const [activeTab, setActiveTab] = useState<TabId>(() => {
|
||||
if (typeof window !== 'undefined') {
|
||||
const hasVisited = localStorage.getItem('klausur_korrektur_visited')
|
||||
return hasVisited ? 'klausuren' : 'willkommen'
|
||||
}
|
||||
return 'willkommen'
|
||||
})
|
||||
const [klausuren, setKlausuren] = useState<Klausur[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [creating, setCreating] = useState(false)
|
||||
const [gradeInfo, setGradeInfo] = useState<GradeInfo | null>(null)
|
||||
|
||||
// Vorabitur templates
|
||||
const [templates, setTemplates] = useState<EHTemplate[]>([])
|
||||
const [loadingTemplates, setLoadingTemplates] = useState(false)
|
||||
|
||||
// Create form state
|
||||
const [form, setForm] = useState<CreateKlausurForm>({
|
||||
title: '', subject: 'Deutsch', year: new Date().getFullYear(),
|
||||
semester: 'Abitur', modus: 'abitur',
|
||||
})
|
||||
|
||||
const [ehForm, setEhForm] = useState<VorabiturEHForm>({
|
||||
aufgabentyp: '', titel: '', text_titel: '', text_autor: '', aufgabenstellung: '',
|
||||
})
|
||||
|
||||
// Direktupload form
|
||||
const [direktForm, setDirektForm] = useState<DirektuploadForm>({
|
||||
files: [], ehFile: null, ehText: '', aufgabentyp: '',
|
||||
klausurTitle: `Schnellkorrektur ${new Date().toLocaleDateString('de-DE')}`,
|
||||
})
|
||||
const [direktStep, setDirektStep] = useState<1 | 2 | 3>(1)
|
||||
const [uploading, setUploading] = useState(false)
|
||||
|
||||
// Fetch klausuren
|
||||
const fetchKlausuren = useCallback(async () => {
|
||||
try {
|
||||
setLoading(true)
|
||||
const res = await fetch(`${API_BASE}/api/v1/klausuren`)
|
||||
if (res.ok) {
|
||||
const data = await res.json()
|
||||
setKlausuren(Array.isArray(data) ? data : data.klausuren || [])
|
||||
setError(null)
|
||||
} else {
|
||||
setError(`Fehler beim Laden: ${res.status}`)
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch klausuren:', err)
|
||||
setError('Verbindung zum Klausur-Service fehlgeschlagen')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
// Fetch grade info
|
||||
const fetchGradeInfo = useCallback(async () => {
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/api/v1/grade-info`)
|
||||
if (res.ok) setGradeInfo(await res.json())
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch grade info:', err)
|
||||
}
|
||||
}, [])
|
||||
|
||||
// Fetch templates
|
||||
const fetchTemplates = useCallback(async () => {
|
||||
try {
|
||||
setLoadingTemplates(true)
|
||||
const res = await fetch(`${API_BASE}/api/v1/vorabitur/templates`)
|
||||
if (res.ok) {
|
||||
const data = await res.json()
|
||||
setTemplates(data.templates || [])
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch templates:', err)
|
||||
} finally {
|
||||
setLoadingTemplates(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => { fetchKlausuren(); fetchGradeInfo() }, [fetchKlausuren, fetchGradeInfo])
|
||||
|
||||
useEffect(() => {
|
||||
if (form.modus === 'vorabitur' && templates.length === 0) fetchTemplates()
|
||||
}, [form.modus, templates.length, fetchTemplates])
|
||||
|
||||
const markAsVisited = () => {
|
||||
if (typeof window !== 'undefined') localStorage.setItem('klausur_korrektur_visited', 'true')
|
||||
}
|
||||
|
||||
// Create new Klausur
|
||||
const handleCreateKlausur = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
if (!form.title.trim()) { setError('Bitte einen Titel eingeben'); return }
|
||||
if (form.modus === 'vorabitur') {
|
||||
if (!ehForm.aufgabentyp) { setError('Bitte einen Aufgabentyp auswaehlen'); return }
|
||||
if (!ehForm.aufgabenstellung.trim()) { setError('Bitte die Aufgabenstellung eingeben'); return }
|
||||
}
|
||||
|
||||
try {
|
||||
setCreating(true)
|
||||
const res = await fetch(`${API_BASE}/api/v1/klausuren`, {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(form),
|
||||
})
|
||||
if (!res.ok) {
|
||||
const errorData = await res.json()
|
||||
setError(errorData.detail || 'Fehler beim Erstellen'); return
|
||||
}
|
||||
const newKlausur = await res.json()
|
||||
|
||||
if (form.modus === 'vorabitur') {
|
||||
const ehRes = await fetch(`${API_BASE}/api/v1/klausuren/${newKlausur.id}/vorabitur-eh`, {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
aufgabentyp: ehForm.aufgabentyp, titel: ehForm.titel || `EH: ${form.title}`,
|
||||
text_titel: ehForm.text_titel || null, text_autor: ehForm.text_autor || null,
|
||||
aufgabenstellung: ehForm.aufgabenstellung,
|
||||
}),
|
||||
})
|
||||
if (!ehRes.ok) {
|
||||
console.error('Failed to create EH:', await ehRes.text())
|
||||
setError('Klausur erstellt, aber Erwartungshorizont konnte nicht erstellt werden.')
|
||||
}
|
||||
}
|
||||
|
||||
setKlausuren(prev => [newKlausur, ...prev])
|
||||
setForm({ title: '', subject: 'Deutsch', year: new Date().getFullYear(), semester: 'Abitur', modus: 'abitur' })
|
||||
setEhForm({ aufgabentyp: '', titel: '', text_titel: '', text_autor: '', aufgabenstellung: '' })
|
||||
setActiveTab('klausuren')
|
||||
if (!error) setError(null)
|
||||
} catch (err) {
|
||||
console.error('Failed to create klausur:', err)
|
||||
setError('Fehler beim Erstellen der Klausur')
|
||||
} finally {
|
||||
setCreating(false)
|
||||
}
|
||||
}
|
||||
|
||||
// Delete Klausur
|
||||
const handleDeleteKlausur = async (id: string) => {
|
||||
if (!confirm('Klausur wirklich loeschen? Alle Studentenarbeiten werden ebenfalls geloescht.')) return
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/api/v1/klausuren/${id}`, { method: 'DELETE' })
|
||||
if (res.ok) setKlausuren(prev => prev.filter(k => k.id !== id))
|
||||
else setError('Fehler beim Loeschen')
|
||||
} catch (err) {
|
||||
console.error('Failed to delete klausur:', err)
|
||||
setError('Fehler beim Loeschen der Klausur')
|
||||
}
|
||||
}
|
||||
|
||||
// Direktupload
|
||||
const handleDirektupload = async () => {
|
||||
if (direktForm.files.length === 0) { setError('Bitte mindestens eine Arbeit hochladen'); return }
|
||||
try {
|
||||
setUploading(true)
|
||||
const klausurRes = await fetch(`${API_BASE}/api/v1/klausuren`, {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
title: direktForm.klausurTitle, subject: 'Deutsch',
|
||||
year: new Date().getFullYear(), semester: 'Vorabitur', modus: 'vorabitur',
|
||||
}),
|
||||
})
|
||||
if (!klausurRes.ok) {
|
||||
const err = await klausurRes.json()
|
||||
throw new Error(err.detail || 'Klausur erstellen fehlgeschlagen')
|
||||
}
|
||||
const newKlausur = await klausurRes.json()
|
||||
|
||||
if (direktForm.ehText.trim() || direktForm.aufgabentyp) {
|
||||
const ehRes = await fetch(`${API_BASE}/api/v1/klausuren/${newKlausur.id}/vorabitur-eh`, {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
aufgabentyp: direktForm.aufgabentyp || 'textanalyse_pragmatisch',
|
||||
titel: `EH: ${direktForm.klausurTitle}`,
|
||||
aufgabenstellung: direktForm.ehText || 'Individuelle Aufgabenstellung',
|
||||
}),
|
||||
})
|
||||
if (!ehRes.ok) console.error('EH creation failed, continuing with upload')
|
||||
}
|
||||
|
||||
for (let i = 0; i < direktForm.files.length; i++) {
|
||||
const file = direktForm.files[i]
|
||||
const formData = new FormData()
|
||||
formData.append('file', file)
|
||||
formData.append('anonym_id', `Arbeit-${i + 1}`)
|
||||
const uploadRes = await fetch(`${API_BASE}/api/v1/klausuren/${newKlausur.id}/students`, {
|
||||
method: 'POST', body: formData,
|
||||
})
|
||||
if (!uploadRes.ok) console.error(`Upload failed for file ${i + 1}:`, file.name)
|
||||
}
|
||||
|
||||
setKlausuren(prev => [newKlausur, ...prev])
|
||||
markAsVisited()
|
||||
window.location.href = `${basePath}/${newKlausur.id}`
|
||||
} catch (err) {
|
||||
console.error('Direktupload failed:', err)
|
||||
setError(err instanceof Error ? err.message : 'Fehler beim Direktupload')
|
||||
} finally {
|
||||
setUploading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
// Data
|
||||
klausuren, gradeInfo, templates,
|
||||
// UI state
|
||||
activeTab, loading, error, creating, loadingTemplates,
|
||||
form, ehForm, direktForm, direktStep, uploading,
|
||||
// Setters
|
||||
setActiveTab, setError, setForm, setEhForm, setDirektForm, setDirektStep,
|
||||
// Actions
|
||||
markAsVisited, handleCreateKlausur, handleDeleteKlausur, handleDirektupload,
|
||||
// Route config
|
||||
basePath,
|
||||
}
|
||||
}
|
||||
471
website/components/klausur-korrektur/useKorrekturWorkspace.ts
Normal file
471
website/components/klausur-korrektur/useKorrekturWorkspace.ts
Normal file
@@ -0,0 +1,471 @@
|
||||
'use client'
|
||||
|
||||
/**
|
||||
* Custom hook for the Korrektur-Workspace.
|
||||
* Encapsulates all state, data fetching, and actions.
|
||||
*/
|
||||
|
||||
import { useState, useEffect, useCallback, useMemo } from 'react'
|
||||
import type {
|
||||
Klausur,
|
||||
StudentWork,
|
||||
Annotation,
|
||||
CriteriaScores,
|
||||
GradeInfo,
|
||||
AnnotationType,
|
||||
AnnotationPosition,
|
||||
} from '../../app/admin/klausur-korrektur/types'
|
||||
import type { ExaminerWorkflow, ActiveTab } from './workspace-types'
|
||||
import { API_BASE } from './workspace-types'
|
||||
|
||||
interface UseKorrekturWorkspaceArgs {
|
||||
klausurId: string
|
||||
studentId: string
|
||||
}
|
||||
|
||||
export function useKorrekturWorkspace({ klausurId, studentId }: UseKorrekturWorkspaceArgs) {
|
||||
// Core state
|
||||
const [klausur, setKlausur] = useState<Klausur | null>(null)
|
||||
const [student, setStudent] = useState<StudentWork | null>(null)
|
||||
const [students, setStudents] = useState<StudentWork[]>([])
|
||||
const [annotations, setAnnotations] = useState<Annotation[]>([])
|
||||
const [gradeInfo, setGradeInfo] = useState<GradeInfo | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [activeTab, setActiveTab] = useState<ActiveTab>('kriterien')
|
||||
const [currentPage, setCurrentPage] = useState(1)
|
||||
const [totalPages, setTotalPages] = useState(1)
|
||||
const [zoom, setZoom] = useState(100)
|
||||
const [documentUrl, setDocumentUrl] = useState<string | null>(null)
|
||||
const [generatingGutachten, setGeneratingGutachten] = useState(false)
|
||||
const [exporting, setExporting] = useState(false)
|
||||
|
||||
// Annotation state
|
||||
const [selectedTool, setSelectedTool] = useState<AnnotationType | null>(null)
|
||||
const [selectedAnnotation, setSelectedAnnotation] = useState<Annotation | null>(null)
|
||||
|
||||
// Form state
|
||||
const [criteriaScores, setCriteriaScores] = useState<CriteriaScores>({})
|
||||
const [gutachten, setGutachten] = useState('')
|
||||
|
||||
// Examiner workflow state
|
||||
const [workflow, setWorkflow] = useState<ExaminerWorkflow | null>(null)
|
||||
const [showEinigungModal, setShowEinigungModal] = useState(false)
|
||||
const [einigungGrade, setEinigungGrade] = useState<number>(0)
|
||||
const [einigungNotes, setEinigungNotes] = useState('')
|
||||
const [submittingWorkflow, setSubmittingWorkflow] = useState(false)
|
||||
|
||||
// Current student index
|
||||
const currentIndex = students.findIndex(s => s.id === studentId)
|
||||
|
||||
// Annotation counts by type
|
||||
const annotationCounts = useMemo(() => {
|
||||
const counts: Record<AnnotationType, number> = {
|
||||
rechtschreibung: 0, grammatik: 0, inhalt: 0,
|
||||
struktur: 0, stil: 0, comment: 0, highlight: 0,
|
||||
}
|
||||
annotations.forEach((ann) => {
|
||||
counts[ann.type] = (counts[ann.type] || 0) + 1
|
||||
})
|
||||
return counts
|
||||
}, [annotations])
|
||||
|
||||
// Fetch all data
|
||||
const fetchData = useCallback(async () => {
|
||||
try {
|
||||
setLoading(true)
|
||||
|
||||
const klausurRes = await fetch(`${API_BASE}/api/v1/klausuren/${klausurId}`)
|
||||
if (klausurRes.ok) setKlausur(await klausurRes.json())
|
||||
|
||||
const studentsRes = await fetch(`${API_BASE}/api/v1/klausuren/${klausurId}/students`)
|
||||
if (studentsRes.ok) {
|
||||
const data = await studentsRes.json()
|
||||
setStudents(Array.isArray(data) ? data : data.students || [])
|
||||
}
|
||||
|
||||
const studentRes = await fetch(`${API_BASE}/api/v1/students/${studentId}`)
|
||||
if (studentRes.ok) {
|
||||
const studentData = await studentRes.json()
|
||||
setStudent(studentData)
|
||||
setCriteriaScores(studentData.criteria_scores || {})
|
||||
setGutachten(studentData.gutachten || '')
|
||||
}
|
||||
|
||||
const gradeInfoRes = await fetch(`${API_BASE}/api/v1/grade-info`)
|
||||
if (gradeInfoRes.ok) setGradeInfo(await gradeInfoRes.json())
|
||||
|
||||
const workflowRes = await fetch(`${API_BASE}/api/v1/students/${studentId}/examiner-workflow`)
|
||||
if (workflowRes.ok) {
|
||||
const workflowData = await workflowRes.json()
|
||||
setWorkflow(workflowData)
|
||||
if (workflowData.workflow_status === 'einigung_required' && workflowData.first_result && workflowData.second_result) {
|
||||
const avgGrade = Math.round((workflowData.first_result.grade_points + workflowData.second_result.grade_points) / 2)
|
||||
setEinigungGrade(avgGrade)
|
||||
}
|
||||
}
|
||||
|
||||
const annotationsEndpoint = workflow?.user_role === 'zk'
|
||||
? `${API_BASE}/api/v1/students/${studentId}/annotations-filtered`
|
||||
: `${API_BASE}/api/v1/students/${studentId}/annotations`
|
||||
const annotationsRes = await fetch(annotationsEndpoint)
|
||||
if (annotationsRes.ok) {
|
||||
const annotationsData = await annotationsRes.json()
|
||||
setAnnotations(Array.isArray(annotationsData) ? annotationsData : annotationsData.annotations || [])
|
||||
}
|
||||
|
||||
setDocumentUrl(`${API_BASE}/api/v1/students/${studentId}/file`)
|
||||
setError(null)
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch data:', err)
|
||||
setError('Fehler beim Laden der Daten')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [klausurId, studentId])
|
||||
|
||||
// Create annotation
|
||||
const createAnnotation = useCallback(async (position: AnnotationPosition, type: AnnotationType) => {
|
||||
try {
|
||||
const newAnnotation = {
|
||||
page: currentPage, position, type, text: '',
|
||||
severity: type === 'rechtschreibung' || type === 'grammatik' ? 'minor' : 'major',
|
||||
role: 'first_examiner',
|
||||
linked_criterion: ['rechtschreibung', 'grammatik', 'inhalt', 'struktur', 'stil'].includes(type) ? type : undefined,
|
||||
}
|
||||
const res = await fetch(`${API_BASE}/api/v1/students/${studentId}/annotations`, {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(newAnnotation),
|
||||
})
|
||||
if (res.ok) {
|
||||
const created = await res.json()
|
||||
setAnnotations((prev) => [...prev, created])
|
||||
setSelectedAnnotation(created)
|
||||
setActiveTab('annotationen')
|
||||
} else {
|
||||
const errorData = await res.json()
|
||||
setError(errorData.detail || 'Fehler beim Erstellen der Annotation')
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to create annotation:', err)
|
||||
setError('Fehler beim Erstellen der Annotation')
|
||||
}
|
||||
}, [studentId, currentPage])
|
||||
|
||||
// Update annotation
|
||||
const updateAnnotation = useCallback(async (id: string, updates: Partial<Annotation>) => {
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/api/v1/annotations/${id}`, {
|
||||
method: 'PUT', headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(updates),
|
||||
})
|
||||
if (res.ok) {
|
||||
const updated = await res.json()
|
||||
setAnnotations((prev) => prev.map((ann) => (ann.id === id ? updated : ann)))
|
||||
if (selectedAnnotation?.id === id) setSelectedAnnotation(updated)
|
||||
} else {
|
||||
const errorData = await res.json()
|
||||
setError(errorData.detail || 'Fehler beim Aktualisieren der Annotation')
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to update annotation:', err)
|
||||
setError('Fehler beim Aktualisieren der Annotation')
|
||||
}
|
||||
}, [selectedAnnotation?.id])
|
||||
|
||||
// Delete annotation
|
||||
const deleteAnnotation = useCallback(async (id: string) => {
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/api/v1/annotations/${id}`, { method: 'DELETE' })
|
||||
if (res.ok) {
|
||||
setAnnotations((prev) => prev.filter((ann) => ann.id !== id))
|
||||
if (selectedAnnotation?.id === id) setSelectedAnnotation(null)
|
||||
} else {
|
||||
const errorData = await res.json()
|
||||
setError(errorData.detail || 'Fehler beim Loeschen der Annotation')
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to delete annotation:', err)
|
||||
setError('Fehler beim Loeschen der Annotation')
|
||||
}
|
||||
}, [selectedAnnotation?.id])
|
||||
|
||||
useEffect(() => { fetchData() }, [fetchData])
|
||||
|
||||
// Save criteria scores
|
||||
const saveCriteriaScores = useCallback(async (newScores: CriteriaScores) => {
|
||||
try {
|
||||
setSaving(true)
|
||||
const res = await fetch(`${API_BASE}/api/v1/students/${studentId}/criteria`, {
|
||||
method: 'PUT', headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ criteria_scores: newScores }),
|
||||
})
|
||||
if (res.ok) setStudent(await res.json())
|
||||
else setError('Fehler beim Speichern')
|
||||
} catch (err) {
|
||||
console.error('Failed to save criteria:', err)
|
||||
setError('Fehler beim Speichern')
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}, [studentId])
|
||||
|
||||
// Save gutachten
|
||||
const saveGutachten = useCallback(async () => {
|
||||
try {
|
||||
setSaving(true)
|
||||
const res = await fetch(`${API_BASE}/api/v1/students/${studentId}/gutachten`, {
|
||||
method: 'PUT', headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ gutachten }),
|
||||
})
|
||||
if (res.ok) setStudent(await res.json())
|
||||
else setError('Fehler beim Speichern')
|
||||
} catch (err) {
|
||||
console.error('Failed to save gutachten:', err)
|
||||
setError('Fehler beim Speichern')
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}, [studentId, gutachten])
|
||||
|
||||
// Generate gutachten
|
||||
const generateGutachten = useCallback(async () => {
|
||||
try {
|
||||
setGeneratingGutachten(true)
|
||||
setError(null)
|
||||
const res = await fetch(`${API_BASE}/api/v1/students/${studentId}/gutachten/generate`, {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ criteria_scores: criteriaScores }),
|
||||
})
|
||||
if (res.ok) {
|
||||
const data = await res.json()
|
||||
const generatedText = [data.einleitung || '', '', data.hauptteil || '', '', data.fazit || '']
|
||||
.filter(Boolean).join('\n\n')
|
||||
setGutachten(generatedText)
|
||||
setActiveTab('gutachten')
|
||||
} else {
|
||||
const errorData = await res.json()
|
||||
setError(errorData.detail || 'Fehler bei der Gutachten-Generierung')
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to generate gutachten:', err)
|
||||
setError('Fehler bei der Gutachten-Generierung')
|
||||
} finally {
|
||||
setGeneratingGutachten(false)
|
||||
}
|
||||
}, [studentId, criteriaScores])
|
||||
|
||||
// Export PDF helpers
|
||||
const downloadBlob = useCallback((blob: Blob, filename: string) => {
|
||||
const url = window.URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = filename
|
||||
document.body.appendChild(a)
|
||||
a.click()
|
||||
document.body.removeChild(a)
|
||||
window.URL.revokeObjectURL(url)
|
||||
}, [])
|
||||
|
||||
const exportGutachtenPDF = useCallback(async () => {
|
||||
try {
|
||||
setExporting(true)
|
||||
setError(null)
|
||||
const res = await fetch(`${API_BASE}/api/v1/students/${studentId}/export/gutachten`)
|
||||
if (res.ok) {
|
||||
const blob = await res.blob()
|
||||
downloadBlob(blob, `Gutachten_${student?.anonym_id?.replace(/\s+/g, '_') || 'Student'}_${new Date().toISOString().split('T')[0]}.pdf`)
|
||||
} else {
|
||||
setError('Fehler beim PDF-Export')
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to export PDF:', err)
|
||||
setError('Fehler beim PDF-Export')
|
||||
} finally {
|
||||
setExporting(false)
|
||||
}
|
||||
}, [studentId, student?.anonym_id, downloadBlob])
|
||||
|
||||
const exportAnnotationsPDF = useCallback(async () => {
|
||||
try {
|
||||
setExporting(true)
|
||||
setError(null)
|
||||
const res = await fetch(`${API_BASE}/api/v1/students/${studentId}/export/annotations`)
|
||||
if (res.ok) {
|
||||
const blob = await res.blob()
|
||||
downloadBlob(blob, `Anmerkungen_${student?.anonym_id?.replace(/\s+/g, '_') || 'Student'}_${new Date().toISOString().split('T')[0]}.pdf`)
|
||||
} else {
|
||||
setError('Fehler beim PDF-Export')
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to export annotations PDF:', err)
|
||||
setError('Fehler beim PDF-Export')
|
||||
} finally {
|
||||
setExporting(false)
|
||||
}
|
||||
}, [studentId, student?.anonym_id, downloadBlob])
|
||||
|
||||
// Handle criteria change
|
||||
const handleCriteriaChange = (criterion: string, value: number) => {
|
||||
const newScores = { ...criteriaScores, [criterion]: value }
|
||||
setCriteriaScores(newScores)
|
||||
saveCriteriaScores(newScores)
|
||||
}
|
||||
|
||||
// Calculate total points
|
||||
const calculateTotalPoints = useCallback(() => {
|
||||
if (!gradeInfo?.criteria) return { raw: 0, weighted: 0, gradePoints: 0 }
|
||||
let totalWeighted = 0
|
||||
let totalWeight = 0
|
||||
Object.entries(gradeInfo.criteria).forEach(([key, criterion]) => {
|
||||
const score = criteriaScores[key] || 0
|
||||
totalWeighted += score * (criterion.weight / 100)
|
||||
totalWeight += criterion.weight
|
||||
})
|
||||
const percentage = totalWeight > 0 ? (totalWeighted / totalWeight) * 100 : 0
|
||||
let gradePoints = 0
|
||||
const thresholds = [
|
||||
{ points: 15, min: 95 }, { points: 14, min: 90 }, { points: 13, min: 85 },
|
||||
{ points: 12, min: 80 }, { points: 11, min: 75 }, { points: 10, min: 70 },
|
||||
{ points: 9, min: 65 }, { points: 8, min: 60 }, { points: 7, min: 55 },
|
||||
{ points: 6, min: 50 }, { points: 5, min: 45 }, { points: 4, min: 40 },
|
||||
{ points: 3, min: 33 }, { points: 2, min: 27 }, { points: 1, min: 20 },
|
||||
]
|
||||
for (const t of thresholds) {
|
||||
if (percentage >= t.min) { gradePoints = t.points; break }
|
||||
}
|
||||
return { raw: Math.round(totalWeighted), weighted: Math.round(percentage), gradePoints }
|
||||
}, [gradeInfo, criteriaScores])
|
||||
|
||||
const totals = calculateTotalPoints()
|
||||
|
||||
// Submit Erstkorrektur
|
||||
const submitErstkorrektur = useCallback(async () => {
|
||||
try {
|
||||
setSubmittingWorkflow(true)
|
||||
const assignRes = await fetch(`${API_BASE}/api/v1/students/${studentId}/examiner`, {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ examiner_id: 'current-user', examiner_role: 'first_examiner' }),
|
||||
})
|
||||
if (!assignRes.ok && assignRes.status !== 400) {
|
||||
const error = await assignRes.json()
|
||||
throw new Error(error.detail || 'Fehler bei der Zuweisung')
|
||||
}
|
||||
const submitRes = await fetch(`${API_BASE}/api/v1/students/${studentId}/examiner/result`, {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ grade_points: totals.gradePoints, notes: gutachten }),
|
||||
})
|
||||
if (submitRes.ok) { fetchData() }
|
||||
else {
|
||||
const error = await submitRes.json()
|
||||
setError(error.detail || 'Fehler beim Abschliessen der Erstkorrektur')
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to submit Erstkorrektur:', err)
|
||||
setError('Fehler beim Abschliessen der Erstkorrektur')
|
||||
} finally {
|
||||
setSubmittingWorkflow(false)
|
||||
}
|
||||
}, [studentId, totals.gradePoints, gutachten, fetchData])
|
||||
|
||||
// Start Zweitkorrektur
|
||||
const startZweitkorrektur = useCallback(async (zweitkorrektorId: string) => {
|
||||
try {
|
||||
setSubmittingWorkflow(true)
|
||||
const res = await fetch(`${API_BASE}/api/v1/students/${studentId}/start-zweitkorrektur`, {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ zweitkorrektor_id: zweitkorrektorId }),
|
||||
})
|
||||
if (res.ok) fetchData()
|
||||
else {
|
||||
const error = await res.json()
|
||||
setError(error.detail || 'Fehler beim Starten der Zweitkorrektur')
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to start Zweitkorrektur:', err)
|
||||
setError('Fehler beim Starten der Zweitkorrektur')
|
||||
} finally {
|
||||
setSubmittingWorkflow(false)
|
||||
}
|
||||
}, [studentId, fetchData])
|
||||
|
||||
// Submit Zweitkorrektur
|
||||
const submitZweitkorrektur = useCallback(async () => {
|
||||
try {
|
||||
setSubmittingWorkflow(true)
|
||||
const res = await fetch(`${API_BASE}/api/v1/students/${studentId}/submit-zweitkorrektur`, {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
grade_points: totals.gradePoints, criteria_scores: criteriaScores,
|
||||
gutachten: gutachten ? { text: gutachten } : null, notes: '',
|
||||
}),
|
||||
})
|
||||
if (res.ok) {
|
||||
const result = await res.json()
|
||||
if (result.workflow_status === 'completed') {
|
||||
alert(`Auto-Konsens erreicht! Endnote: ${result.final_grade} Punkte`)
|
||||
} else if (result.workflow_status === 'einigung_required') {
|
||||
setShowEinigungModal(true)
|
||||
} else if (result.workflow_status === 'drittkorrektur_required') {
|
||||
alert(`Drittkorrektur erforderlich: Differenz ${result.grade_difference} Punkte`)
|
||||
}
|
||||
fetchData()
|
||||
} else {
|
||||
const error = await res.json()
|
||||
setError(error.detail || 'Fehler beim Abschliessen der Zweitkorrektur')
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to submit Zweitkorrektur:', err)
|
||||
setError('Fehler beim Abschliessen der Zweitkorrektur')
|
||||
} finally {
|
||||
setSubmittingWorkflow(false)
|
||||
}
|
||||
}, [studentId, totals.gradePoints, criteriaScores, gutachten, fetchData])
|
||||
|
||||
// Submit Einigung
|
||||
const submitEinigung = useCallback(async (type: 'agreed' | 'split' | 'escalated') => {
|
||||
try {
|
||||
setSubmittingWorkflow(true)
|
||||
const res = await fetch(`${API_BASE}/api/v1/students/${studentId}/einigung`, {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ final_grade: einigungGrade, einigung_notes: einigungNotes, einigung_type: type }),
|
||||
})
|
||||
if (res.ok) {
|
||||
const result = await res.json()
|
||||
setShowEinigungModal(false)
|
||||
if (result.workflow_status === 'drittkorrektur_required') alert('Eskaliert zu Drittkorrektur')
|
||||
else alert(`Einigung abgeschlossen: Endnote ${result.final_grade} Punkte`)
|
||||
fetchData()
|
||||
} else {
|
||||
const error = await res.json()
|
||||
setError(error.detail || 'Fehler bei der Einigung')
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to submit Einigung:', err)
|
||||
setError('Fehler bei der Einigung')
|
||||
} finally {
|
||||
setSubmittingWorkflow(false)
|
||||
}
|
||||
}, [studentId, einigungGrade, einigungNotes, fetchData])
|
||||
|
||||
return {
|
||||
// Data
|
||||
klausur, student, students, annotations, gradeInfo, workflow, documentUrl,
|
||||
// UI state
|
||||
loading, saving, error, activeTab, currentPage, totalPages, zoom,
|
||||
generatingGutachten, exporting, selectedTool, selectedAnnotation,
|
||||
criteriaScores, gutachten, showEinigungModal, einigungGrade, einigungNotes,
|
||||
submittingWorkflow, currentIndex, annotationCounts, totals,
|
||||
// Setters
|
||||
setError, setActiveTab, setCurrentPage, setZoom, setSelectedTool,
|
||||
setSelectedAnnotation, setGutachten, setShowEinigungModal,
|
||||
setEinigungGrade, setEinigungNotes, setCriteriaScores,
|
||||
// Actions
|
||||
createAnnotation, updateAnnotation, deleteAnnotation,
|
||||
handleCriteriaChange, saveCriteriaScores, saveGutachten, generateGutachten,
|
||||
exportGutachtenPDF, exportAnnotationsPDF,
|
||||
submitErstkorrektur, startZweitkorrektur, submitZweitkorrektur, submitEinigung,
|
||||
fetchData,
|
||||
}
|
||||
}
|
||||
81
website/components/klausur-korrektur/workspace-types.ts
Normal file
81
website/components/klausur-korrektur/workspace-types.ts
Normal file
@@ -0,0 +1,81 @@
|
||||
/**
|
||||
* Types and constants for the Korrektur-Workspace.
|
||||
* Shared between admin and lehrer routes.
|
||||
*/
|
||||
|
||||
import type { CriteriaScores } from '../../app/admin/klausur-korrektur/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
|
||||
}
|
||||
|
||||
export type ActiveTab = 'kriterien' | 'gutachten' | 'annotationen' | 'eh-vorschlaege'
|
||||
|
||||
// Workflow status labels
|
||||
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' },
|
||||
}
|
||||
|
||||
export const API_BASE = process.env.NEXT_PUBLIC_KLAUSUR_SERVICE_URL || 'http://localhost:8086'
|
||||
|
||||
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',
|
||||
}
|
||||
Reference in New Issue
Block a user