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