[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:
@@ -0,0 +1,88 @@
|
||||
'use client'
|
||||
|
||||
import type { Feature } from './types'
|
||||
import { priorityColors } from './types'
|
||||
|
||||
interface BacklogTabProps {
|
||||
features: Feature[]
|
||||
}
|
||||
|
||||
export default function BacklogTab({ features }: BacklogTabProps) {
|
||||
return (
|
||||
<div>
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
{/* Todo Column */}
|
||||
<div className="bg-amber-50 rounded-xl p-4">
|
||||
<h3 className="font-semibold text-amber-800 mb-3 flex items-center gap-2">
|
||||
<span className="w-6 h-6 bg-amber-200 rounded-full flex items-center justify-center text-sm">
|
||||
{features.filter(f => f.status === 'todo').length}
|
||||
</span>
|
||||
Todo
|
||||
</h3>
|
||||
<div className="space-y-2">
|
||||
{features.filter(f => f.status === 'todo').map(f => (
|
||||
<div key={f.id} className="bg-white p-3 rounded-lg shadow-sm">
|
||||
<div className="font-medium text-sm text-slate-900">{f.title}</div>
|
||||
<div className="text-xs text-slate-500 mt-1">{f.description}</div>
|
||||
<div className="flex gap-1 mt-2">
|
||||
<span className={`px-1.5 py-0.5 rounded text-xs ${priorityColors[f.priority]}`}>
|
||||
{f.priority}
|
||||
</span>
|
||||
<span className="px-1.5 py-0.5 bg-slate-100 text-slate-600 rounded text-xs">
|
||||
{f.effort}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* In Progress Column */}
|
||||
<div className="bg-blue-50 rounded-xl p-4">
|
||||
<h3 className="font-semibold text-blue-800 mb-3 flex items-center gap-2">
|
||||
<span className="w-6 h-6 bg-blue-200 rounded-full flex items-center justify-center text-sm">
|
||||
{features.filter(f => f.status === 'in_progress').length}
|
||||
</span>
|
||||
In Arbeit
|
||||
</h3>
|
||||
<div className="space-y-2">
|
||||
{features.filter(f => f.status === 'in_progress').map(f => (
|
||||
<div key={f.id} className="bg-white p-3 rounded-lg shadow-sm">
|
||||
<div className="font-medium text-sm text-slate-900">{f.title}</div>
|
||||
<div className="text-xs text-slate-500 mt-1">{f.description}</div>
|
||||
<div className="flex gap-1 mt-2">
|
||||
<span className={`px-1.5 py-0.5 rounded text-xs ${priorityColors[f.priority]}`}>
|
||||
{f.priority}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Backlog Column */}
|
||||
<div className="bg-slate-100 rounded-xl p-4">
|
||||
<h3 className="font-semibold text-slate-700 mb-3 flex items-center gap-2">
|
||||
<span className="w-6 h-6 bg-slate-300 rounded-full flex items-center justify-center text-sm">
|
||||
{features.filter(f => f.status === 'backlog').length}
|
||||
</span>
|
||||
Backlog
|
||||
</h3>
|
||||
<div className="space-y-2">
|
||||
{features.filter(f => f.status === 'backlog').map(f => (
|
||||
<div key={f.id} className="bg-white p-3 rounded-lg shadow-sm">
|
||||
<div className="font-medium text-sm text-slate-900">{f.title}</div>
|
||||
<div className="text-xs text-slate-500 mt-1">{f.description}</div>
|
||||
<div className="flex gap-1 mt-2">
|
||||
<span className={`px-1.5 py-0.5 rounded text-xs ${priorityColors[f.priority]}`}>
|
||||
{f.priority}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
'use client'
|
||||
|
||||
import type { Feature } from './types'
|
||||
import { statusColors, priorityColors } from './types'
|
||||
import { roadmapPhases } from './data'
|
||||
|
||||
interface FeaturesTabProps {
|
||||
features: Feature[]
|
||||
selectedPhase: string | null
|
||||
setSelectedPhase: (phase: string | null) => void
|
||||
updateFeatureStatus: (id: string, status: Feature['status']) => void
|
||||
}
|
||||
|
||||
export default function FeaturesTab({ features, selectedPhase, setSelectedPhase, updateFeatureStatus }: FeaturesTabProps) {
|
||||
return (
|
||||
<div>
|
||||
{/* Phase Filter */}
|
||||
<div className="flex gap-2 mb-4 flex-wrap">
|
||||
<button
|
||||
onClick={() => setSelectedPhase(null)}
|
||||
className={`px-3 py-1.5 rounded-lg text-sm font-medium transition-colors ${
|
||||
!selectedPhase ? 'bg-primary-600 text-white' : 'bg-slate-100 text-slate-600 hover:bg-slate-200'
|
||||
}`}
|
||||
>
|
||||
Alle
|
||||
</button>
|
||||
{roadmapPhases.map(phase => (
|
||||
<button
|
||||
key={phase.id}
|
||||
onClick={() => setSelectedPhase(phase.id)}
|
||||
className={`px-3 py-1.5 rounded-lg text-sm font-medium transition-colors ${
|
||||
selectedPhase === phase.id ? 'bg-primary-600 text-white' : 'bg-slate-100 text-slate-600 hover:bg-slate-200'
|
||||
}`}
|
||||
>
|
||||
{phase.name.replace('Phase ', 'P')}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Features List */}
|
||||
<div className="space-y-2">
|
||||
{features
|
||||
.filter(f => !selectedPhase || f.phase === selectedPhase)
|
||||
.map(feature => (
|
||||
<div key={feature.id} className="flex items-center gap-4 p-3 bg-slate-50 rounded-lg">
|
||||
<span className={`px-2 py-0.5 rounded text-xs font-medium ${priorityColors[feature.priority]}`}>
|
||||
{feature.priority}
|
||||
</span>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="font-medium text-slate-900 truncate">{feature.title}</div>
|
||||
<div className="text-xs text-slate-500 truncate">{feature.description}</div>
|
||||
</div>
|
||||
<span className={`px-2 py-1 rounded text-xs font-medium whitespace-nowrap ${
|
||||
feature.effort === 'small' ? 'bg-green-100 text-green-700' :
|
||||
feature.effort === 'medium' ? 'bg-amber-100 text-amber-700' :
|
||||
feature.effort === 'large' ? 'bg-orange-100 text-orange-700' :
|
||||
'bg-red-100 text-red-700'
|
||||
}`}>
|
||||
{feature.effort}
|
||||
</span>
|
||||
<select
|
||||
value={feature.status}
|
||||
onChange={(e) => updateFeatureStatus(feature.id, e.target.value as Feature['status'])}
|
||||
className={`px-2 py-1 rounded text-xs font-medium border-0 cursor-pointer ${statusColors[feature.status]}`}
|
||||
>
|
||||
<option value="done">Fertig</option>
|
||||
<option value="in_progress">In Arbeit</option>
|
||||
<option value="todo">Todo</option>
|
||||
<option value="backlog">Backlog</option>
|
||||
</select>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
'use client'
|
||||
|
||||
import type { Feature, TeacherFeedback } from './types'
|
||||
import { priorityColors, feedbackTypeIcons } from './types'
|
||||
|
||||
interface FeedbackTabProps {
|
||||
features: Feature[]
|
||||
filteredFeedback: TeacherFeedback[]
|
||||
feedbackFilter: string
|
||||
setFeedbackFilter: (filter: string) => void
|
||||
updateFeedbackStatus: (id: string, status: TeacherFeedback['status']) => void
|
||||
}
|
||||
|
||||
export default function FeedbackTab({
|
||||
features,
|
||||
filteredFeedback,
|
||||
feedbackFilter,
|
||||
setFeedbackFilter,
|
||||
updateFeedbackStatus,
|
||||
}: FeedbackTabProps) {
|
||||
return (
|
||||
<div>
|
||||
{/* Filter */}
|
||||
<div className="flex gap-2 mb-4 flex-wrap">
|
||||
{['all', 'new', 'bug', 'feature_request', 'improvement'].map(filter => (
|
||||
<button
|
||||
key={filter}
|
||||
onClick={() => setFeedbackFilter(filter)}
|
||||
className={`px-3 py-1.5 rounded-lg text-sm font-medium transition-colors ${
|
||||
feedbackFilter === filter ? 'bg-primary-600 text-white' : 'bg-slate-100 text-slate-600 hover:bg-slate-200'
|
||||
}`}
|
||||
>
|
||||
{filter === 'all' ? 'Alle' :
|
||||
filter === 'new' ? 'Neu' :
|
||||
filter === 'bug' ? 'Bugs' :
|
||||
filter === 'feature_request' ? 'Feature-Requests' : 'Verbesserungen'}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Feedback List */}
|
||||
<div className="space-y-3">
|
||||
{filteredFeedback.map(fb => (
|
||||
<div key={fb.id} className="border border-slate-200 rounded-xl p-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className={`w-10 h-10 rounded-full flex items-center justify-center ${
|
||||
fb.type === 'bug' ? 'bg-red-100' :
|
||||
fb.type === 'feature_request' ? 'bg-blue-100' :
|
||||
fb.type === 'improvement' ? 'bg-amber-100' :
|
||||
fb.type === 'praise' ? 'bg-pink-100' : 'bg-purple-100'
|
||||
}`}>
|
||||
<svg className={`w-5 h-5 ${
|
||||
fb.type === 'bug' ? 'text-red-600' :
|
||||
fb.type === 'feature_request' ? 'text-blue-600' :
|
||||
fb.type === 'improvement' ? 'text-amber-600' :
|
||||
fb.type === 'praise' ? 'text-pink-600' : 'text-purple-600'
|
||||
}`} fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d={feedbackTypeIcons[fb.type]} />
|
||||
</svg>
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<span className="font-semibold text-slate-900">{fb.title}</span>
|
||||
<span className={`px-1.5 py-0.5 rounded text-xs ${priorityColors[fb.priority]}`}>
|
||||
{fb.priority}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-sm text-slate-600 mb-2">{fb.description}</p>
|
||||
<div className="flex items-center gap-4 text-xs text-slate-400">
|
||||
<span>{fb.teacher}</span>
|
||||
<span>{fb.date}</span>
|
||||
{fb.relatedFeature && (
|
||||
<span className="text-primary-600">→ {features.find(f => f.id === fb.relatedFeature)?.title}</span>
|
||||
)}
|
||||
</div>
|
||||
{fb.response && (
|
||||
<div className="mt-2 p-2 bg-green-50 rounded text-sm text-green-800">
|
||||
<strong>Antwort:</strong> {fb.response}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<select
|
||||
value={fb.status}
|
||||
onChange={(e) => updateFeedbackStatus(fb.id, e.target.value as TeacherFeedback['status'])}
|
||||
className={`px-2 py-1 rounded text-xs font-medium border-0 cursor-pointer ${
|
||||
fb.status === 'new' ? 'bg-red-100 text-red-700' :
|
||||
fb.status === 'acknowledged' ? 'bg-blue-100 text-blue-700' :
|
||||
fb.status === 'planned' ? 'bg-amber-100 text-amber-700' :
|
||||
fb.status === 'implemented' ? 'bg-green-100 text-green-700' :
|
||||
'bg-slate-100 text-slate-600'
|
||||
}`}
|
||||
>
|
||||
<option value="new">Neu</option>
|
||||
<option value="acknowledged">Gesehen</option>
|
||||
<option value="planned">Geplant</option>
|
||||
<option value="implemented">Umgesetzt</option>
|
||||
<option value="declined">Abgelehnt</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Add Feedback Button */}
|
||||
<button className="mt-4 w-full py-3 border-2 border-dashed border-slate-300 rounded-xl text-slate-500 hover:border-primary-400 hover:text-primary-600 transition-colors">
|
||||
+ Neues Feedback hinzufuegen
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
'use client'
|
||||
|
||||
import { roadmapPhases } from './data'
|
||||
|
||||
export default function RoadmapTab() {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{roadmapPhases.map((phase, index) => (
|
||||
<div
|
||||
key={phase.id}
|
||||
className={`border rounded-xl overflow-hidden ${
|
||||
phase.status === 'completed' ? 'border-green-200 bg-green-50/50' :
|
||||
phase.status === 'in_progress' ? 'border-blue-200 bg-blue-50/50' :
|
||||
phase.status === 'planned' ? 'border-amber-200 bg-amber-50/50' :
|
||||
'border-slate-200 bg-slate-50/50'
|
||||
}`}
|
||||
>
|
||||
<div className="p-4">
|
||||
<div className="flex items-start justify-between mb-2">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className={`w-8 h-8 rounded-full flex items-center justify-center text-sm font-bold ${
|
||||
phase.status === 'completed' ? 'bg-green-500 text-white' :
|
||||
phase.status === 'in_progress' ? 'bg-blue-500 text-white' :
|
||||
phase.status === 'planned' ? 'bg-amber-500 text-white' :
|
||||
'bg-slate-300 text-slate-600'
|
||||
}`}>
|
||||
{phase.status === 'completed' ? '✓' : index + 1}
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-semibold text-slate-900">{phase.name}</h3>
|
||||
<p className="text-sm text-slate-500">{phase.description}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<span className={`px-2 py-1 rounded text-xs font-medium ${
|
||||
phase.status === 'completed' ? 'bg-green-100 text-green-800' :
|
||||
phase.status === 'in_progress' ? 'bg-blue-100 text-blue-800' :
|
||||
phase.status === 'planned' ? 'bg-amber-100 text-amber-800' :
|
||||
'bg-slate-100 text-slate-600'
|
||||
}`}>
|
||||
{phase.status === 'completed' ? 'Abgeschlossen' :
|
||||
phase.status === 'in_progress' ? 'In Arbeit' :
|
||||
phase.status === 'planned' ? 'Geplant' : 'Zukunft'}
|
||||
</span>
|
||||
{phase.startDate && (
|
||||
<div className="text-xs text-slate-400 mt-1">
|
||||
{phase.startDate} {phase.endDate ? `- ${phase.endDate}` : ''}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Progress Bar */}
|
||||
<div className="mt-3 mb-3">
|
||||
<div className="flex justify-between text-xs text-slate-500 mb-1">
|
||||
<span>Fortschritt</span>
|
||||
<span>{phase.progress}%</span>
|
||||
</div>
|
||||
<div className="w-full bg-slate-200 rounded-full h-2">
|
||||
<div
|
||||
className={`h-2 rounded-full transition-all ${
|
||||
phase.status === 'completed' ? 'bg-green-500' :
|
||||
phase.status === 'in_progress' ? 'bg-blue-500' :
|
||||
'bg-amber-500'
|
||||
}`}
|
||||
style={{ width: `${phase.progress}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Features */}
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{phase.features.map((feature, i) => (
|
||||
<span key={i} className="px-2 py-1 bg-white border border-slate-200 rounded text-xs text-slate-600">
|
||||
{feature}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
'use client'
|
||||
|
||||
interface StatsOverviewProps {
|
||||
phaseStats: { completed: number; total: number; inProgress: number }
|
||||
featureStats: { percentage: number; done: number; total: number }
|
||||
feedbackStats: { newCount: number; total: number; bugs: number; requests: number }
|
||||
}
|
||||
|
||||
export default function StatsOverview({ phaseStats, featureStats, feedbackStats }: StatsOverviewProps) {
|
||||
return (
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-4 mb-6">
|
||||
<div className="bg-white rounded-xl border border-slate-200 p-4">
|
||||
<div className="text-sm text-slate-500 mb-1">Roadmap-Phasen</div>
|
||||
<div className="text-2xl font-bold text-primary-600">{phaseStats.completed}/{phaseStats.total}</div>
|
||||
<div className="text-xs text-slate-400">{phaseStats.inProgress} in Arbeit</div>
|
||||
</div>
|
||||
<div className="bg-white rounded-xl border border-slate-200 p-4">
|
||||
<div className="text-sm text-slate-500 mb-1">Features</div>
|
||||
<div className="text-2xl font-bold text-green-600">{featureStats.percentage}%</div>
|
||||
<div className="text-xs text-slate-400">{featureStats.done}/{featureStats.total} fertig</div>
|
||||
</div>
|
||||
<div className="bg-white rounded-xl border border-slate-200 p-4">
|
||||
<div className="text-sm text-slate-500 mb-1">Neues Feedback</div>
|
||||
<div className="text-2xl font-bold text-amber-600">{feedbackStats.newCount}</div>
|
||||
<div className="text-xs text-slate-400">{feedbackStats.total} gesamt</div>
|
||||
</div>
|
||||
<div className="bg-white rounded-xl border border-slate-200 p-4">
|
||||
<div className="text-sm text-slate-500 mb-1">Offene Bugs</div>
|
||||
<div className="text-2xl font-bold text-red-600">{feedbackStats.bugs}</div>
|
||||
<div className="text-xs text-slate-400">{feedbackStats.requests} Feature-Requests</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,404 @@
|
||||
import type { RoadmapPhase, Feature, TeacherFeedback } from './types'
|
||||
|
||||
// ==================== ROADMAP DATA ====================
|
||||
|
||||
export const roadmapPhases: RoadmapPhase[] = [
|
||||
{
|
||||
id: 'phase-1',
|
||||
name: 'Phase 1: Core Engine',
|
||||
status: 'completed',
|
||||
progress: 100,
|
||||
startDate: '2026-01-10',
|
||||
endDate: '2026-01-14',
|
||||
description: 'Grundlegende State Machine und API-Endpunkte',
|
||||
features: [
|
||||
'Finite State Machine (5 Phasen)',
|
||||
'Timer Service mit Countdown',
|
||||
'Phasenspezifische Suggestions',
|
||||
'REST API Endpoints',
|
||||
'In-Memory Session Storage',
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'phase-2',
|
||||
name: 'Phase 2: Frontend Integration',
|
||||
status: 'completed',
|
||||
progress: 100,
|
||||
startDate: '2026-01-14',
|
||||
endDate: '2026-01-14',
|
||||
description: 'Integration in das Studio-Frontend',
|
||||
features: [
|
||||
'Lesson-Modus im Companion',
|
||||
'Timer-Anzeige mit Warning/Overtime',
|
||||
'Phasen-Timeline Visualisierung',
|
||||
'Suggestions pro Phase',
|
||||
'Session Start/End UI',
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'phase-2b',
|
||||
name: 'Phase 2b: Teacher UX Optimierung',
|
||||
status: 'completed',
|
||||
progress: 100,
|
||||
startDate: '2026-01-15',
|
||||
endDate: '2026-01-15',
|
||||
description: 'Forschungsbasierte UX-Verbesserungen fuer intuitive Lehrer-Bedienung',
|
||||
features: [
|
||||
'Visual Pie Timer (Kreis statt Zahlen)',
|
||||
'Phasen-Farbschema (Blau→Orange→Gruen→Lila→Grau)',
|
||||
'Quick Actions Bar (+5min, Pause, Skip)',
|
||||
'Tablet-First Responsive Design',
|
||||
'Large Touch Targets (48x48px min)',
|
||||
'High Contrast fuer Beamer',
|
||||
'Audio Cues (sanfte Toene)',
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'phase-3',
|
||||
name: 'Phase 3: Persistenz',
|
||||
status: 'completed',
|
||||
progress: 100,
|
||||
startDate: '2026-01-15',
|
||||
endDate: '2026-01-15',
|
||||
description: 'Datenbank-Anbindung und Session-Persistenz',
|
||||
features: [
|
||||
'PostgreSQL Integration (done)',
|
||||
'SQLAlchemy Models (done)',
|
||||
'Session Repository (done)',
|
||||
'Alembic Migration Scripts (done)',
|
||||
'Session History API (done)',
|
||||
'Hybrid Storage (Memory+DB) (done)',
|
||||
'Lehrer-spezifische Settings (backlog)',
|
||||
'Keycloak Auth Integration (backlog)',
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'phase-4',
|
||||
name: 'Phase 4: Content Integration',
|
||||
status: 'completed',
|
||||
progress: 100,
|
||||
startDate: '2026-01-15',
|
||||
endDate: '2026-01-15',
|
||||
description: 'Verknuepfung mit Learning Units',
|
||||
features: [
|
||||
'Lesson Templates (done)',
|
||||
'Fachspezifische Unit-Vorschlaege (done)',
|
||||
'Hausaufgaben-Tracker (done)',
|
||||
'Material-Verknuepfung (done)',
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'phase-5',
|
||||
name: 'Phase 5: Analytics',
|
||||
status: 'completed',
|
||||
progress: 100,
|
||||
startDate: '2026-01-15',
|
||||
endDate: '2026-01-15',
|
||||
description: 'Unterrichtsanalyse und Optimierung (ohne wertende Metriken)',
|
||||
features: [
|
||||
'Phasen-Dauer Statistiken (done)',
|
||||
'Overtime-Analyse (done)',
|
||||
'Post-Lesson Reflection API (done)',
|
||||
'Lehrer-Dashboard UI (done)',
|
||||
'HTML/PDF Export (done)',
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'phase-6',
|
||||
name: 'Phase 6: Real-time',
|
||||
status: 'completed',
|
||||
progress: 100,
|
||||
startDate: '2026-01-15',
|
||||
endDate: '2026-01-15',
|
||||
description: 'WebSocket-basierte Echtzeit-Updates',
|
||||
features: [
|
||||
'WebSocket API Endpoint (done)',
|
||||
'Connection Manager mit Multi-Device Support (done)',
|
||||
'Timer Broadcast Loop (1-Sekunden-Genauigkeit) (done)',
|
||||
'Client-seitiger WebSocket Handler (done)',
|
||||
'Automatischer Reconnect mit Fallback zu Polling (done)',
|
||||
'Phase Change & Session End Notifications (done)',
|
||||
'Connection Status Indicator (done)',
|
||||
'WebSocket Tests (done)',
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'phase-7',
|
||||
name: 'Phase 7: Erweiterungen',
|
||||
status: 'completed',
|
||||
progress: 100,
|
||||
startDate: '2026-01-15',
|
||||
endDate: '2026-01-15',
|
||||
description: 'Lehrer-Feedback und Authentifizierung',
|
||||
features: [
|
||||
'Teacher Feedback API (done)',
|
||||
'Feedback Modal im Lehrer-Frontend (done)',
|
||||
'Keycloak Auth Integration (done)',
|
||||
'Optional Auth Dependency (done)',
|
||||
'Feedback DB Model & Migration (done)',
|
||||
'Feedback Repository (done)',
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'phase-8',
|
||||
name: 'Phase 8: Schuljahres-Begleiter',
|
||||
status: 'in_progress',
|
||||
progress: 85,
|
||||
startDate: '2026-01-15',
|
||||
description: '2-Schichten-Modell: Makro-Phasen (Schuljahr) + Mikro-Engine (Events/Routinen)',
|
||||
features: [
|
||||
'Kontext-Datenmodell (TeacherContextDB, SchoolyearEventDB, RecurringRoutineDB) (done)',
|
||||
'Alembic Migration 007 (done)',
|
||||
'GET /v1/context Endpoint (done)',
|
||||
'Events & Routinen CRUD-APIs (done)',
|
||||
'Bundeslaender & Schularten Stammdaten (done)',
|
||||
'Antizipations-Engine mit 12 Regeln (done)',
|
||||
'GET /v1/suggestions Endpoint (done)',
|
||||
'Dynamische Sidebar /v1/sidebar (done)',
|
||||
'Schuljahres-Pfad /v1/path (done)',
|
||||
'Frontend ContextBar Component (done)',
|
||||
'Frontend Dynamic Sidebar (done)',
|
||||
'Frontend PathPanel Component (done)',
|
||||
'Main Content Actions Integration (done)',
|
||||
'Onboarding-Flow (geplant)',
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'phase-9',
|
||||
name: 'Phase 9: Zukunft',
|
||||
status: 'future',
|
||||
progress: 0,
|
||||
description: 'Weitere geplante Features',
|
||||
features: [
|
||||
'Push Notifications',
|
||||
'Dark Mode',
|
||||
'Lesson Templates Library (erweitert)',
|
||||
'Multi-Language Support',
|
||||
'KI-Assistenz fuer Unterrichtsplanung',
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
// ==================== FEATURES DATA ====================
|
||||
|
||||
export const initialFeatures: Feature[] = [
|
||||
// Phase 1 - Done
|
||||
{ id: 'f1', title: 'LessonPhase Enum', description: '7 Zustaende: not_started, 5 Phasen, ended', priority: 'critical', status: 'done', phase: 'phase-1', effort: 'small' },
|
||||
{ id: 'f2', title: 'LessonSession Dataclass', description: 'Session-Datenmodell mit History', priority: 'critical', status: 'done', phase: 'phase-1', effort: 'medium' },
|
||||
{ id: 'f3', title: 'FSM Transitions', description: 'Erlaubte Phasen-Uebergaenge', priority: 'critical', status: 'done', phase: 'phase-1', effort: 'medium' },
|
||||
{ id: 'f4', title: 'PhaseTimer Service', description: 'Countdown, Warning, Overtime', priority: 'high', status: 'done', phase: 'phase-1', effort: 'medium' },
|
||||
{ id: 'f5', title: 'SuggestionEngine', description: 'Phasenspezifische Aktivitaets-Vorschlaege', priority: 'high', status: 'done', phase: 'phase-1', effort: 'large' },
|
||||
{ id: 'f6', title: 'REST API Endpoints', description: '10 Endpoints unter /api/classroom', priority: 'critical', status: 'done', phase: 'phase-1', effort: 'large' },
|
||||
|
||||
// Phase 2 - Done
|
||||
{ id: 'f7', title: 'Mode Toggle (3 Modi)', description: 'Begleiter, Stunde, Klassisch', priority: 'high', status: 'done', phase: 'phase-2', effort: 'small' },
|
||||
{ id: 'f8', title: 'Timer-Display', description: 'Grosser Countdown mit Styling', priority: 'high', status: 'done', phase: 'phase-2', effort: 'medium' },
|
||||
{ id: 'f9', title: 'Phasen-Timeline', description: 'Horizontale 5-Phasen-Anzeige', priority: 'high', status: 'done', phase: 'phase-2', effort: 'medium' },
|
||||
{ id: 'f10', title: 'Control Buttons', description: 'Naechste Phase, Beenden', priority: 'high', status: 'done', phase: 'phase-2', effort: 'small' },
|
||||
{ id: 'f11', title: 'Suggestions Cards', description: 'Aktivitaets-Vorschlaege UI', priority: 'medium', status: 'done', phase: 'phase-2', effort: 'medium' },
|
||||
{ id: 'f12', title: 'Session Start Form', description: 'Klasse, Fach, Thema auswaehlen', priority: 'high', status: 'done', phase: 'phase-2', effort: 'small' },
|
||||
|
||||
// Phase 3 - In Progress (Persistenz)
|
||||
{ id: 'f13', title: 'PostgreSQL Models', description: 'SQLAlchemy Models fuer Sessions (LessonSessionDB, PhaseHistoryDB, TeacherSettingsDB)', priority: 'critical', status: 'done', phase: 'phase-3', effort: 'medium' },
|
||||
{ id: 'f14', title: 'Session Repository', description: 'CRUD Operationen fuer Sessions (SessionRepository, TeacherSettingsRepository)', priority: 'critical', status: 'done', phase: 'phase-3', effort: 'medium' },
|
||||
{ id: 'f15', title: 'Migration Scripts', description: 'Alembic Migrationen fuer Classroom Tables', priority: 'high', status: 'done', phase: 'phase-3', effort: 'small' },
|
||||
{ id: 'f16', title: 'Teacher Settings', description: 'Individuelle Phasen-Dauern speichern (API + Settings Modal UI)', priority: 'medium', status: 'done', phase: 'phase-7', effort: 'medium' },
|
||||
{ id: 'f17', title: 'Session History API', description: 'GET /history/{teacher_id} mit Pagination', priority: 'medium', status: 'done', phase: 'phase-3', effort: 'small' },
|
||||
|
||||
// Phase 4 - In Progress (Content)
|
||||
{ id: 'f18', title: 'Unit-Vorschlaege', description: 'Fachspezifische Learning Units pro Phase (Mathe, Deutsch, Englisch, Bio, Physik, Informatik)', priority: 'high', status: 'done', phase: 'phase-4', effort: 'large' },
|
||||
{ id: 'f19', title: 'Material-Verknuepfung', description: 'Dokumente an Phasen anhaengen (PhaseMaterial Model, Repository, 8 API-Endpoints, Frontend-Integration)', priority: 'medium', status: 'done', phase: 'phase-4', effort: 'medium' },
|
||||
{ id: 'f20', title: 'Hausaufgaben-Tracker', description: 'CRUD API fuer Hausaufgaben mit Status und Faelligkeit', priority: 'medium', status: 'done', phase: 'phase-4', effort: 'medium' },
|
||||
|
||||
// ==================== NEUE UX FEATURES (aus Research) ====================
|
||||
|
||||
// P0 - KRITISCH (UX Research basiert)
|
||||
{ id: 'f21', title: 'Visual Pie Timer', description: 'Kreisfoermiger Countdown mit Farbverlauf (Gruen→Gelb→Rot) - reduziert Stress laut Forschung', priority: 'critical', status: 'done', phase: 'phase-2', effort: 'large' },
|
||||
{ id: 'f22', title: 'Database Persistence', description: 'PostgreSQL statt In-Memory - Sessions ueberleben Neustart (Hybrid Storage)', priority: 'critical', status: 'done', phase: 'phase-3', effort: 'large' },
|
||||
{ id: 'f23', title: 'Teacher Auth Integration', description: 'Keycloak-Anbindung mit optionalem Demo-User Fallback', priority: 'critical', status: 'done', phase: 'phase-7', effort: 'large' },
|
||||
{ id: 'f24', title: 'Tablet-First Responsive', description: 'Optimiert fuer 10" Touch-Screens, Einhand-Bedienung im Klassenraum', priority: 'critical', status: 'done', phase: 'phase-2b', effort: 'medium' },
|
||||
|
||||
// P1 - WICHTIG (UX Research basiert)
|
||||
{ id: 'f25', title: 'Phasen-Farbschema', description: 'Forschungsbasierte Farben: Blau(Einstieg), Orange(Erarbeitung), Gruen(Sicherung), Lila(Transfer), Grau(Reflexion)', priority: 'high', status: 'done', phase: 'phase-2b', effort: 'medium' },
|
||||
{ id: 'f26', title: 'Quick Actions Bar', description: '+5min, Pause, Skip-Phase als One-Click Touch-Buttons (min 56px)', priority: 'high', status: 'done', phase: 'phase-2b', effort: 'medium' },
|
||||
{ id: 'f27', title: 'Pause Timer API', description: 'POST /sessions/{id}/pause - Timer anhalten bei Stoerungen', priority: 'high', status: 'done', phase: 'phase-2b', effort: 'small' },
|
||||
{ id: 'f28', title: 'Extend Phase API', description: 'POST /sessions/{id}/extend?minutes=5 - Phase verlaengern', priority: 'high', status: 'done', phase: 'phase-2b', effort: 'small' },
|
||||
{ id: 'f29', title: 'Non-Intrusive Suggestions', description: 'Vorschlaege in dedizierter Sektion, nicht als stoerende Popups', priority: 'high', status: 'done', phase: 'phase-2', effort: 'medium' },
|
||||
{ id: 'f30', title: 'WebSocket Real-Time Timer', description: 'Sub-Sekunden Genauigkeit statt 5s Polling', priority: 'high', status: 'done', phase: 'phase-6', effort: 'large' },
|
||||
{ id: 'f31', title: 'Mobile Breakpoints', description: 'Responsive Design fuer 600px, 900px, 1200px Screens', priority: 'high', status: 'done', phase: 'phase-2b', effort: 'medium' },
|
||||
{ id: 'f32', title: 'Large Touch Targets', description: 'Alle Buttons min 48x48px fuer sichere Touch-Bedienung', priority: 'high', status: 'done', phase: 'phase-2b', effort: 'small' },
|
||||
|
||||
// P2 - NICE-TO-HAVE (UX Research basiert)
|
||||
{ id: 'f33', title: 'Audio Cues', description: 'Sanfte Toene bei Phasenwechsel und Warnungen (Taste A zum Toggle)', priority: 'medium', status: 'done', phase: 'phase-2b', effort: 'small' },
|
||||
{ id: 'f34', title: 'Keyboard Shortcuts', description: 'Space=Pause, N=Next Phase, E=Extend, H=High Contrast - fuer Desktop-Nutzung', priority: 'medium', status: 'done', phase: 'phase-2b', effort: 'small' },
|
||||
{ id: 'f35', title: 'Offline Timer Fallback', description: 'Client-seitige Timer-Berechnung bei Verbindungsverlust', priority: 'medium', status: 'done', phase: 'phase-2b', effort: 'medium' },
|
||||
{ id: 'f36', title: 'Post-Lesson Analytics', description: 'Phasen-Dauer Statistiken ohne wertende Metriken (SessionSummary, TeacherAnalytics, 4 API-Endpoints)', priority: 'medium', status: 'done', phase: 'phase-5', effort: 'large' },
|
||||
{ id: 'f37', title: 'Lesson Templates', description: '5 System-Templates + eigene Vorlagen erstellen/speichern', priority: 'medium', status: 'done', phase: 'phase-4', effort: 'medium' },
|
||||
{ id: 'f38', title: 'ARIA Labels', description: 'Screen-Reader Unterstuetzung fuer Barrierefreiheit', priority: 'medium', status: 'done', phase: 'phase-2b', effort: 'small' },
|
||||
{ id: 'f39', title: 'High Contrast Mode', description: 'Erhoehter Kontrast fuer Beamer/Projektor (Taste H)', priority: 'medium', status: 'done', phase: 'phase-2b', effort: 'small' },
|
||||
{ id: 'f40', title: 'Export to PDF', description: 'Stundenprotokoll als druckbares HTML mit Browser-PDF-Export (Strg+P)', priority: 'low', status: 'done', phase: 'phase-5', effort: 'medium' },
|
||||
{ id: 'f41', title: 'Overtime-Analyse', description: 'Phase-by-Phase Overtime-Statistiken und Trends', priority: 'medium', status: 'done', phase: 'phase-5', effort: 'medium' },
|
||||
{ id: 'f42', title: 'Post-Lesson Reflection', description: 'Reflexions-Notizen nach Stundenende (CRUD API, DB-Model)', priority: 'medium', status: 'done', phase: 'phase-5', effort: 'medium' },
|
||||
{ id: 'f43', title: 'Phase Duration Trends', description: 'Visualisierung der Phasendauer-Entwicklung', priority: 'medium', status: 'done', phase: 'phase-5', effort: 'small' },
|
||||
{ id: 'f44', title: 'Analytics Dashboard UI', description: 'Lehrer-Frontend fuer Analytics-Anzeige (Phasen-Bars, Overtime, Reflection)', priority: 'high', status: 'done', phase: 'phase-5', effort: 'medium' },
|
||||
|
||||
// Phase 6 - Real-time (WebSocket)
|
||||
{ id: 'f45', title: 'WebSocket API Endpoint', description: 'Real-time Verbindung unter /api/classroom/ws/{session_id}', priority: 'critical', status: 'done', phase: 'phase-6', effort: 'large' },
|
||||
{ id: 'f46', title: 'Connection Manager', description: 'Multi-Device Support mit Session-basierter Verbindungsverwaltung', priority: 'critical', status: 'done', phase: 'phase-6', effort: 'medium' },
|
||||
{ id: 'f47', title: 'Timer Broadcast Loop', description: 'Hintergrund-Task sendet Timer-Updates jede Sekunde an alle Clients', priority: 'critical', status: 'done', phase: 'phase-6', effort: 'medium' },
|
||||
{ id: 'f48', title: 'Client WebSocket Handler', description: 'Frontend-Integration mit automatischem Reconnect und Fallback zu Polling', priority: 'high', status: 'done', phase: 'phase-6', effort: 'large' },
|
||||
{ id: 'f49', title: 'Phase Change Notifications', description: 'Echtzeit-Benachrichtigung bei Phasenwechsel an alle Devices', priority: 'high', status: 'done', phase: 'phase-6', effort: 'small' },
|
||||
{ id: 'f50', title: 'Session End Notifications', description: 'Automatische Benachrichtigung bei Stundenende', priority: 'high', status: 'done', phase: 'phase-6', effort: 'small' },
|
||||
{ id: 'f51', title: 'Connection Status Indicator', description: 'UI-Element zeigt Live/Polling/Offline Status', priority: 'medium', status: 'done', phase: 'phase-6', effort: 'small' },
|
||||
{ id: 'f52', title: 'WebSocket Status API', description: 'GET /ws/status zeigt aktive Sessions und Verbindungszahlen', priority: 'low', status: 'done', phase: 'phase-6', effort: 'small' },
|
||||
|
||||
// Phase 7 - Erweiterungen (Auth & Feedback)
|
||||
{ id: 'f53', title: 'Teacher Feedback API', description: 'POST/GET /feedback Endpoints fuer Bug-Reports und Feature-Requests', priority: 'high', status: 'done', phase: 'phase-7', effort: 'large' },
|
||||
{ id: 'f54', title: 'Feedback Modal UI', description: 'Floating Action Button und Modal im Lehrer-Frontend', priority: 'high', status: 'done', phase: 'phase-7', effort: 'medium' },
|
||||
{ id: 'f55', title: 'Feedback DB Model', description: 'TeacherFeedbackDB SQLAlchemy Model mit Alembic Migration', priority: 'high', status: 'done', phase: 'phase-7', effort: 'medium' },
|
||||
{ id: 'f56', title: 'Feedback Repository', description: 'CRUD-Operationen fuer Feedback mit Status-Management', priority: 'high', status: 'done', phase: 'phase-7', effort: 'medium' },
|
||||
{ id: 'f57', title: 'Keycloak Auth Integration', description: 'Optional Auth Dependency mit Demo-User Fallback', priority: 'critical', status: 'done', phase: 'phase-7', effort: 'medium' },
|
||||
{ id: 'f58', title: 'Feedback Stats API', description: 'GET /feedback/stats fuer Dashboard-Statistiken', priority: 'medium', status: 'done', phase: 'phase-7', effort: 'small' },
|
||||
|
||||
// Phase 8 - Schuljahres-Begleiter (2-Schichten-Modell)
|
||||
{ id: 'f59', title: 'TeacherContextDB Model', description: 'Makro-Kontext pro Lehrer (Bundesland, Schulart, Schuljahr, Phase)', priority: 'critical', status: 'done', phase: 'phase-8', effort: 'medium' },
|
||||
{ id: 'f60', title: 'SchoolyearEventDB Model', description: 'Events (Klausuren, Elternabende, Klassenfahrten, etc.)', priority: 'critical', status: 'done', phase: 'phase-8', effort: 'medium' },
|
||||
{ id: 'f61', title: 'RecurringRoutineDB Model', description: 'Wiederkehrende Routinen (Konferenzen, Sprechstunden, etc.)', priority: 'critical', status: 'done', phase: 'phase-8', effort: 'medium' },
|
||||
{ id: 'f62', title: 'Alembic Migration 007', description: 'DB-Migration fuer teacher_contexts, schoolyear_events, recurring_routines', priority: 'critical', status: 'done', phase: 'phase-8', effort: 'small' },
|
||||
{ id: 'f63', title: 'GET /v1/context Endpoint', description: 'Makro-Kontext abrufen (Schuljahr, Woche, Phase, Flags)', priority: 'critical', status: 'done', phase: 'phase-8', effort: 'large' },
|
||||
{ id: 'f64', title: 'PUT /v1/context Endpoint', description: 'Kontext aktualisieren (Bundesland, Schulart, Schuljahr)', priority: 'high', status: 'done', phase: 'phase-8', effort: 'medium' },
|
||||
{ id: 'f65', title: 'Events CRUD-API', description: 'GET/POST/DELETE /v1/events mit Status und Vorbereitung', priority: 'high', status: 'done', phase: 'phase-8', effort: 'large' },
|
||||
{ id: 'f66', title: 'Routines CRUD-API', description: 'GET/POST/DELETE /v1/routines mit Wiederholungsmustern', priority: 'high', status: 'done', phase: 'phase-8', effort: 'large' },
|
||||
{ id: 'f67', title: 'Stammdaten-APIs', description: '/v1/federal-states, /v1/school-types, /v1/macro-phases, /v1/event-types', priority: 'medium', status: 'done', phase: 'phase-8', effort: 'small' },
|
||||
{ id: 'f68', title: 'Context Repositories', description: 'TeacherContextRepository, SchoolyearEventRepository, RecurringRoutineRepository', priority: 'high', status: 'done', phase: 'phase-8', effort: 'large' },
|
||||
{ id: 'f69', title: 'Antizipations-Engine', description: 'Signal-Collector + Regel-Engine (12 Regeln) fuer proaktive Vorschlaege', priority: 'high', status: 'done', phase: 'phase-8', effort: 'epic' },
|
||||
{ id: 'f70', title: 'GET /v1/suggestions Endpoint', description: 'Kontextbasierte Vorschlaege mit active_contexts[]', priority: 'high', status: 'done', phase: 'phase-8', effort: 'large' },
|
||||
{ id: 'f71', title: 'GET /v1/sidebar Endpoint', description: 'Dynamisches Sidebar-Model (Companion vs Classic Mode)', priority: 'medium', status: 'done', phase: 'phase-8', effort: 'medium' },
|
||||
{ id: 'f72', title: 'GET /v1/path Endpoint', description: 'Schuljahres-Meilensteine mit Status (DONE, CURRENT, UPCOMING)', priority: 'medium', status: 'done', phase: 'phase-8', effort: 'medium' },
|
||||
{ id: 'f73', title: 'ContextBar Component', description: 'Schuljahr, Woche, Bundesland Anzeige im Header', priority: 'medium', status: 'done', phase: 'phase-8', effort: 'medium' },
|
||||
{ id: 'f74', title: 'Begleiter-Sidebar', description: 'Top 5 relevante Module + Alle Module + Suche', priority: 'medium', status: 'done', phase: 'phase-8', effort: 'large' },
|
||||
{ id: 'f75', title: 'PathPanel Component', description: 'Vertikaler Schuljahres-Pfad mit "Du bist hier" Markierung', priority: 'medium', status: 'done', phase: 'phase-8', effort: 'medium' },
|
||||
{ id: 'f76', title: 'Onboarding-Flow', description: 'Bundesland, Schulart, Schuljahres-Start, erste Klassen', priority: 'high', status: 'backlog', phase: 'phase-8', effort: 'large' },
|
||||
{ id: 'f77', title: 'Complete Onboarding API', description: 'POST /v1/context/complete-onboarding zum Abschliessen', priority: 'high', status: 'done', phase: 'phase-8', effort: 'small' },
|
||||
]
|
||||
|
||||
// ==================== FEEDBACK DATA ====================
|
||||
|
||||
export const initialFeedback: TeacherFeedback[] = [
|
||||
{
|
||||
id: 'fb1',
|
||||
teacher: 'Frau Mueller',
|
||||
date: '2026-01-14',
|
||||
type: 'feature_request',
|
||||
priority: 'high',
|
||||
status: 'implemented',
|
||||
title: 'Individuelle Phasen-Dauern',
|
||||
description: 'Ich moechte die Dauern der einzelnen Phasen selbst festlegen koennen, je nach Unterrichtseinheit.',
|
||||
relatedFeature: 'f16',
|
||||
response: 'In Phase 7 implementiert: Einstellungen-Button oeffnet Modal zur Konfiguration der Phasendauern.',
|
||||
},
|
||||
{
|
||||
id: 'fb2',
|
||||
teacher: 'Herr Schmidt',
|
||||
date: '2026-01-14',
|
||||
type: 'improvement',
|
||||
priority: 'medium',
|
||||
status: 'implemented',
|
||||
title: 'Akustisches Signal bei Phasen-Ende',
|
||||
description: 'Ein kurzer Ton wuerde helfen, das Ende einer Phase nicht zu verpassen.',
|
||||
relatedFeature: 'f33',
|
||||
response: 'Audio Cues wurden in Phase 2b implementiert - sanfte Toene statt harter Alarme. Taste A zum Toggle.',
|
||||
},
|
||||
{
|
||||
id: 'fb3',
|
||||
teacher: 'Frau Wagner',
|
||||
date: '2026-01-15',
|
||||
type: 'praise',
|
||||
priority: 'low',
|
||||
status: 'acknowledged',
|
||||
title: 'Super einfache Bedienung!',
|
||||
description: 'Die Stunden-Steuerung ist sehr intuitiv. Meine erste Stunde damit hat super geklappt.',
|
||||
},
|
||||
{
|
||||
id: 'fb4',
|
||||
teacher: 'Herr Becker',
|
||||
date: '2026-01-15',
|
||||
type: 'bug',
|
||||
priority: 'high',
|
||||
status: 'implemented',
|
||||
title: 'Timer stoppt bei Browser-Tab-Wechsel',
|
||||
description: 'Wenn ich den Browser-Tab wechsle und zurueckkomme, zeigt der Timer manchmal falsche Werte.',
|
||||
relatedFeature: 'f35',
|
||||
response: 'Offline Timer Fallback in Phase 2b implementiert + WebSocket Real-time in Phase 6.',
|
||||
},
|
||||
{
|
||||
id: 'fb5',
|
||||
teacher: 'Frau Klein',
|
||||
date: '2026-01-15',
|
||||
type: 'feature_request',
|
||||
priority: 'critical',
|
||||
status: 'implemented',
|
||||
title: 'Pause-Funktion',
|
||||
description: 'Manchmal muss ich die Stunde kurz unterbrechen (Stoerung, Durchsage). Eine Pause-Funktion waere super.',
|
||||
relatedFeature: 'f27',
|
||||
response: 'Pause Timer API und Quick Actions Bar wurden in Phase 2b implementiert. Tastenkuerzel: Leertaste.',
|
||||
},
|
||||
{
|
||||
id: 'fb6',
|
||||
teacher: 'Herr Hoffmann',
|
||||
date: '2026-01-15',
|
||||
type: 'feature_request',
|
||||
priority: 'high',
|
||||
status: 'implemented',
|
||||
title: 'Visueller Timer statt Zahlen',
|
||||
description: 'Der numerische Countdown ist manchmal stressig. Ein visueller Kreis-Timer waere entspannter.',
|
||||
relatedFeature: 'f21',
|
||||
response: 'Visual Pie Timer mit Farbverlauf (Gruen→Gelb→Rot) wurde in Phase 2b implementiert.',
|
||||
},
|
||||
{
|
||||
id: 'fb7',
|
||||
teacher: 'Frau Richter',
|
||||
date: '2026-01-15',
|
||||
type: 'feature_request',
|
||||
priority: 'high',
|
||||
status: 'implemented',
|
||||
title: 'Tablet-Nutzung im Klassenraum',
|
||||
description: 'Ich laufe waehrend des Unterrichts herum. Die Anzeige muesste auch auf meinem iPad gut funktionieren.',
|
||||
relatedFeature: 'f24',
|
||||
response: 'Tablet-First Responsive Design wurde in Phase 2b implementiert. Touch-Targets min 48x48px.',
|
||||
},
|
||||
{
|
||||
id: 'fb8',
|
||||
teacher: 'Herr Weber',
|
||||
date: '2026-01-15',
|
||||
type: 'improvement',
|
||||
priority: 'medium',
|
||||
status: 'implemented',
|
||||
title: '+5 Minuten Button',
|
||||
description: 'Manchmal brauche ich einfach nur 5 Minuten mehr fuer eine Phase. Ein Schnell-Button waere praktisch.',
|
||||
relatedFeature: 'f28',
|
||||
response: 'In Quick Actions Bar integriert. Tastenkuerzel: E.',
|
||||
},
|
||||
{
|
||||
id: 'fb9',
|
||||
teacher: 'Frau Schneider',
|
||||
date: '2026-01-15',
|
||||
type: 'praise',
|
||||
priority: 'low',
|
||||
status: 'acknowledged',
|
||||
title: 'Phasen-Vorschlaege sind hilfreich',
|
||||
description: 'Die Aktivitaets-Vorschlaege pro Phase geben mir gute Ideen. Weiter so!',
|
||||
},
|
||||
{
|
||||
id: 'fb10',
|
||||
teacher: 'Herr Meier',
|
||||
date: '2026-01-15',
|
||||
type: 'feature_request',
|
||||
priority: 'medium',
|
||||
status: 'implemented',
|
||||
title: 'Stundenvorlage speichern',
|
||||
description: 'Fuer Mathe-Stunden nutze ich immer die gleiche Phasen-Aufteilung. Waere cool, das als Template zu speichern.',
|
||||
relatedFeature: 'f37',
|
||||
response: 'Lesson Templates wurden in Phase 4 implementiert. 5 System-Templates + eigene Vorlagen moeglich.',
|
||||
},
|
||||
]
|
||||
@@ -0,0 +1,119 @@
|
||||
// ==================== SYSTEM INFO CONFIG ====================
|
||||
|
||||
export const companionSystemInfo = {
|
||||
title: 'Companion Module System Info',
|
||||
description: 'Technische Details zur Classroom State Machine',
|
||||
version: '1.1.0',
|
||||
architecture: {
|
||||
layers: [
|
||||
{
|
||||
title: 'Frontend Layer',
|
||||
components: [
|
||||
'companion.py (Lesson-Modus UI)',
|
||||
'Mode Toggle (Begleiter/Stunde/Klassisch)',
|
||||
'Timer Display Component',
|
||||
'Phase Timeline Component',
|
||||
'Suggestions Cards',
|
||||
'Material Design Icons (CDN)',
|
||||
],
|
||||
color: 'bg-blue-50',
|
||||
},
|
||||
{
|
||||
title: 'API Layer',
|
||||
components: [
|
||||
'classroom_api.py (FastAPI Router)',
|
||||
'POST /sessions - Session erstellen',
|
||||
'POST /sessions/{id}/start - Stunde starten',
|
||||
'POST /sessions/{id}/next-phase - Naechste Phase',
|
||||
'POST /sessions/{id}/pause - Timer pausieren',
|
||||
'POST /sessions/{id}/extend - Phase verlaengern',
|
||||
'GET /sessions/{id}/timer - Timer Status',
|
||||
'GET /sessions/{id}/suggestions - Vorschlaege',
|
||||
'GET /history/{teacher_id} - Session History',
|
||||
'GET /health - Health Check mit DB-Status',
|
||||
'GET/PUT /v1/context - Schuljahres-Kontext',
|
||||
'GET/POST/DELETE /v1/events - Events CRUD',
|
||||
'GET/POST/DELETE /v1/routines - Routinen CRUD',
|
||||
'GET /v1/federal-states, /v1/school-types, etc.',
|
||||
],
|
||||
color: 'bg-green-50',
|
||||
},
|
||||
{
|
||||
title: 'Engine Layer',
|
||||
components: [
|
||||
'classroom_engine/ Package',
|
||||
'models.py - LessonPhase, LessonSession',
|
||||
'fsm.py - LessonStateMachine',
|
||||
'timer.py - PhaseTimer',
|
||||
'suggestions.py - SuggestionEngine',
|
||||
'context_models.py - TeacherContextDB, SchoolyearEventDB, RecurringRoutineDB',
|
||||
'antizipation.py - AntizipationsEngine (geplant)',
|
||||
],
|
||||
color: 'bg-amber-50',
|
||||
},
|
||||
{
|
||||
title: 'Storage Layer',
|
||||
components: [
|
||||
'Hybrid Storage (Memory + PostgreSQL)',
|
||||
'SessionRepository (CRUD)',
|
||||
'TeacherSettingsRepository',
|
||||
'TeacherContextRepository (Phase 8)',
|
||||
'SchoolyearEventRepository (Phase 8)',
|
||||
'RecurringRoutineRepository (Phase 8)',
|
||||
'Alembic Migrations (007: Phase 8 Tables)',
|
||||
'Session History API',
|
||||
],
|
||||
color: 'bg-purple-50',
|
||||
},
|
||||
],
|
||||
},
|
||||
features: [
|
||||
{ name: '5-Phasen-Modell', status: 'active' as const, description: 'Einstieg, Erarbeitung, Sicherung, Transfer, Reflexion' },
|
||||
{ name: 'Timer mit Warning', status: 'active' as const, description: '2 Minuten Warnung vor Phasen-Ende' },
|
||||
{ name: 'Overtime Detection', status: 'active' as const, description: 'Anzeige wenn Phase ueberzogen wird' },
|
||||
{ name: 'Phasen-Suggestions', status: 'active' as const, description: '3-6 Aktivitaets-Vorschlaege pro Phase' },
|
||||
{ name: 'Visual Pie Timer', status: 'active' as const, description: 'Kreisfoermiger Countdown mit Farbverlauf' },
|
||||
{ name: 'Quick Actions Bar', status: 'active' as const, description: '+5min, Pause, Skip Buttons' },
|
||||
{ name: 'Tablet-First Design', status: 'active' as const, description: 'Touch-optimiert fuer Tablets' },
|
||||
{ name: 'Phasen-Farbschema', status: 'active' as const, description: 'Blau→Orange→Gruen→Lila→Grau' },
|
||||
{ name: 'Keyboard Shortcuts', status: 'active' as const, description: 'Space=Pause, N=Next, E=Extend, H=Contrast' },
|
||||
{ name: 'Audio Cues', status: 'active' as const, description: 'Sanfte Toene bei Phasenwechsel' },
|
||||
{ name: 'Offline Timer', status: 'active' as const, description: 'Client-seitige Fallback bei Verbindungsverlust' },
|
||||
{ name: 'DB Persistenz', status: 'active' as const, description: 'PostgreSQL Hybrid Storage' },
|
||||
{ name: 'Session History', status: 'active' as const, description: 'GET /history/{teacher_id} API' },
|
||||
{ name: 'Alembic Migrations', status: 'active' as const, description: 'Versionierte DB-Schema-Aenderungen' },
|
||||
{ name: 'Teacher Auth', status: 'active' as const, description: 'Keycloak Integration mit Optional Fallback (Phase 7)' },
|
||||
{ name: 'WebSocket Real-time', status: 'active' as const, description: 'Echtzeit-Updates mit 1-Sekunden-Genauigkeit (Phase 6)' },
|
||||
{ name: 'Schuljahres-Kontext', status: 'active' as const, description: 'Makro-Phasen, Bundesland, Schulart (Phase 8)' },
|
||||
{ name: 'Events & Routinen', status: 'active' as const, description: 'Klausuren, Konferenzen, Elternabende (Phase 8)' },
|
||||
{ name: 'Antizipations-Engine', status: 'active' as const, description: '12 Regeln fuer proaktive Vorschlaege (Phase 8)' },
|
||||
{ name: 'Dynamische Sidebar', status: 'active' as const, description: 'Top 5 relevante Module + Alle Module (Phase 8)' },
|
||||
{ name: 'Schuljahres-Pfad', status: 'active' as const, description: '7 Meilensteine mit Fortschrittsanzeige (Phase 8)' },
|
||||
],
|
||||
roadmap: [
|
||||
{ phase: 'Phase 1: Core Engine', priority: 'high' as const, items: ['FSM', 'Timer', 'Suggestions', 'API'] },
|
||||
{ phase: 'Phase 2: Frontend', priority: 'high' as const, items: ['Lesson-Modus UI', 'Timer Display', 'Timeline'] },
|
||||
{ phase: 'Phase 2b: UX Optimierung', priority: 'high' as const, items: ['Visual Timer', 'Farbschema', 'Tablet-First', 'Quick Actions'] },
|
||||
{ phase: 'Phase 3: Persistenz', priority: 'high' as const, items: ['PostgreSQL', 'Keycloak Auth', 'Session History'] },
|
||||
{ phase: 'Phase 4: Content', priority: 'medium' as const, items: ['Unit-Vorschlaege', 'Templates', 'Hausaufgaben'] },
|
||||
{ phase: 'Phase 5: Analytics', priority: 'medium' as const, items: ['Statistiken (ohne Bewertung)', 'PDF Export'] },
|
||||
{ phase: 'Phase 6: Real-time', priority: 'low' as const, items: ['WebSocket', 'Offline Fallback', 'Multi-Device'] },
|
||||
],
|
||||
technicalDetails: [
|
||||
{ component: 'Backend', technology: 'Python FastAPI', version: '0.123+', description: 'Async REST API' },
|
||||
{ component: 'State Machine', technology: 'Python Enum + Dataclass', description: 'Finite State Machine Pattern' },
|
||||
{ component: 'Timer', technology: 'datetime.utcnow()', description: 'Server-side Time Calculation' },
|
||||
{ component: 'Frontend', technology: 'Vanilla JavaScript ES6+', description: 'In companion.py eingebettet' },
|
||||
{ component: 'Icons', technology: 'Material Design Icons', description: 'Via Google Fonts CDN (Apache-2.0)' },
|
||||
{ component: 'WebSocket', technology: 'FastAPI WebSocket', description: 'Echtzeit-Updates mit 1-Sekunden-Genauigkeit + Polling Fallback' },
|
||||
{ component: 'Database', technology: 'PostgreSQL + SQLAlchemy 2.0', description: 'Hybrid Storage (Memory + DB)' },
|
||||
{ component: 'Migrations', technology: 'Alembic 1.14', description: 'Versionierte Schema-Migrationen' },
|
||||
],
|
||||
privacyNotes: [
|
||||
'Keine Schueler-Daten werden gespeichert',
|
||||
'Session-Daten sind nur waehrend der Stunde verfuegbar',
|
||||
'Lehrer-ID wird fuer Session-Zuordnung verwendet',
|
||||
'Keine Tracking-Cookies oder externe Services',
|
||||
'Analytics ohne bewertende Metriken (keine "70% Redezeit"-Anzeigen)',
|
||||
],
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
// ==================== TYPES ====================
|
||||
|
||||
export interface RoadmapPhase {
|
||||
id: string
|
||||
name: string
|
||||
status: 'completed' | 'in_progress' | 'planned' | 'future'
|
||||
progress: number
|
||||
startDate?: string
|
||||
endDate?: string
|
||||
description: string
|
||||
features: string[]
|
||||
}
|
||||
|
||||
export interface Feature {
|
||||
id: string
|
||||
title: string
|
||||
description: string
|
||||
priority: 'critical' | 'high' | 'medium' | 'low'
|
||||
status: 'done' | 'in_progress' | 'todo' | 'backlog'
|
||||
phase: string
|
||||
effort: 'small' | 'medium' | 'large' | 'epic'
|
||||
assignee?: string
|
||||
dueDate?: string
|
||||
feedback?: string[]
|
||||
}
|
||||
|
||||
export interface TeacherFeedback {
|
||||
id: string
|
||||
teacher: string
|
||||
date: string
|
||||
type: 'bug' | 'feature_request' | 'improvement' | 'praise' | 'question'
|
||||
priority: 'critical' | 'high' | 'medium' | 'low'
|
||||
status: 'new' | 'acknowledged' | 'planned' | 'implemented' | 'declined'
|
||||
title: string
|
||||
description: string
|
||||
relatedFeature?: string
|
||||
response?: string
|
||||
}
|
||||
|
||||
// ==================== STYLE MAPS ====================
|
||||
|
||||
export const statusColors: Record<Feature['status'], string> = {
|
||||
done: 'bg-green-100 text-green-800',
|
||||
in_progress: 'bg-blue-100 text-blue-800',
|
||||
todo: 'bg-amber-100 text-amber-800',
|
||||
backlog: 'bg-slate-100 text-slate-600',
|
||||
}
|
||||
|
||||
export const priorityColors: Record<Feature['priority'], string> = {
|
||||
critical: 'bg-red-500 text-white',
|
||||
high: 'bg-orange-500 text-white',
|
||||
medium: 'bg-yellow-500 text-white',
|
||||
low: 'bg-slate-400 text-white',
|
||||
}
|
||||
|
||||
export const feedbackTypeIcons: Record<TeacherFeedback['type'], string> = {
|
||||
bug: 'M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z',
|
||||
feature_request: 'M12 6v6m0 0v6m0-6h6m-6 0H6',
|
||||
improvement: 'M13 10V3L4 14h7v7l9-11h-7z',
|
||||
praise: 'M4.318 6.318a4.5 4.5 0 000 6.364L12 20.364l7.682-7.682a4.5 4.5 0 00-6.364-6.364L12 7.636l-1.318-1.318a4.5 4.5 0 00-6.364 0z',
|
||||
question: 'M8.228 9c.549-1.165 2.03-2 3.772-2 2.21 0 4 1.343 4 3 0 1.4-1.278 2.575-3.006 2.907-.542.104-.994.54-.994 1.093m0 3h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z',
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect } from 'react'
|
||||
import type { Feature, TeacherFeedback } from './types'
|
||||
import { initialFeatures, initialFeedback, roadmapPhases } from './data'
|
||||
|
||||
// Data version - increment when adding new features/feedback to force refresh
|
||||
const DATA_VERSION = '8.2.0' // Phase 8e: Frontend UI-Komponenten (ContextBar, Sidebar, PathPanel)
|
||||
|
||||
export function useCompanionDev() {
|
||||
const [features, setFeatures] = useState<Feature[]>(initialFeatures)
|
||||
const [feedback, setFeedback] = useState<TeacherFeedback[]>(initialFeedback)
|
||||
const [activeTab, setActiveTab] = useState<'roadmap' | 'features' | 'feedback' | 'backlog'>('roadmap')
|
||||
const [selectedPhase, setSelectedPhase] = useState<string | null>(null)
|
||||
const [feedbackFilter, setFeedbackFilter] = useState<string>('all')
|
||||
|
||||
// Load from localStorage with version check
|
||||
useEffect(() => {
|
||||
const savedVersion = localStorage.getItem('companion-dev-version')
|
||||
const savedFeatures = localStorage.getItem('companion-dev-features')
|
||||
const savedFeedback = localStorage.getItem('companion-dev-feedback')
|
||||
|
||||
// If version mismatch or no version, use initial data and save new version
|
||||
if (savedVersion !== DATA_VERSION) {
|
||||
console.log(`Companion Dev: Data version updated from ${savedVersion} to ${DATA_VERSION}`)
|
||||
localStorage.setItem('companion-dev-version', DATA_VERSION)
|
||||
localStorage.setItem('companion-dev-features', JSON.stringify(initialFeatures))
|
||||
localStorage.setItem('companion-dev-feedback', JSON.stringify(initialFeedback))
|
||||
// State already initialized with initialFeatures/initialFeedback, no need to setFeatures
|
||||
return
|
||||
}
|
||||
|
||||
// Load saved data if version matches
|
||||
if (savedFeatures) setFeatures(JSON.parse(savedFeatures))
|
||||
if (savedFeedback) setFeedback(JSON.parse(savedFeedback))
|
||||
}, [])
|
||||
|
||||
// Save to localStorage
|
||||
useEffect(() => {
|
||||
localStorage.setItem('companion-dev-features', JSON.stringify(features))
|
||||
}, [features])
|
||||
|
||||
useEffect(() => {
|
||||
localStorage.setItem('companion-dev-feedback', JSON.stringify(feedback))
|
||||
}, [feedback])
|
||||
|
||||
const getPhaseStats = () => {
|
||||
const total = roadmapPhases.length
|
||||
const completed = roadmapPhases.filter(p => p.status === 'completed').length
|
||||
const inProgress = roadmapPhases.filter(p => p.status === 'in_progress').length
|
||||
return { total, completed, inProgress }
|
||||
}
|
||||
|
||||
const getFeatureStats = () => {
|
||||
const total = features.length
|
||||
const done = features.filter(f => f.status === 'done').length
|
||||
const inProgress = features.filter(f => f.status === 'in_progress').length
|
||||
return { total, done, inProgress, percentage: Math.round((done / total) * 100) }
|
||||
}
|
||||
|
||||
const getFeedbackStats = () => {
|
||||
const total = feedback.length
|
||||
const newCount = feedback.filter(f => f.status === 'new').length
|
||||
const bugs = feedback.filter(f => f.type === 'bug').length
|
||||
const requests = feedback.filter(f => f.type === 'feature_request').length
|
||||
return { total, newCount, bugs, requests }
|
||||
}
|
||||
|
||||
const updateFeatureStatus = (id: string, status: Feature['status']) => {
|
||||
setFeatures(features.map(f => f.id === id ? { ...f, status } : f))
|
||||
}
|
||||
|
||||
const updateFeedbackStatus = (id: string, status: TeacherFeedback['status']) => {
|
||||
setFeedback(feedback.map(f => f.id === id ? { ...f, status } : f))
|
||||
}
|
||||
|
||||
const filteredFeedback = feedbackFilter === 'all'
|
||||
? feedback
|
||||
: feedback.filter(f => f.type === feedbackFilter || f.status === feedbackFilter)
|
||||
|
||||
return {
|
||||
features,
|
||||
feedback,
|
||||
activeTab,
|
||||
setActiveTab,
|
||||
selectedPhase,
|
||||
setSelectedPhase,
|
||||
feedbackFilter,
|
||||
setFeedbackFilter,
|
||||
phaseStats: getPhaseStats(),
|
||||
featureStats: getFeatureStats(),
|
||||
feedbackStats: getFeedbackStats(),
|
||||
updateFeatureStatus,
|
||||
updateFeedbackStatus,
|
||||
filteredFeedback,
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,143 @@
|
||||
'use client'
|
||||
|
||||
const ARCHITECTURE_DIAGRAM = `┌─────────────────────────────────────────────────────────────────────────────┐
|
||||
│ MAGIC HELP ARCHITEKTUR │
|
||||
├─────────────────────────────────────────────────────────────────────────────┤
|
||||
│ │
|
||||
│ ┌───────────────┐ ┌──────────────────┐ ┌───────────────┐ │
|
||||
│ │ FRONTEND │ │ BACKEND │ │ STORAGE │ │
|
||||
│ │ (Next.js) │ │ (FastAPI) │ │ │ │
|
||||
│ │ │ │ │ │ │ │
|
||||
│ │ ┌─────────┐ │ REST │ ┌────────────┐ │ │ ┌─────────┐ │ │
|
||||
│ │ │ Admin │──┼─────────┼──│ TrOCR │ │ │ │ Models │ │ │
|
||||
│ │ │ Panel │ │ │ │ Service │──┼─────────┼──│ (ONNX) │ │ │
|
||||
│ │ └─────────┘ │ │ └────────────┘ │ │ └─────────┘ │ │
|
||||
│ │ │ │ │ │ │ │ │
|
||||
│ │ ┌─────────┐ │ WebSocket│ ┌────────────┐ │ │ ┌─────────┐ │ │
|
||||
│ │ │ Lehrer │──┼─────────┼──│ Klausur │ │ │ │ LoRA │ │ │
|
||||
│ │ │ Portal │ │ │ │ Processor │──┼─────────┼──│ Adapter │ │ │
|
||||
│ │ └─────────┘ │ │ └────────────┘ │ │ └─────────┘ │ │
|
||||
│ │ │ │ │ │ │ │ │
|
||||
│ └───────────────┘ │ ┌────────────┐ │ │ ┌─────────┐ │ │
|
||||
│ │ │ Pseudo- │ │ │ │Training │ │ │
|
||||
│ │ │ nymizer │──┼─────────┼──│ Data │ │ │
|
||||
│ │ └────────────┘ │ │ └─────────┘ │ │
|
||||
│ │ │ │ │ │
|
||||
│ └──────────────────┘ └───────────────┘ │
|
||||
│ │ │
|
||||
│ │ (nur pseudonymisiert) │
|
||||
│ ▼ │
|
||||
│ ┌──────────────────┐ │
|
||||
│ │ CLOUD LLM │ │
|
||||
│ │ (SysEleven) │ │
|
||||
│ │ Namespace- │ │
|
||||
│ │ Isolation │ │
|
||||
│ └──────────────────┘ │
|
||||
│ │
|
||||
└─────────────────────────────────────────────────────────────────────────────┘`
|
||||
|
||||
const COMPONENTS = [
|
||||
{
|
||||
icon: '🔍',
|
||||
title: 'TrOCR Service',
|
||||
details: [
|
||||
{ label: 'Modell', value: 'microsoft/trocr-base-handwritten' },
|
||||
{ label: 'Größe', value: '~350 MB' },
|
||||
{ label: 'Lizenz', value: 'MIT' },
|
||||
{ label: 'Framework', value: 'PyTorch / Transformers' },
|
||||
],
|
||||
description: 'Das TrOCR-Modell von Microsoft ist speziell für Handschrifterkennung trainiert. Es verwendet eine Vision-Transformer (ViT) Architektur für Bildverarbeitung und einen Text-Decoder für die Textgenerierung.',
|
||||
},
|
||||
{
|
||||
icon: '🎯',
|
||||
title: 'LoRA Fine-Tuning',
|
||||
details: [
|
||||
{ label: 'Methode', value: 'Low-Rank Adaptation' },
|
||||
{ label: 'Adapter-Größe', value: '~10 MB' },
|
||||
{ label: 'Trainingszeit', value: '5-15 Min (CPU)' },
|
||||
{ label: 'Min. Beispiele', value: '10' },
|
||||
],
|
||||
description: 'LoRA fügt kleine, trainierbare Matrizen zu bestimmten Schichten hinzu, ohne das Basismodell zu verändern. Dies ermöglicht effizientes Fine-Tuning mit minimaler Speichernutzung.',
|
||||
},
|
||||
{
|
||||
icon: '🔒',
|
||||
title: 'Pseudonymisierung',
|
||||
details: [
|
||||
{ label: 'Methode', value: 'QR-Code Tokens' },
|
||||
{ label: 'Token-Format', value: 'UUID v4' },
|
||||
{ label: 'Mapping', value: 'Lokal beim Lehrer' },
|
||||
{ label: 'Cloud-Daten', value: 'Nur Tokens + Text' },
|
||||
],
|
||||
description: 'Schülernamen werden durch anonyme Tokens ersetzt, bevor Daten die lokale Umgebung verlassen. Das Mapping wird ausschließlich lokal gespeichert.',
|
||||
},
|
||||
{
|
||||
icon: '☁️',
|
||||
title: 'Cloud LLM',
|
||||
details: [
|
||||
{ label: 'Provider', value: 'SysEleven (DE)' },
|
||||
{ label: 'Standort', value: 'Deutschland' },
|
||||
{ label: 'Isolation', value: 'Namespace pro Schule' },
|
||||
{ label: 'Datenverarbeitung', value: 'Nur pseudonymisiert' },
|
||||
],
|
||||
description: 'Die KI-Korrektur erfolgt auf deutschen Servern mit strikter Mandantentrennung. Es werden keine Klarnamen oder identifizierenden Informationen übertragen.',
|
||||
},
|
||||
]
|
||||
|
||||
const DATA_FLOW_STEPS = [
|
||||
{ color: 'blue', num: 1, title: 'Lokale Header-Extraktion', desc: 'TrOCR erkennt Schülernamen, Klasse und Fach direkt im Browser/PWA (offline-fähig)' },
|
||||
{ color: 'purple', num: 2, title: 'Pseudonymisierung', desc: 'Namen werden durch QR-Code Tokens ersetzt, Mapping bleibt lokal' },
|
||||
{ color: 'green', num: 3, title: 'Cloud-Korrektur', desc: 'Nur pseudonymisierte Dokument-Tokens werden an die KI gesendet' },
|
||||
{ color: 'yellow', num: 4, title: 'Re-Identifikation', desc: 'Ergebnisse werden lokal mit dem Mapping wieder den echten Namen zugeordnet' },
|
||||
]
|
||||
|
||||
export default function ArchitectureTab() {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Architecture Diagram */}
|
||||
<div className="bg-gray-800/50 border border-gray-700 rounded-xl p-6">
|
||||
<h2 className="text-lg font-semibold text-white mb-6">Systemarchitektur</h2>
|
||||
<div className="bg-gray-900 rounded-lg p-6 font-mono text-xs overflow-x-auto">
|
||||
<pre className="text-gray-300">{ARCHITECTURE_DIAGRAM}</pre>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Components */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
{COMPONENTS.map(comp => (
|
||||
<div key={comp.title} className="bg-gray-800/50 border border-gray-700 rounded-xl p-6">
|
||||
<h3 className="text-lg font-semibold text-white mb-4 flex items-center gap-2">
|
||||
<span>{comp.icon}</span> {comp.title}
|
||||
</h3>
|
||||
<div className="space-y-3 text-sm">
|
||||
{comp.details.map(d => (
|
||||
<div key={d.label} className="flex justify-between">
|
||||
<span className="text-gray-400">{d.label}</span>
|
||||
<span className="text-white">{d.value}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<p className="text-gray-400 text-sm mt-4">{comp.description}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Data Flow */}
|
||||
<div className="bg-gray-800/50 border border-gray-700 rounded-xl p-6">
|
||||
<h2 className="text-lg font-semibold text-white mb-4">Datenfluss</h2>
|
||||
<div className="space-y-4">
|
||||
{DATA_FLOW_STEPS.map(step => (
|
||||
<div key={step.num} className="flex items-start gap-4 bg-gray-900/50 rounded-lg p-4">
|
||||
<div className={`w-8 h-8 rounded-full bg-${step.color}-500/20 flex items-center justify-center text-${step.color}-400 font-bold`}>
|
||||
{step.num}
|
||||
</div>
|
||||
<div>
|
||||
<div className="font-medium text-white">{step.title}</div>
|
||||
<div className="text-sm text-gray-400">{step.desc}</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
'use client'
|
||||
|
||||
import type { OCRResult } from './types'
|
||||
|
||||
interface OcrTestTabProps {
|
||||
ocrResult: OCRResult | null
|
||||
ocrLoading: boolean
|
||||
handleFileUpload: (file: File) => void
|
||||
}
|
||||
|
||||
export default function OcrTestTab({ ocrResult, ocrLoading, handleFileUpload }: OcrTestTabProps) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* OCR Test */}
|
||||
<div className="bg-gray-800/50 border border-gray-700 rounded-xl p-6">
|
||||
<h2 className="text-lg font-semibold text-white mb-4">OCR Test</h2>
|
||||
<p className="text-sm text-gray-400 mb-4">
|
||||
Teste die Handschrifterkennung mit einem eigenen Bild. Das Ergebnis zeigt
|
||||
den erkannten Text, Konfidenz und Verarbeitungszeit.
|
||||
</p>
|
||||
|
||||
<div
|
||||
className="border-2 border-dashed border-gray-600 rounded-lg p-8 text-center cursor-pointer hover:border-blue-500 transition-colors"
|
||||
onClick={() => document.getElementById('ocr-file-input')?.click()}
|
||||
onDragOver={(e) => { e.preventDefault(); e.currentTarget.classList.add('border-blue-500') }}
|
||||
onDragLeave={(e) => { e.currentTarget.classList.remove('border-blue-500') }}
|
||||
onDrop={(e) => {
|
||||
e.preventDefault()
|
||||
e.currentTarget.classList.remove('border-blue-500')
|
||||
const file = e.dataTransfer.files[0]
|
||||
if (file?.type.startsWith('image/')) handleFileUpload(file)
|
||||
}}
|
||||
>
|
||||
<div className="text-4xl mb-2">📄</div>
|
||||
<div className="text-gray-300">Bild hierher ziehen oder klicken zum Hochladen</div>
|
||||
<div className="text-xs text-gray-500 mt-1">PNG, JPG - Handgeschriebener Text</div>
|
||||
</div>
|
||||
<input
|
||||
type="file"
|
||||
id="ocr-file-input"
|
||||
accept="image/*"
|
||||
className="hidden"
|
||||
onChange={(e) => {
|
||||
const file = e.target.files?.[0]
|
||||
if (file) handleFileUpload(file)
|
||||
}}
|
||||
/>
|
||||
|
||||
{ocrLoading && (
|
||||
<div className="mt-4 flex items-center gap-2 text-gray-400">
|
||||
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white"></div>
|
||||
Analysiere Bild...
|
||||
</div>
|
||||
)}
|
||||
|
||||
{ocrResult && (
|
||||
<div className="mt-4 bg-gray-900/50 rounded-lg p-4">
|
||||
<h3 className="text-sm font-medium text-gray-300 mb-2">Erkannter Text:</h3>
|
||||
<pre className="bg-gray-950 p-3 rounded text-sm text-white whitespace-pre-wrap max-h-48 overflow-y-auto">
|
||||
{ocrResult.text || '(Kein Text erkannt)'}
|
||||
</pre>
|
||||
<div className="mt-3 grid grid-cols-2 md:grid-cols-4 gap-4 text-sm">
|
||||
<div className="bg-gray-800 rounded p-2">
|
||||
<div className="text-gray-400 text-xs">Konfidenz</div>
|
||||
<div className="text-white font-medium">{(ocrResult.confidence * 100).toFixed(1)}%</div>
|
||||
</div>
|
||||
<div className="bg-gray-800 rounded p-2">
|
||||
<div className="text-gray-400 text-xs">Verarbeitungszeit</div>
|
||||
<div className="text-white font-medium">{ocrResult.processing_time_ms}ms</div>
|
||||
</div>
|
||||
<div className="bg-gray-800 rounded p-2">
|
||||
<div className="text-gray-400 text-xs">Modell</div>
|
||||
<div className="text-white font-medium">{ocrResult.model || 'TrOCR'}</div>
|
||||
</div>
|
||||
<div className="bg-gray-800 rounded p-2">
|
||||
<div className="text-gray-400 text-xs">LoRA Adapter</div>
|
||||
<div className="text-white font-medium">{ocrResult.has_lora_adapter ? 'Ja' : 'Nein'}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Confidence Interpretation */}
|
||||
<div className="bg-gray-800/50 border border-gray-700 rounded-xl p-6">
|
||||
<h2 className="text-lg font-semibold text-white mb-4">Konfidenz-Interpretation</h2>
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<div className="bg-green-900/20 border border-green-800 rounded-lg p-4">
|
||||
<div className="text-green-400 font-medium">90-100%</div>
|
||||
<div className="text-sm text-gray-300 mt-1">Sehr hohe Sicherheit - Text kann direkt übernommen werden</div>
|
||||
</div>
|
||||
<div className="bg-yellow-900/20 border border-yellow-800 rounded-lg p-4">
|
||||
<div className="text-yellow-400 font-medium">70-90%</div>
|
||||
<div className="text-sm text-gray-300 mt-1">Gute Sicherheit - manuelle Überprüfung empfohlen</div>
|
||||
</div>
|
||||
<div className="bg-red-900/20 border border-red-800 rounded-lg p-4">
|
||||
<div className="text-red-400 font-medium">< 70%</div>
|
||||
<div className="text-sm text-gray-300 mt-1">Niedrige Sicherheit - manuelle Eingabe erforderlich</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
'use client'
|
||||
|
||||
import type { TrOCRStatus } from './types'
|
||||
|
||||
interface OverviewTabProps {
|
||||
status: TrOCRStatus | null
|
||||
loading: boolean
|
||||
fetchStatus: () => void
|
||||
}
|
||||
|
||||
export default function OverviewTab({ status, loading, fetchStatus }: OverviewTabProps) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Status Card */}
|
||||
<div className="bg-gray-800/50 border border-gray-700 rounded-xl p-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h2 className="text-lg font-semibold text-white">Systemstatus</h2>
|
||||
<button
|
||||
onClick={fetchStatus}
|
||||
className="px-3 py-1 bg-blue-600 hover:bg-blue-700 rounded text-sm transition-colors"
|
||||
>
|
||||
Aktualisieren
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div className="text-gray-400">Lade Status...</div>
|
||||
) : status?.status === 'available' ? (
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
<div className="bg-gray-900/50 rounded-lg p-4">
|
||||
<div className="text-2xl font-bold text-white">{status.model_name || 'trocr-base'}</div>
|
||||
<div className="text-xs text-gray-400">Modell</div>
|
||||
</div>
|
||||
<div className="bg-gray-900/50 rounded-lg p-4">
|
||||
<div className="text-2xl font-bold text-white">{status.device || 'CPU'}</div>
|
||||
<div className="text-xs text-gray-400">Gerät</div>
|
||||
</div>
|
||||
<div className="bg-gray-900/50 rounded-lg p-4">
|
||||
<div className="text-2xl font-bold text-white">{status.training_examples_count || 0}</div>
|
||||
<div className="text-xs text-gray-400">Trainingsbeispiele</div>
|
||||
</div>
|
||||
<div className="bg-gray-900/50 rounded-lg p-4">
|
||||
<div className="text-2xl font-bold text-white">{status.has_lora_adapter ? 'Aktiv' : 'Keiner'}</div>
|
||||
<div className="text-xs text-gray-400">LoRA Adapter</div>
|
||||
</div>
|
||||
</div>
|
||||
) : status?.status === 'not_installed' ? (
|
||||
<div className="text-gray-400">
|
||||
<p className="mb-2">TrOCR ist nicht installiert. Führe aus:</p>
|
||||
<code className="bg-gray-900 px-3 py-2 rounded text-sm block">{status.install_command}</code>
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-red-400">{status?.error || 'Unbekannter Fehler'}</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Quick Overview Cards */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||
<div className="bg-gradient-to-br from-purple-900/30 to-purple-800/20 border border-purple-700/50 rounded-xl p-6">
|
||||
<div className="text-3xl mb-2">🎯</div>
|
||||
<h3 className="text-lg font-semibold text-white mb-2">Handschrifterkennung</h3>
|
||||
<p className="text-sm text-gray-300">
|
||||
TrOCR erkennt automatisch handgeschriebenen Text in Klausuren.
|
||||
Das Modell wurde speziell für deutsche Handschriften optimiert.
|
||||
</p>
|
||||
</div>
|
||||
<div className="bg-gradient-to-br from-green-900/30 to-green-800/20 border border-green-700/50 rounded-xl p-6">
|
||||
<div className="text-3xl mb-2">🔒</div>
|
||||
<h3 className="text-lg font-semibold text-white mb-2">Privacy by Design</h3>
|
||||
<p className="text-sm text-gray-300">
|
||||
Alle Daten werden lokal verarbeitet. Schülernamen werden durch
|
||||
QR-Codes pseudonymisiert - DSGVO-konform.
|
||||
</p>
|
||||
</div>
|
||||
<div className="bg-gradient-to-br from-blue-900/30 to-blue-800/20 border border-blue-700/50 rounded-xl p-6">
|
||||
<div className="text-3xl mb-2">📈</div>
|
||||
<h3 className="text-lg font-semibold text-white mb-2">Kontinuierliches Lernen</h3>
|
||||
<p className="text-sm text-gray-300">
|
||||
Mit LoRA Fine-Tuning passt sich das Modell an individuelle
|
||||
Handschriften an - ohne das Basismodell zu verändern.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Workflow Overview */}
|
||||
<div className="bg-gray-800/50 border border-gray-700 rounded-xl p-6">
|
||||
<h2 className="text-lg font-semibold text-white mb-4">Magic Onboarding Workflow</h2>
|
||||
<div className="flex flex-wrap items-center gap-4 text-sm">
|
||||
{[
|
||||
{ icon: '📄', title: '1. Upload', desc: '25 Klausuren hochladen' },
|
||||
{ icon: '🔍', title: '2. Analyse', desc: 'Lokale OCR in 5-10 Sek' },
|
||||
{ icon: '✅', title: '3. Bestätigung', desc: 'Klasse, Schüler, Fach' },
|
||||
{ icon: '🤖', title: '4. KI-Korrektur', desc: 'Cloud mit Pseudonymisierung' },
|
||||
{ icon: '📊', title: '5. Integration', desc: 'Notenbuch, Zeugnisse' },
|
||||
].map((step, i, arr) => (
|
||||
<div key={step.title} className="contents">
|
||||
<div className="flex items-center gap-2 bg-gray-900/50 rounded-lg px-4 py-3">
|
||||
<span className="text-2xl">{step.icon}</span>
|
||||
<div>
|
||||
<div className="font-medium text-white">{step.title}</div>
|
||||
<div className="text-gray-400">{step.desc}</div>
|
||||
</div>
|
||||
</div>
|
||||
{i < arr.length - 1 && <div className="text-gray-600">→</div>}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,193 @@
|
||||
'use client'
|
||||
|
||||
import type { MagicSettings } from './types'
|
||||
import { DEFAULT_SETTINGS } from './types'
|
||||
|
||||
interface SettingsTabProps {
|
||||
settings: MagicSettings
|
||||
setSettings: (settings: MagicSettings) => void
|
||||
settingsSaved: boolean
|
||||
saveSettings: () => void
|
||||
}
|
||||
|
||||
export default function SettingsTab({ settings, setSettings, settingsSaved, saveSettings }: SettingsTabProps) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* OCR Settings */}
|
||||
<div className="bg-gray-800/50 border border-gray-700 rounded-xl p-6">
|
||||
<h2 className="text-lg font-semibold text-white mb-4">OCR Einstellungen</h2>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div>
|
||||
<label className="flex items-center gap-3 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={settings.autoDetectLines}
|
||||
onChange={(e) => setSettings({ ...settings, autoDetectLines: e.target.checked })}
|
||||
className="w-5 h-5 rounded bg-gray-900 border-gray-700"
|
||||
/>
|
||||
<div>
|
||||
<div className="text-white font-medium">Automatische Zeilenerkennung</div>
|
||||
<div className="text-sm text-gray-400">Erkennt und verarbeitet einzelne Zeilen separat</div>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm text-gray-300 mb-2">Konfidenz-Schwellwert</label>
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
max="1"
|
||||
step="0.1"
|
||||
value={settings.confidenceThreshold}
|
||||
onChange={(e) => setSettings({ ...settings, confidenceThreshold: parseFloat(e.target.value) })}
|
||||
className="w-full"
|
||||
/>
|
||||
<div className="flex justify-between text-xs text-gray-500 mt-1">
|
||||
<span>0%</span>
|
||||
<span className="text-white">{(settings.confidenceThreshold * 100).toFixed(0)}%</span>
|
||||
<span>100%</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm text-gray-300 mb-2">Max. Bildgröße (px)</label>
|
||||
<input
|
||||
type="number"
|
||||
value={settings.maxImageSize}
|
||||
onChange={(e) => setSettings({ ...settings, maxImageSize: parseInt(e.target.value) })}
|
||||
className="w-full bg-gray-900 border border-gray-700 rounded-lg px-3 py-2 text-white"
|
||||
/>
|
||||
<div className="text-xs text-gray-500 mt-1">Größere Bilder werden skaliert</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="flex items-center gap-3 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={settings.enableCache}
|
||||
onChange={(e) => setSettings({ ...settings, enableCache: e.target.checked })}
|
||||
className="w-5 h-5 rounded bg-gray-900 border-gray-700"
|
||||
/>
|
||||
<div>
|
||||
<div className="text-white font-medium">Ergebnis-Cache aktivieren</div>
|
||||
<div className="text-sm text-gray-400">Speichert OCR-Ergebnisse für identische Bilder</div>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Training Settings */}
|
||||
<div className="bg-gray-800/50 border border-gray-700 rounded-xl p-6">
|
||||
<h2 className="text-lg font-semibold text-white mb-4">Training Einstellungen</h2>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div>
|
||||
<label className="block text-sm text-gray-300 mb-2">LoRA Rank</label>
|
||||
<select
|
||||
value={settings.loraRank}
|
||||
onChange={(e) => setSettings({ ...settings, loraRank: parseInt(e.target.value) })}
|
||||
className="w-full bg-gray-900 border border-gray-700 rounded-lg px-3 py-2 text-white"
|
||||
>
|
||||
<option value="4">4 (Schnell, weniger Kapazität)</option>
|
||||
<option value="8">8 (Ausgewogen)</option>
|
||||
<option value="16">16 (Mehr Kapazität)</option>
|
||||
<option value="32">32 (Maximum)</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm text-gray-300 mb-2">LoRA Alpha</label>
|
||||
<input
|
||||
type="number"
|
||||
value={settings.loraAlpha}
|
||||
onChange={(e) => setSettings({ ...settings, loraAlpha: parseInt(e.target.value) })}
|
||||
className="w-full bg-gray-900 border border-gray-700 rounded-lg px-3 py-2 text-white"
|
||||
/>
|
||||
<div className="text-xs text-gray-500 mt-1">Empfohlen: 4 × LoRA Rank</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm text-gray-300 mb-2">Epochen</label>
|
||||
<input
|
||||
type="number"
|
||||
min="1"
|
||||
max="10"
|
||||
value={settings.epochs}
|
||||
onChange={(e) => setSettings({ ...settings, epochs: parseInt(e.target.value) })}
|
||||
className="w-full bg-gray-900 border border-gray-700 rounded-lg px-3 py-2 text-white"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm text-gray-300 mb-2">Batch Size</label>
|
||||
<select
|
||||
value={settings.batchSize}
|
||||
onChange={(e) => setSettings({ ...settings, batchSize: parseInt(e.target.value) })}
|
||||
className="w-full bg-gray-900 border border-gray-700 rounded-lg px-3 py-2 text-white"
|
||||
>
|
||||
<option value="1">1 (Wenig RAM)</option>
|
||||
<option value="2">2</option>
|
||||
<option value="4">4 (Standard)</option>
|
||||
<option value="8">8 (Viel RAM)</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm text-gray-300 mb-2">Learning Rate</label>
|
||||
<select
|
||||
value={settings.learningRate}
|
||||
onChange={(e) => setSettings({ ...settings, learningRate: parseFloat(e.target.value) })}
|
||||
className="w-full bg-gray-900 border border-gray-700 rounded-lg px-3 py-2 text-white"
|
||||
>
|
||||
<option value="0.0001">0.0001 (Schnell)</option>
|
||||
<option value="0.00005">0.00005 (Standard)</option>
|
||||
<option value="0.00001">0.00001 (Konservativ)</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Save Button */}
|
||||
<div className="flex justify-end gap-4">
|
||||
<button
|
||||
onClick={() => setSettings(DEFAULT_SETTINGS)}
|
||||
className="px-6 py-2 bg-gray-700 hover:bg-gray-600 rounded-lg text-sm font-medium transition-colors"
|
||||
>
|
||||
Zurücksetzen
|
||||
</button>
|
||||
<button
|
||||
onClick={saveSettings}
|
||||
className="px-6 py-2 bg-blue-600 hover:bg-blue-700 rounded-lg text-sm font-medium transition-colors"
|
||||
>
|
||||
{settingsSaved ? '✓ Gespeichert!' : 'Einstellungen speichern'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Technical Info */}
|
||||
<div className="bg-gray-800/50 border border-gray-700 rounded-xl p-6">
|
||||
<h2 className="text-lg font-semibold text-white mb-4">Technische Informationen</h2>
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 text-sm">
|
||||
<div>
|
||||
<span className="text-gray-400">API Endpoint:</span>
|
||||
<code className="text-white ml-2 bg-gray-900 px-2 py-1 rounded text-xs">/api/klausur/trocr</code>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-gray-400">Model Path:</span>
|
||||
<code className="text-white ml-2 bg-gray-900 px-2 py-1 rounded text-xs">~/.cache/huggingface</code>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-gray-400">LoRA Path:</span>
|
||||
<code className="text-white ml-2 bg-gray-900 px-2 py-1 rounded text-xs">./models/lora</code>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-gray-400">Training Data:</span>
|
||||
<code className="text-white ml-2 bg-gray-900 px-2 py-1 rounded text-xs">./data/training</code>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,182 @@
|
||||
'use client'
|
||||
|
||||
import type { TrOCRStatus, TrainingExample, MagicSettings } from './types'
|
||||
|
||||
interface TrainingTabProps {
|
||||
status: TrOCRStatus | null
|
||||
examples: TrainingExample[]
|
||||
trainingImage: File | null
|
||||
setTrainingImage: (file: File | null) => void
|
||||
trainingText: string
|
||||
setTrainingText: (text: string) => void
|
||||
fineTuning: boolean
|
||||
settings: MagicSettings
|
||||
handleAddTrainingExample: () => void
|
||||
handleFineTune: () => void
|
||||
}
|
||||
|
||||
export default function TrainingTab({
|
||||
status,
|
||||
examples,
|
||||
trainingImage,
|
||||
setTrainingImage,
|
||||
trainingText,
|
||||
setTrainingText,
|
||||
fineTuning,
|
||||
settings,
|
||||
handleAddTrainingExample,
|
||||
handleFineTune,
|
||||
}: TrainingTabProps) {
|
||||
const examplesCount = status?.training_examples_count || 0
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Training Overview */}
|
||||
<div className="bg-gray-800/50 border border-gray-700 rounded-xl p-6">
|
||||
<h2 className="text-lg font-semibold text-white mb-4">Training mit LoRA</h2>
|
||||
<p className="text-sm text-gray-400 mb-4">
|
||||
LoRA (Low-Rank Adaptation) ermöglicht effizientes Fine-Tuning ohne das Basismodell zu verändern.
|
||||
Das Training erfolgt lokal auf Ihrem System.
|
||||
</p>
|
||||
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 mb-6">
|
||||
<div className="bg-gray-900/50 rounded-lg p-4 text-center">
|
||||
<div className="text-3xl font-bold text-white">{examplesCount}</div>
|
||||
<div className="text-xs text-gray-400">Trainingsbeispiele</div>
|
||||
</div>
|
||||
<div className="bg-gray-900/50 rounded-lg p-4 text-center">
|
||||
<div className="text-3xl font-bold text-white">10</div>
|
||||
<div className="text-xs text-gray-400">Minimum benötigt</div>
|
||||
</div>
|
||||
<div className="bg-gray-900/50 rounded-lg p-4 text-center">
|
||||
<div className="text-3xl font-bold text-white">{settings.loraRank}</div>
|
||||
<div className="text-xs text-gray-400">LoRA Rank</div>
|
||||
</div>
|
||||
<div className="bg-gray-900/50 rounded-lg p-4 text-center">
|
||||
<div className="text-3xl font-bold text-white">{status?.has_lora_adapter ? '✓' : '✗'}</div>
|
||||
<div className="text-xs text-gray-400">Adapter aktiv</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Progress Bar */}
|
||||
<div className="mb-6">
|
||||
<div className="flex justify-between text-sm mb-1">
|
||||
<span className="text-gray-400">Fortschritt zum Fine-Tuning</span>
|
||||
<span className="text-gray-400">{Math.min(100, (examplesCount / 10) * 100).toFixed(0)}%</span>
|
||||
</div>
|
||||
<div className="h-2 bg-gray-700 rounded-full overflow-hidden">
|
||||
<div
|
||||
className="h-full bg-gradient-to-r from-purple-500 to-blue-500 transition-all duration-500"
|
||||
style={{ width: `${Math.min(100, (examplesCount / 10) * 100)}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
{/* Add Training Example */}
|
||||
<div className="bg-gray-800/50 border border-gray-700 rounded-xl p-6">
|
||||
<h2 className="text-lg font-semibold text-white mb-4">Trainingsbeispiel hinzufügen</h2>
|
||||
<p className="text-sm text-gray-400 mb-4">
|
||||
Lade ein Bild mit handgeschriebenem Text hoch und gib die korrekte Transkription ein.
|
||||
</p>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm text-gray-300 mb-1">Bild</label>
|
||||
<input
|
||||
type="file"
|
||||
accept="image/*"
|
||||
className="w-full bg-gray-900 border border-gray-700 rounded-lg px-3 py-2 text-sm"
|
||||
onChange={(e) => setTrainingImage(e.target.files?.[0] || null)}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm text-gray-300 mb-1">Korrekter Text (Ground Truth)</label>
|
||||
<textarea
|
||||
className="w-full bg-gray-900 border border-gray-700 rounded-lg px-3 py-2 text-sm text-white resize-none"
|
||||
rows={3}
|
||||
placeholder="Gib hier den korrekten Text ein..."
|
||||
value={trainingText}
|
||||
onChange={(e) => setTrainingText(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleAddTrainingExample}
|
||||
className="w-full px-4 py-2 bg-purple-600 hover:bg-purple-700 rounded-lg text-sm font-medium transition-colors"
|
||||
>
|
||||
+ Trainingsbeispiel hinzufügen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Fine-Tuning */}
|
||||
<div className="bg-gray-800/50 border border-gray-700 rounded-xl p-6">
|
||||
<h2 className="text-lg font-semibold text-white mb-4">Fine-Tuning starten</h2>
|
||||
<p className="text-sm text-gray-400 mb-4">
|
||||
Trainiere das Modell mit den gesammelten Beispielen. Der Prozess dauert
|
||||
je nach Anzahl der Beispiele einige Minuten.
|
||||
</p>
|
||||
|
||||
<div className="bg-gray-900/50 rounded-lg p-4 mb-4">
|
||||
<div className="grid grid-cols-2 gap-4 text-sm">
|
||||
<div>
|
||||
<span className="text-gray-400">Epochen:</span>
|
||||
<span className="text-white ml-2">{settings.epochs}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-gray-400">Learning Rate:</span>
|
||||
<span className="text-white ml-2">{settings.learningRate}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-gray-400">LoRA Rank:</span>
|
||||
<span className="text-white ml-2">{settings.loraRank}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-gray-400">Batch Size:</span>
|
||||
<span className="text-white ml-2">{settings.batchSize}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={handleFineTune}
|
||||
disabled={fineTuning || examplesCount < 10}
|
||||
className="w-full px-4 py-2 bg-green-600 hover:bg-green-700 disabled:bg-gray-700 disabled:cursor-not-allowed rounded-lg text-sm font-medium transition-colors"
|
||||
>
|
||||
{fineTuning ? (
|
||||
<span className="flex items-center justify-center gap-2">
|
||||
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white"></div>
|
||||
Fine-Tuning läuft...
|
||||
</span>
|
||||
) : (
|
||||
'Fine-Tuning starten'
|
||||
)}
|
||||
</button>
|
||||
|
||||
{examplesCount < 10 && (
|
||||
<p className="text-xs text-yellow-400 mt-2 text-center">
|
||||
Noch {10 - examplesCount} Beispiele benötigt
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Training Examples List */}
|
||||
{examples.length > 0 && (
|
||||
<div className="bg-gray-800/50 border border-gray-700 rounded-xl p-6">
|
||||
<h2 className="text-lg font-semibold text-white mb-4">Trainingsbeispiele ({examples.length})</h2>
|
||||
<div className="space-y-2 max-h-64 overflow-y-auto">
|
||||
{examples.map((ex, i) => (
|
||||
<div key={i} className="flex items-center gap-4 bg-gray-900/50 rounded-lg p-3">
|
||||
<span className="text-gray-500 font-mono text-sm w-8">{i + 1}.</span>
|
||||
<span className="text-white text-sm flex-1 truncate">{ex.ground_truth}</span>
|
||||
<span className="text-gray-500 text-xs">{new Date(ex.created_at).toLocaleDateString('de-DE')}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
export type TabId = 'overview' | 'test' | 'training' | 'architecture' | 'settings'
|
||||
|
||||
export interface TrOCRStatus {
|
||||
status: 'available' | 'not_installed' | 'error'
|
||||
model_name?: string
|
||||
model_id?: string
|
||||
device?: string
|
||||
is_loaded?: boolean
|
||||
has_lora_adapter?: boolean
|
||||
training_examples_count?: number
|
||||
error?: string
|
||||
install_command?: string
|
||||
}
|
||||
|
||||
export interface OCRResult {
|
||||
text: string
|
||||
confidence: number
|
||||
processing_time_ms: number
|
||||
model: string
|
||||
has_lora_adapter: boolean
|
||||
}
|
||||
|
||||
export interface TrainingExample {
|
||||
image_path: string
|
||||
ground_truth: string
|
||||
teacher_id: string
|
||||
created_at: string
|
||||
}
|
||||
|
||||
export interface MagicSettings {
|
||||
autoDetectLines: boolean
|
||||
confidenceThreshold: number
|
||||
maxImageSize: number
|
||||
loraRank: number
|
||||
loraAlpha: number
|
||||
learningRate: number
|
||||
epochs: number
|
||||
batchSize: number
|
||||
enableCache: boolean
|
||||
cacheMaxAge: number
|
||||
}
|
||||
|
||||
export const DEFAULT_SETTINGS: MagicSettings = {
|
||||
autoDetectLines: true,
|
||||
confidenceThreshold: 0.7,
|
||||
maxImageSize: 4096,
|
||||
loraRank: 8,
|
||||
loraAlpha: 32,
|
||||
learningRate: 0.00005,
|
||||
epochs: 3,
|
||||
batchSize: 4,
|
||||
enableCache: true,
|
||||
cacheMaxAge: 3600,
|
||||
}
|
||||
|
||||
export const TABS = [
|
||||
{ id: 'overview' as TabId, label: 'Übersicht', icon: '📊' },
|
||||
{ id: 'test' as TabId, label: 'OCR Test', icon: '🔍' },
|
||||
{ id: 'training' as TabId, label: 'Training', icon: '🎯' },
|
||||
{ id: 'architecture' as TabId, label: 'Architektur', icon: '🏗️' },
|
||||
{ id: 'settings' as TabId, label: 'Einstellungen', icon: '⚙️' },
|
||||
]
|
||||
@@ -0,0 +1,180 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import type { TabId, TrOCRStatus, OCRResult, TrainingExample, MagicSettings } from './types'
|
||||
import { DEFAULT_SETTINGS } from './types'
|
||||
|
||||
export function useMagicHelp() {
|
||||
const [activeTab, setActiveTab] = useState<TabId>('overview')
|
||||
const [status, setStatus] = useState<TrOCRStatus | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [ocrResult, setOcrResult] = useState<OCRResult | null>(null)
|
||||
const [ocrLoading, setOcrLoading] = useState(false)
|
||||
const [examples, setExamples] = useState<TrainingExample[]>([])
|
||||
const [trainingImage, setTrainingImage] = useState<File | null>(null)
|
||||
const [trainingText, setTrainingText] = useState('')
|
||||
const [fineTuning, setFineTuning] = useState(false)
|
||||
const [settings, setSettings] = useState<MagicSettings>(DEFAULT_SETTINGS)
|
||||
const [settingsSaved, setSettingsSaved] = useState(false)
|
||||
|
||||
const fetchStatus = useCallback(async () => {
|
||||
try {
|
||||
const res = await fetch('/api/klausur/trocr/status')
|
||||
const data = await res.json()
|
||||
setStatus(data)
|
||||
} catch {
|
||||
setStatus({ status: 'error', error: 'Failed to fetch status' })
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const fetchExamples = useCallback(async () => {
|
||||
try {
|
||||
const res = await fetch('/api/klausur/trocr/training/examples')
|
||||
const data = await res.json()
|
||||
setExamples(data.examples || [])
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch examples:', error)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
fetchStatus()
|
||||
fetchExamples()
|
||||
// Load settings from localStorage
|
||||
const saved = localStorage.getItem('magic-help-settings')
|
||||
if (saved) {
|
||||
try {
|
||||
setSettings(JSON.parse(saved))
|
||||
} catch {
|
||||
// ignore parse errors
|
||||
}
|
||||
}
|
||||
}, [fetchStatus, fetchExamples])
|
||||
|
||||
const handleFileUpload = async (file: File) => {
|
||||
setOcrLoading(true)
|
||||
setOcrResult(null)
|
||||
|
||||
const formData = new FormData()
|
||||
formData.append('file', file)
|
||||
|
||||
try {
|
||||
const res = await fetch(`/api/klausur/trocr/extract?detect_lines=${settings.autoDetectLines}`, {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
})
|
||||
const data = await res.json()
|
||||
if (data.text !== undefined) {
|
||||
setOcrResult(data)
|
||||
} else {
|
||||
setOcrResult({ text: `Error: ${data.detail || 'Unknown error'}`, confidence: 0, processing_time_ms: 0, model: '', has_lora_adapter: false })
|
||||
}
|
||||
} catch (error) {
|
||||
setOcrResult({ text: `Error: ${error}`, confidence: 0, processing_time_ms: 0, model: '', has_lora_adapter: false })
|
||||
} finally {
|
||||
setOcrLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleAddTrainingExample = async () => {
|
||||
if (!trainingImage || !trainingText.trim()) {
|
||||
alert('Please provide both an image and the correct text')
|
||||
return
|
||||
}
|
||||
|
||||
const formData = new FormData()
|
||||
formData.append('file', trainingImage)
|
||||
|
||||
try {
|
||||
const res = await fetch(`/api/klausur/trocr/training/add?ground_truth=${encodeURIComponent(trainingText)}`, {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
})
|
||||
const data = await res.json()
|
||||
if (data.example_id) {
|
||||
alert(`Training example added! Total: ${data.total_examples}`)
|
||||
setTrainingImage(null)
|
||||
setTrainingText('')
|
||||
fetchStatus()
|
||||
fetchExamples()
|
||||
} else {
|
||||
alert(`Error: ${data.detail || 'Unknown error'}`)
|
||||
}
|
||||
} catch (error) {
|
||||
alert(`Error: ${error}`)
|
||||
}
|
||||
}
|
||||
|
||||
const handleFineTune = async () => {
|
||||
if (!confirm('Start fine-tuning? This may take several minutes.')) return
|
||||
|
||||
setFineTuning(true)
|
||||
try {
|
||||
const res = await fetch('/api/klausur/trocr/training/fine-tune', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
epochs: settings.epochs,
|
||||
learning_rate: settings.learningRate,
|
||||
lora_rank: settings.loraRank,
|
||||
lora_alpha: settings.loraAlpha,
|
||||
}),
|
||||
})
|
||||
const data = await res.json()
|
||||
if (data.status === 'success') {
|
||||
alert(`Fine-tuning successful!\nExamples used: ${data.examples_used}\nEpochs: ${data.epochs}`)
|
||||
fetchStatus()
|
||||
} else {
|
||||
alert(`Fine-tuning failed: ${data.message}`)
|
||||
}
|
||||
} catch (error) {
|
||||
alert(`Error: ${error}`)
|
||||
} finally {
|
||||
setFineTuning(false)
|
||||
}
|
||||
}
|
||||
|
||||
const saveSettings = () => {
|
||||
localStorage.setItem('magic-help-settings', JSON.stringify(settings))
|
||||
setSettingsSaved(true)
|
||||
setTimeout(() => setSettingsSaved(false), 2000)
|
||||
}
|
||||
|
||||
const getStatusBadge = () => {
|
||||
if (!status) return null
|
||||
switch (status.status) {
|
||||
case 'available':
|
||||
return <span className="px-2 py-1 text-xs font-medium rounded-full bg-green-500/20 text-green-400">Available</span>
|
||||
case 'not_installed':
|
||||
return <span className="px-2 py-1 text-xs font-medium rounded-full bg-red-500/20 text-red-400">Not Installed</span>
|
||||
case 'error':
|
||||
return <span className="px-2 py-1 text-xs font-medium rounded-full bg-yellow-500/20 text-yellow-400">Error</span>
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
activeTab,
|
||||
setActiveTab,
|
||||
status,
|
||||
loading,
|
||||
ocrResult,
|
||||
ocrLoading,
|
||||
examples,
|
||||
trainingImage,
|
||||
setTrainingImage,
|
||||
trainingText,
|
||||
setTrainingText,
|
||||
fineTuning,
|
||||
settings,
|
||||
setSettings,
|
||||
settingsSaved,
|
||||
fetchStatus,
|
||||
handleFileUpload,
|
||||
handleAddTrainingExample,
|
||||
handleFineTune,
|
||||
saveSettings,
|
||||
getStatusBadge,
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
})}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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