All checks were successful
CI / go-lint (push) Has been skipped
CI / python-lint (push) Has been skipped
CI / nodejs-lint (push) Has been skipped
CI / test-go-ai-compliance (push) Successful in 36s
CI / test-python-backend-compliance (push) Successful in 31s
CI / test-python-document-crawler (push) Successful in 23s
CI / test-python-dsms-gateway (push) Successful in 21s
- Backend: UpdateLesson handler (PUT /lessons/:id) for editing title, content, quiz questions - Backend: TestQuiz handler (POST /lessons/:id/quiz-test) for quiz evaluation without enrollment - Frontend: Content editor with markdown textarea, save, and approve-for-video workflow - Frontend: Fix quiz endpoint to /lessons/:id/quiz-test Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
652 lines
30 KiB
TypeScript
652 lines
30 KiB
TypeScript
'use client'
|
|
|
|
import React, { useState, useEffect, useMemo } from 'react'
|
|
import { useParams, useRouter } from 'next/navigation'
|
|
import Link from 'next/link'
|
|
import {
|
|
Course,
|
|
Lesson,
|
|
Enrollment,
|
|
QuizQuestion,
|
|
COURSE_CATEGORY_INFO,
|
|
ENROLLMENT_STATUS_INFO,
|
|
isEnrollmentOverdue,
|
|
getDaysUntilDeadline
|
|
} from '@/lib/sdk/academy/types'
|
|
import {
|
|
fetchCourse,
|
|
fetchEnrollments,
|
|
deleteCourse,
|
|
submitQuiz,
|
|
updateLesson,
|
|
generateVideos,
|
|
getVideoStatus
|
|
} from '@/lib/sdk/academy/api'
|
|
|
|
type TabId = 'overview' | 'lessons' | 'enrollments' | 'videos'
|
|
|
|
export default function CourseDetailPage() {
|
|
const params = useParams()
|
|
const router = useRouter()
|
|
const courseId = params.id as string
|
|
|
|
const [course, setCourse] = useState<Course | null>(null)
|
|
const [enrollments, setEnrollments] = useState<Enrollment[]>([])
|
|
const [activeTab, setActiveTab] = useState<TabId>('overview')
|
|
const [isLoading, setIsLoading] = useState(true)
|
|
const [selectedLesson, setSelectedLesson] = useState<Lesson | null>(null)
|
|
const [quizAnswers, setQuizAnswers] = useState<Record<string, number>>({})
|
|
const [quizResult, setQuizResult] = useState<any>(null)
|
|
const [isSubmittingQuiz, setIsSubmittingQuiz] = useState(false)
|
|
const [videoStatus, setVideoStatus] = useState<any>(null)
|
|
const [isGeneratingVideos, setIsGeneratingVideos] = useState(false)
|
|
const [isEditing, setIsEditing] = useState(false)
|
|
const [editContent, setEditContent] = useState('')
|
|
const [editTitle, setEditTitle] = useState('')
|
|
const [isSaving, setIsSaving] = useState(false)
|
|
const [saveMessage, setSaveMessage] = useState<{ type: 'success' | 'error', text: string } | null>(null)
|
|
|
|
useEffect(() => {
|
|
const loadData = async () => {
|
|
setIsLoading(true)
|
|
try {
|
|
const [courseData, enrollmentData] = await Promise.all([
|
|
fetchCourse(courseId).catch(() => null),
|
|
fetchEnrollments(courseId).catch(() => [])
|
|
])
|
|
setCourse(courseData)
|
|
setEnrollments(Array.isArray(enrollmentData) ? enrollmentData : [])
|
|
if (courseData && courseData.lessons && courseData.lessons.length > 0) {
|
|
setSelectedLesson(courseData.lessons[0])
|
|
}
|
|
} catch (error) {
|
|
console.error('Failed to load course:', error)
|
|
} finally {
|
|
setIsLoading(false)
|
|
}
|
|
}
|
|
loadData()
|
|
}, [courseId])
|
|
|
|
const handleDeleteCourse = async () => {
|
|
if (!confirm('Sind Sie sicher, dass Sie diesen Kurs loeschen moechten? Diese Aktion kann nicht rueckgaengig gemacht werden.')) return
|
|
try {
|
|
await deleteCourse(courseId)
|
|
router.push('/sdk/academy')
|
|
} catch (error) {
|
|
console.error('Failed to delete course:', error)
|
|
}
|
|
}
|
|
|
|
const handleSubmitQuiz = async () => {
|
|
if (!selectedLesson) return
|
|
const questions = selectedLesson.quizQuestions || []
|
|
const answers = questions.map((q: QuizQuestion) => quizAnswers[q.id] ?? -1)
|
|
|
|
setIsSubmittingQuiz(true)
|
|
try {
|
|
const result = await submitQuiz(selectedLesson.id, { answers })
|
|
setQuizResult(result)
|
|
} catch (error: any) {
|
|
console.error('Quiz submission failed:', error)
|
|
setQuizResult({ error: error.message || 'Fehler bei der Auswertung' })
|
|
} finally {
|
|
setIsSubmittingQuiz(false)
|
|
}
|
|
}
|
|
|
|
const handleStartEdit = () => {
|
|
if (!selectedLesson) return
|
|
setEditContent(selectedLesson.contentMarkdown || '')
|
|
setEditTitle(selectedLesson.title || '')
|
|
setIsEditing(true)
|
|
setSaveMessage(null)
|
|
}
|
|
|
|
const handleCancelEdit = () => {
|
|
setIsEditing(false)
|
|
setSaveMessage(null)
|
|
}
|
|
|
|
const handleSaveLesson = async () => {
|
|
if (!selectedLesson) return
|
|
setIsSaving(true)
|
|
setSaveMessage(null)
|
|
try {
|
|
await updateLesson(selectedLesson.id, {
|
|
title: editTitle,
|
|
content_url: editContent,
|
|
})
|
|
const updatedLesson = { ...selectedLesson, title: editTitle, contentMarkdown: editContent }
|
|
setSelectedLesson(updatedLesson)
|
|
if (course) {
|
|
const updatedLessons = course.lessons.map(l => l.id === updatedLesson.id ? updatedLesson : l)
|
|
setCourse({ ...course, lessons: updatedLessons })
|
|
}
|
|
setIsEditing(false)
|
|
setSaveMessage({ type: 'success', text: 'Lektion gespeichert.' })
|
|
} catch (error: any) {
|
|
setSaveMessage({ type: 'error', text: error.message || 'Fehler beim Speichern.' })
|
|
} finally {
|
|
setIsSaving(false)
|
|
}
|
|
}
|
|
|
|
const handleApproveLesson = async () => {
|
|
if (!selectedLesson) return
|
|
if (!confirm('Lektion fuer Video-Verarbeitung freigeben? Der Text wird als final markiert.')) return
|
|
setIsSaving(true)
|
|
setSaveMessage(null)
|
|
try {
|
|
await updateLesson(selectedLesson.id, {
|
|
description: 'approved_for_video',
|
|
})
|
|
const updatedLesson = { ...selectedLesson }
|
|
if (course) {
|
|
const updatedLessons = course.lessons.map(l => l.id === updatedLesson.id ? updatedLesson : l)
|
|
setCourse({ ...course, lessons: updatedLessons })
|
|
}
|
|
setSaveMessage({ type: 'success', text: 'Lektion fuer Video-Verarbeitung freigegeben.' })
|
|
} catch (error: any) {
|
|
setSaveMessage({ type: 'error', text: error.message || 'Fehler bei der Freigabe.' })
|
|
} finally {
|
|
setIsSaving(false)
|
|
}
|
|
}
|
|
|
|
const handleGenerateVideos = async () => {
|
|
setIsGeneratingVideos(true)
|
|
try {
|
|
const status = await generateVideos(courseId)
|
|
setVideoStatus(status)
|
|
} catch (error) {
|
|
console.error('Video generation failed:', error)
|
|
} finally {
|
|
setIsGeneratingVideos(false)
|
|
}
|
|
}
|
|
|
|
const handleCheckVideoStatus = async () => {
|
|
try {
|
|
const status = await getVideoStatus(courseId)
|
|
setVideoStatus(status)
|
|
} catch (error) {
|
|
console.error('Failed to check video status:', error)
|
|
}
|
|
}
|
|
|
|
if (isLoading) {
|
|
return (
|
|
<div className="flex items-center justify-center py-20">
|
|
<svg className="animate-spin w-8 h-8 text-purple-600" fill="none" viewBox="0 0 24 24">
|
|
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
|
|
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z" />
|
|
</svg>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
if (!course) {
|
|
return (
|
|
<div className="text-center py-20">
|
|
<h2 className="text-xl font-semibold text-gray-900">Kurs nicht gefunden</h2>
|
|
<Link href="/sdk/academy" className="mt-4 inline-block text-purple-600 hover:underline">
|
|
Zurueck zur Uebersicht
|
|
</Link>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
const categoryInfo = COURSE_CATEGORY_INFO[course.category] || COURSE_CATEGORY_INFO['custom']
|
|
const sortedLessons = [...(course.lessons || [])].sort((a, b) => a.order - b.order)
|
|
const completedEnrollments = enrollments.filter(e => e.status === 'completed').length
|
|
const overdueEnrollments = enrollments.filter(e => isEnrollmentOverdue(e)).length
|
|
|
|
return (
|
|
<div className="space-y-6">
|
|
{/* Header */}
|
|
<div className="flex items-start justify-between">
|
|
<div className="flex items-center gap-4">
|
|
<Link
|
|
href="/sdk/academy"
|
|
className="p-2 text-gray-500 hover:text-gray-700 hover:bg-gray-100 rounded-lg transition-colors"
|
|
>
|
|
<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>
|
|
</Link>
|
|
<div>
|
|
<div className="flex items-center gap-2 mb-1">
|
|
<span className={`px-2 py-1 text-xs rounded-full ${categoryInfo.bgColor} ${categoryInfo.color}`}>
|
|
{categoryInfo.label}
|
|
</span>
|
|
<span className={`px-2 py-1 text-xs rounded-full ${
|
|
course.status === 'published' ? 'bg-green-100 text-green-700' : 'bg-gray-100 text-gray-600'
|
|
}`}>
|
|
{course.status === 'published' ? 'Veroeffentlicht' : 'Entwurf'}
|
|
</span>
|
|
</div>
|
|
<h1 className="text-2xl font-bold text-gray-900">{course.title}</h1>
|
|
<p className="text-sm text-gray-500 mt-1">{course.description}</p>
|
|
</div>
|
|
</div>
|
|
<div className="flex items-center gap-2">
|
|
<button
|
|
onClick={handleDeleteCourse}
|
|
className="px-3 py-2 text-sm text-red-600 hover:bg-red-50 rounded-lg transition-colors"
|
|
>
|
|
Loeschen
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Stats Row */}
|
|
<div className="grid grid-cols-4 gap-4">
|
|
<div className="bg-white rounded-xl border border-gray-200 p-4">
|
|
<div className="text-sm text-gray-500">Lektionen</div>
|
|
<div className="text-2xl font-bold text-gray-900">{sortedLessons.length}</div>
|
|
</div>
|
|
<div className="bg-white rounded-xl border border-gray-200 p-4">
|
|
<div className="text-sm text-gray-500">Dauer</div>
|
|
<div className="text-2xl font-bold text-gray-900">{course.durationMinutes} Min.</div>
|
|
</div>
|
|
<div className="bg-white rounded-xl border border-gray-200 p-4">
|
|
<div className="text-sm text-gray-500">Teilnehmer</div>
|
|
<div className="text-2xl font-bold text-blue-600">{enrollments.length}</div>
|
|
</div>
|
|
<div className="bg-white rounded-xl border border-gray-200 p-4">
|
|
<div className="text-sm text-gray-500">Abgeschlossen</div>
|
|
<div className="text-2xl font-bold text-green-600">{completedEnrollments}</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Tabs */}
|
|
<div className="border-b border-gray-200">
|
|
<nav className="flex gap-1 -mb-px">
|
|
{(['overview', 'lessons', 'enrollments', 'videos'] as TabId[]).map(tab => (
|
|
<button
|
|
key={tab}
|
|
onClick={() => setActiveTab(tab)}
|
|
className={`px-4 py-3 text-sm font-medium border-b-2 transition-colors ${
|
|
activeTab === tab
|
|
? 'border-purple-600 text-purple-600'
|
|
: 'border-transparent text-gray-500 hover:text-gray-700'
|
|
}`}
|
|
>
|
|
{{ overview: 'Uebersicht', lessons: 'Lektionen', enrollments: 'Einschreibungen', videos: 'Videos' }[tab]}
|
|
</button>
|
|
))}
|
|
</nav>
|
|
</div>
|
|
|
|
{/* Overview Tab */}
|
|
{activeTab === 'overview' && (
|
|
<div className="space-y-6">
|
|
<div className="bg-white rounded-xl border border-gray-200 p-6">
|
|
<h3 className="text-lg font-semibold text-gray-900 mb-4">Kurs-Details</h3>
|
|
<dl className="grid grid-cols-2 gap-4 text-sm">
|
|
<div><dt className="text-gray-500">Bestehensgrenze</dt><dd className="font-medium text-gray-900">{course.passingScore}%</dd></div>
|
|
<div><dt className="text-gray-500">Pflicht fuer</dt><dd className="font-medium text-gray-900">{course.requiredForRoles?.join(', ') || 'Alle'}</dd></div>
|
|
<div><dt className="text-gray-500">Erstellt am</dt><dd className="font-medium text-gray-900">{new Date(course.createdAt).toLocaleDateString('de-DE')}</dd></div>
|
|
<div><dt className="text-gray-500">Aktualisiert am</dt><dd className="font-medium text-gray-900">{new Date(course.updatedAt).toLocaleDateString('de-DE')}</dd></div>
|
|
</dl>
|
|
</div>
|
|
|
|
{/* Lesson List Preview */}
|
|
<div className="bg-white rounded-xl border border-gray-200 p-6">
|
|
<h3 className="text-lg font-semibold text-gray-900 mb-4">Lektionen ({sortedLessons.length})</h3>
|
|
<div className="space-y-2">
|
|
{sortedLessons.map((lesson, i) => (
|
|
<div key={lesson.id} className="flex items-center gap-3 p-3 rounded-lg hover:bg-gray-50">
|
|
<div className="w-8 h-8 rounded-full bg-purple-100 text-purple-600 flex items-center justify-center text-sm font-medium">
|
|
{i + 1}
|
|
</div>
|
|
<div className="flex-1">
|
|
<div className="text-sm font-medium text-gray-900">{lesson.title}</div>
|
|
<div className="text-xs text-gray-500">{lesson.durationMinutes} Min. | {lesson.type === 'video' ? 'Video' : lesson.type === 'quiz' ? 'Quiz' : 'Text'}</div>
|
|
</div>
|
|
<span className={`px-2 py-1 text-xs rounded-full ${
|
|
lesson.type === 'quiz' ? 'bg-yellow-100 text-yellow-700' :
|
|
lesson.type === 'video' ? 'bg-blue-100 text-blue-700' :
|
|
'bg-gray-100 text-gray-600'
|
|
}`}>
|
|
{lesson.type === 'quiz' ? 'Quiz' : lesson.type === 'video' ? 'Video' : 'Text'}
|
|
</span>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Lessons Tab - with content viewer and quiz player */}
|
|
{activeTab === 'lessons' && (
|
|
<div className="grid grid-cols-3 gap-6">
|
|
{/* Lesson Navigation */}
|
|
<div className="col-span-1 bg-white rounded-xl border border-gray-200 p-4">
|
|
<h3 className="text-sm font-semibold text-gray-700 mb-3">Lektionen</h3>
|
|
<div className="space-y-1">
|
|
{sortedLessons.map((lesson, i) => (
|
|
<button
|
|
key={lesson.id}
|
|
onClick={() => { setSelectedLesson(lesson); setQuizResult(null); setQuizAnswers({}) }}
|
|
className={`w-full text-left p-3 rounded-lg text-sm transition-colors ${
|
|
selectedLesson?.id === lesson.id
|
|
? 'bg-purple-50 text-purple-700 border border-purple-200'
|
|
: 'hover:bg-gray-50 text-gray-700'
|
|
}`}
|
|
>
|
|
<div className="flex items-center gap-2">
|
|
<span className="text-xs text-gray-400 w-4">{i + 1}.</span>
|
|
<span className="truncate">{lesson.title}</span>
|
|
</div>
|
|
</button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Lesson Content */}
|
|
<div className="col-span-2 bg-white rounded-xl border border-gray-200 p-6">
|
|
{selectedLesson ? (
|
|
<div className="space-y-6">
|
|
<div className="flex items-center justify-between">
|
|
{isEditing ? (
|
|
<input
|
|
type="text"
|
|
value={editTitle}
|
|
onChange={e => setEditTitle(e.target.value)}
|
|
className="text-xl font-semibold text-gray-900 border border-gray-300 rounded-lg px-3 py-1 flex-1 mr-3"
|
|
/>
|
|
) : (
|
|
<h2 className="text-xl font-semibold text-gray-900">{selectedLesson.title}</h2>
|
|
)}
|
|
<div className="flex items-center gap-2">
|
|
<span className={`px-3 py-1 text-xs rounded-full ${
|
|
selectedLesson.type === 'quiz' ? 'bg-yellow-100 text-yellow-700' :
|
|
selectedLesson.type === 'video' ? 'bg-blue-100 text-blue-700' :
|
|
'bg-gray-100 text-gray-600'
|
|
}`}>
|
|
{selectedLesson.type === 'quiz' ? 'Quiz' : selectedLesson.type === 'video' ? 'Video' : 'Text'}
|
|
</span>
|
|
{selectedLesson.type !== 'quiz' && !isEditing && (
|
|
<>
|
|
<button
|
|
onClick={handleStartEdit}
|
|
className="px-3 py-1 text-sm bg-gray-100 text-gray-700 rounded-lg hover:bg-gray-200 transition-colors"
|
|
>
|
|
Bearbeiten
|
|
</button>
|
|
<button
|
|
onClick={handleApproveLesson}
|
|
disabled={isSaving}
|
|
className="px-3 py-1 text-sm bg-green-600 text-white rounded-lg hover:bg-green-700 transition-colors disabled:opacity-50"
|
|
>
|
|
Freigeben
|
|
</button>
|
|
</>
|
|
)}
|
|
{isEditing && (
|
|
<>
|
|
<button
|
|
onClick={handleCancelEdit}
|
|
className="px-3 py-1 text-sm bg-gray-100 text-gray-700 rounded-lg hover:bg-gray-200 transition-colors"
|
|
>
|
|
Abbrechen
|
|
</button>
|
|
<button
|
|
onClick={handleSaveLesson}
|
|
disabled={isSaving}
|
|
className="px-3 py-1 text-sm bg-purple-600 text-white rounded-lg hover:bg-purple-700 transition-colors disabled:opacity-50"
|
|
>
|
|
{isSaving ? 'Speichert...' : 'Speichern'}
|
|
</button>
|
|
</>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Save/Approve Message */}
|
|
{saveMessage && (
|
|
<div className={`p-3 rounded-lg text-sm ${
|
|
saveMessage.type === 'success' ? 'bg-green-50 text-green-700 border border-green-200' : 'bg-red-50 text-red-700 border border-red-200'
|
|
}`}>
|
|
{saveMessage.text}
|
|
</div>
|
|
)}
|
|
|
|
{/* Video Player */}
|
|
{selectedLesson.type === 'video' && selectedLesson.videoUrl && (
|
|
<div className="aspect-video bg-gray-900 rounded-xl overflow-hidden">
|
|
<video
|
|
src={selectedLesson.videoUrl}
|
|
controls
|
|
className="w-full h-full"
|
|
/>
|
|
</div>
|
|
)}
|
|
|
|
{/* Text Content - Edit Mode */}
|
|
{isEditing && (selectedLesson.type === 'text' || selectedLesson.type === 'video') && (
|
|
<div className="space-y-2">
|
|
<label className="text-sm text-gray-500">Inhalt (Markdown)</label>
|
|
<textarea
|
|
value={editContent}
|
|
onChange={e => setEditContent(e.target.value)}
|
|
rows={20}
|
|
className="w-full border border-gray-300 rounded-xl p-4 text-sm font-mono text-gray-800 leading-relaxed resize-y focus:ring-2 focus:ring-purple-500 focus:border-transparent"
|
|
placeholder="Markdown-Inhalt der Lektion..."
|
|
/>
|
|
<p className="text-xs text-gray-400">Unterstuetzt: # Ueberschrift, ## Unterueberschrift, - Aufzaehlung, **fett**</p>
|
|
</div>
|
|
)}
|
|
|
|
{/* Text Content - View Mode */}
|
|
{!isEditing && (selectedLesson.type === 'text' || selectedLesson.type === 'video') && selectedLesson.contentMarkdown && (
|
|
<div className="prose prose-sm max-w-none">
|
|
<div className="whitespace-pre-wrap text-gray-700 leading-relaxed">
|
|
{selectedLesson.contentMarkdown.split('\n').map((line, i) => {
|
|
if (line.startsWith('# ')) return <h1 key={i} className="text-2xl font-bold text-gray-900 mt-6 mb-3">{line.slice(2)}</h1>
|
|
if (line.startsWith('## ')) return <h2 key={i} className="text-xl font-semibold text-gray-900 mt-5 mb-2">{line.slice(3)}</h2>
|
|
if (line.startsWith('### ')) return <h3 key={i} className="text-lg font-medium text-gray-900 mt-4 mb-2">{line.slice(4)}</h3>
|
|
if (line.startsWith('- **')) {
|
|
const parts = line.slice(2).split('**')
|
|
return <li key={i} className="ml-4 list-disc"><strong>{parts[1]}</strong>{parts[2] || ''}</li>
|
|
}
|
|
if (line.startsWith('- ')) return <li key={i} className="ml-4 list-disc">{line.slice(2)}</li>
|
|
if (line.trim() === '') return <br key={i} />
|
|
return <p key={i} className="mb-2">{line}</p>
|
|
})}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Quiz Player */}
|
|
{selectedLesson.type === 'quiz' && selectedLesson.quizQuestions && (
|
|
<div className="space-y-6">
|
|
{selectedLesson.quizQuestions.map((q: QuizQuestion, qi: number) => (
|
|
<div key={q.id} className="bg-gray-50 rounded-xl p-5">
|
|
<h4 className="font-medium text-gray-900 mb-3">Frage {qi + 1}: {q.question}</h4>
|
|
<div className="space-y-2">
|
|
{q.options.map((option: string, oi: number) => {
|
|
const isSelected = quizAnswers[q.id] === oi
|
|
const showResult = quizResult && !quizResult.error
|
|
const isCorrect = showResult && quizResult.results?.[qi]?.correct
|
|
const wasSelected = showResult && isSelected
|
|
|
|
let bgClass = 'bg-white border-gray-200 hover:border-purple-300'
|
|
if (isSelected && !showResult) bgClass = 'bg-purple-50 border-purple-500'
|
|
if (showResult && oi === q.correctOptionIndex) bgClass = 'bg-green-50 border-green-500'
|
|
if (showResult && wasSelected && !isCorrect) bgClass = 'bg-red-50 border-red-500'
|
|
|
|
return (
|
|
<button
|
|
key={oi}
|
|
onClick={() => !quizResult && setQuizAnswers({ ...quizAnswers, [q.id]: oi })}
|
|
disabled={!!quizResult}
|
|
className={`w-full text-left p-3 rounded-lg border-2 transition-all ${bgClass}`}
|
|
>
|
|
<span className="text-sm text-gray-700">{String.fromCharCode(65 + oi)}) {option}</span>
|
|
</button>
|
|
)
|
|
})}
|
|
</div>
|
|
{quizResult && !quizResult.error && quizResult.results?.[qi] && (
|
|
<div className={`mt-3 p-3 rounded-lg text-sm ${
|
|
quizResult.results[qi].correct ? 'bg-green-50 text-green-700' : 'bg-red-50 text-red-700'
|
|
}`}>
|
|
{quizResult.results[qi].correct ? 'Richtig!' : 'Falsch.'} {q.explanation}
|
|
</div>
|
|
)}
|
|
</div>
|
|
))}
|
|
|
|
{/* Quiz Submit / Result */}
|
|
{!quizResult ? (
|
|
<button
|
|
onClick={handleSubmitQuiz}
|
|
disabled={isSubmittingQuiz || Object.keys(quizAnswers).length < (selectedLesson.quizQuestions?.length || 0)}
|
|
className="w-full py-3 bg-purple-600 text-white rounded-xl hover:bg-purple-700 transition-colors disabled:opacity-50 font-medium"
|
|
>
|
|
{isSubmittingQuiz ? 'Wird ausgewertet...' : 'Quiz auswerten'}
|
|
</button>
|
|
) : quizResult.error ? (
|
|
<div className="bg-red-50 border border-red-200 rounded-xl p-4 text-sm text-red-700">{quizResult.error}</div>
|
|
) : (
|
|
<div className={`rounded-xl p-6 text-center ${quizResult.passed ? 'bg-green-50 border border-green-200' : 'bg-red-50 border border-red-200'}`}>
|
|
<div className={`text-3xl font-bold ${quizResult.passed ? 'text-green-600' : 'text-red-600'}`}>
|
|
{quizResult.score}%
|
|
</div>
|
|
<div className={`text-sm mt-1 ${quizResult.passed ? 'text-green-700' : 'text-red-700'}`}>
|
|
{quizResult.passed ? 'Bestanden!' : 'Nicht bestanden'} — {quizResult.correctAnswers}/{quizResult.totalQuestions} richtig
|
|
</div>
|
|
<button
|
|
onClick={() => { setQuizResult(null); setQuizAnswers({}) }}
|
|
className="mt-4 px-4 py-2 text-sm bg-white rounded-lg border hover:bg-gray-50"
|
|
>
|
|
Quiz wiederholen
|
|
</button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
) : (
|
|
<p className="text-gray-500 text-center py-10">Waehlen Sie eine Lektion aus.</p>
|
|
)}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Enrollments Tab */}
|
|
{activeTab === 'enrollments' && (
|
|
<div className="space-y-4">
|
|
{overdueEnrollments > 0 && (
|
|
<div className="bg-red-50 border border-red-200 rounded-xl p-4 text-sm text-red-700">
|
|
{overdueEnrollments} ueberfaellige Einschreibung(en)
|
|
</div>
|
|
)}
|
|
{enrollments.length === 0 ? (
|
|
<div className="bg-white rounded-xl border border-gray-200 p-12 text-center">
|
|
<p className="text-gray-500">Noch keine Einschreibungen fuer diesen Kurs.</p>
|
|
</div>
|
|
) : (
|
|
enrollments.map(enrollment => {
|
|
const statusInfo = ENROLLMENT_STATUS_INFO[enrollment.status]
|
|
const overdue = isEnrollmentOverdue(enrollment)
|
|
const daysUntil = getDaysUntilDeadline(enrollment.deadline)
|
|
return (
|
|
<div key={enrollment.id} className={`bg-white rounded-xl border-2 p-5 ${overdue ? 'border-red-200' : 'border-gray-200'}`}>
|
|
<div className="flex items-center justify-between">
|
|
<div>
|
|
<div className="flex items-center gap-2 mb-1">
|
|
<span className={`px-2 py-0.5 text-xs rounded-full ${statusInfo?.bgColor} ${statusInfo?.color}`}>
|
|
{statusInfo?.label}
|
|
</span>
|
|
{overdue && <span className="text-xs text-red-600">Ueberfaellig</span>}
|
|
</div>
|
|
<div className="font-medium text-gray-900">{enrollment.userName}</div>
|
|
<div className="text-sm text-gray-500">{enrollment.userEmail}</div>
|
|
</div>
|
|
<div className="text-right">
|
|
<div className="text-2xl font-bold text-gray-900">{enrollment.progress}%</div>
|
|
<div className="text-xs text-gray-500">
|
|
{enrollment.status === 'completed' ? 'Abgeschlossen' : `${daysUntil > 0 ? daysUntil + ' Tage verbleibend' : Math.abs(daysUntil) + ' Tage ueberfaellig'}`}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div className="mt-3 w-full h-2 bg-gray-200 rounded-full overflow-hidden">
|
|
<div
|
|
className={`h-full rounded-full ${enrollment.progress === 100 ? 'bg-green-500' : overdue ? 'bg-red-500' : 'bg-purple-500'}`}
|
|
style={{ width: `${enrollment.progress}%` }}
|
|
/>
|
|
</div>
|
|
</div>
|
|
)
|
|
})
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{/* Videos Tab */}
|
|
{activeTab === 'videos' && (
|
|
<div className="space-y-6">
|
|
<div className="bg-white rounded-xl border border-gray-200 p-6">
|
|
<div className="flex items-center justify-between mb-4">
|
|
<h3 className="text-lg font-semibold text-gray-900">Video-Generierung</h3>
|
|
<div className="flex gap-2">
|
|
<button
|
|
onClick={handleCheckVideoStatus}
|
|
className="px-4 py-2 text-sm border border-gray-300 rounded-lg hover:bg-gray-50"
|
|
>
|
|
Status pruefen
|
|
</button>
|
|
<button
|
|
onClick={handleGenerateVideos}
|
|
disabled={isGeneratingVideos}
|
|
className="px-4 py-2 text-sm bg-purple-600 text-white rounded-lg hover:bg-purple-700 disabled:opacity-50"
|
|
>
|
|
{isGeneratingVideos ? 'Wird gestartet...' : 'Videos generieren'}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="bg-blue-50 border border-blue-200 rounded-xl p-4 mb-4 text-sm text-blue-700">
|
|
Videos werden mit ElevenLabs (Stimme) und HeyGen (Avatar) generiert.
|
|
Konfigurieren Sie die API-Keys in den Umgebungsvariablen.
|
|
</div>
|
|
|
|
{videoStatus && (
|
|
<div className="space-y-3">
|
|
<div className="flex items-center gap-2">
|
|
<span className="text-sm text-gray-500">Gesamtstatus:</span>
|
|
<span className={`px-2 py-1 text-xs rounded-full ${
|
|
videoStatus.status === 'completed' ? 'bg-green-100 text-green-700' :
|
|
videoStatus.status === 'processing' ? 'bg-yellow-100 text-yellow-700' :
|
|
'bg-gray-100 text-gray-600'
|
|
}`}>
|
|
{videoStatus.status}
|
|
</span>
|
|
</div>
|
|
{videoStatus.lessons?.map((ls: any) => (
|
|
<div key={ls.lessonId} className="flex items-center justify-between p-3 bg-gray-50 rounded-lg">
|
|
<span className="text-sm text-gray-700">Lektion {ls.lessonId.slice(-4)}</span>
|
|
<span className={`text-xs ${ls.status === 'completed' ? 'text-green-600' : 'text-gray-500'}`}>
|
|
{ls.status}
|
|
</span>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
|
|
{!videoStatus && (
|
|
<p className="text-sm text-gray-500 text-center py-8">
|
|
Klicken Sie auf "Videos generieren" um den Prozess zu starten.
|
|
</p>
|
|
)}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|