refactor(admin): split training, control-provenance, iace/verification, training/learner, ControlDetail
All 5 files reduced below 500 LOC (hard cap) by extracting sub-components: - training/page.tsx: 780→278 LOC — imports existing _components/, adds BlocksSection - control-provenance/page.tsx: 739→145 LOC — extracts provenance-data.ts, ProvenanceHelpers, LicenseMatrix, SourceRegistry - iace/[projectId]/verification/page.tsx: 673→164 LOC — extracts VerificationForm, CompleteModal, SuggestEvidenceModal, VerificationTable - training/learner/page.tsx: 560→216 LOC — extracts AssignmentsList, ContentView, QuizView, CertificatesView - ControlDetail.tsx: 878→311 LOC — adds ControlSourceCitation, ControlTraceability, ControlRegulatorySection, ControlSimilarControls, ControlReviewActions siblings Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,67 @@
|
||||
'use client'
|
||||
|
||||
import type { TrainingAssignment } from '@/lib/sdk/training/types'
|
||||
import { STATUS_LABELS, STATUS_COLORS } from '@/lib/sdk/training/types'
|
||||
|
||||
interface AssignmentsListProps {
|
||||
assignments: TrainingAssignment[]
|
||||
loading: boolean
|
||||
certGenerating: boolean
|
||||
onStart: (a: TrainingAssignment) => void
|
||||
onResume: (a: TrainingAssignment) => void
|
||||
onGenerateCertificate: (id: string) => void
|
||||
onDownloadPDF: (certId: string) => void
|
||||
}
|
||||
|
||||
export function AssignmentsList({
|
||||
assignments, loading, certGenerating, onStart, onResume, onGenerateCertificate, onDownloadPDF,
|
||||
}: AssignmentsListProps) {
|
||||
if (loading) return <div className="text-center py-12 text-gray-400">Lade Schulungen...</div>
|
||||
if (assignments.length === 0) return <div className="text-center py-12 text-gray-400">Keine Schulungen zugewiesen</div>
|
||||
|
||||
return (
|
||||
<div className="grid gap-4">
|
||||
{assignments.map(a => (
|
||||
<div key={a.id} className="bg-white border border-gray-200 rounded-lg p-5 hover:shadow-sm transition-shadow">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-3">
|
||||
<h3 className="font-semibold text-gray-900">{a.module_title || a.module_code}</h3>
|
||||
<span className={`px-2 py-0.5 text-xs rounded-full ${STATUS_COLORS[a.status]?.bg || 'bg-gray-100'} ${STATUS_COLORS[a.status]?.text || 'text-gray-700'}`}>
|
||||
{STATUS_LABELS[a.status] || a.status}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-sm text-gray-500 mt-1">
|
||||
Code: {a.module_code} | Deadline: {new Date(a.deadline).toLocaleDateString('de-DE')}
|
||||
{a.quiz_score != null && ` | Quiz: ${Math.round(a.quiz_score)}%`}
|
||||
</p>
|
||||
<div className="mt-3 w-full bg-gray-200 rounded-full h-2">
|
||||
<div
|
||||
className={`h-2 rounded-full transition-all ${a.status === 'completed' ? 'bg-green-500' : 'bg-indigo-500'}`}
|
||||
style={{ width: `${a.progress_percent}%` }}
|
||||
/>
|
||||
</div>
|
||||
<p className="text-xs text-gray-400 mt-1">{a.progress_percent}% abgeschlossen</p>
|
||||
</div>
|
||||
<div className="flex gap-2 ml-4">
|
||||
{a.status === 'pending' && (
|
||||
<button onClick={() => onStart(a)} className="px-3 py-1.5 bg-indigo-600 text-white text-sm rounded-lg hover:bg-indigo-700">Starten</button>
|
||||
)}
|
||||
{a.status === 'in_progress' && (
|
||||
<button onClick={() => onResume(a)} className="px-3 py-1.5 bg-indigo-600 text-white text-sm rounded-lg hover:bg-indigo-700">Fortsetzen</button>
|
||||
)}
|
||||
{a.status === 'completed' && a.quiz_passed && !a.certificate_id && (
|
||||
<button onClick={() => onGenerateCertificate(a.id)} disabled={certGenerating} className="px-3 py-1.5 bg-green-600 text-white text-sm rounded-lg hover:bg-green-700 disabled:opacity-50">
|
||||
{certGenerating ? 'Erstelle...' : 'Zertifikat'}
|
||||
</button>
|
||||
)}
|
||||
{a.certificate_id && (
|
||||
<button onClick={() => onDownloadPDF(a.certificate_id!)} className="px-3 py-1.5 bg-green-100 text-green-700 text-sm rounded-lg hover:bg-green-200">PDF</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
'use client'
|
||||
|
||||
import type { TrainingAssignment } from '@/lib/sdk/training/types'
|
||||
|
||||
interface CertificatesViewProps {
|
||||
certificates: TrainingAssignment[]
|
||||
onDownloadPDF: (certId: string) => void
|
||||
}
|
||||
|
||||
export function CertificatesView({ certificates, onDownloadPDF }: CertificatesViewProps) {
|
||||
if (certificates.length === 0) {
|
||||
return (
|
||||
<div className="text-center py-12 text-gray-400">
|
||||
Noch keine Zertifikate vorhanden. Schliessen Sie eine Schulung mit Quiz ab.
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||
{certificates.map(cert => (
|
||||
<div key={cert.id} className="bg-white border border-gray-200 rounded-lg p-5">
|
||||
<div className="flex items-start justify-between mb-3">
|
||||
<h3 className="font-semibold text-gray-900 text-sm">{cert.module_title}</h3>
|
||||
<span className="text-xs bg-green-100 text-green-700 px-2 py-0.5 rounded-full">Bestanden</span>
|
||||
</div>
|
||||
<div className="text-xs text-gray-500 space-y-1">
|
||||
<p>Mitarbeiter: {cert.user_name}</p>
|
||||
<p>Abschluss: {cert.completed_at ? new Date(cert.completed_at).toLocaleDateString('de-DE') : '-'}</p>
|
||||
{cert.quiz_score != null && <p>Ergebnis: {Math.round(cert.quiz_score)}%</p>}
|
||||
<p className="font-mono text-[10px] text-gray-400">ID: {cert.certificate_id?.substring(0, 12)}</p>
|
||||
</div>
|
||||
{cert.certificate_id && (
|
||||
<button
|
||||
onClick={() => onDownloadPDF(cert.certificate_id!)}
|
||||
className="mt-3 w-full px-3 py-1.5 bg-indigo-600 text-white text-sm rounded-lg hover:bg-indigo-700"
|
||||
>
|
||||
PDF herunterladen
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
'use client'
|
||||
|
||||
import type { TrainingAssignment, ModuleContent, TrainingMedia, InteractiveVideoManifest } from '@/lib/sdk/training/types'
|
||||
import { getMediaStreamURL } from '@/lib/sdk/training/api'
|
||||
import InteractiveVideoPlayer from '@/components/training/InteractiveVideoPlayer'
|
||||
|
||||
function simpleMarkdownToHtml(md: string): string {
|
||||
return md
|
||||
.replace(/^### (.+)$/gm, '<h3 class="text-lg font-semibold mt-4 mb-2">$1</h3>')
|
||||
.replace(/^## (.+)$/gm, '<h2 class="text-xl font-bold mt-6 mb-3">$1</h2>')
|
||||
.replace(/^# (.+)$/gm, '<h1 class="text-2xl font-bold mt-6 mb-3">$1</h1>')
|
||||
.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>')
|
||||
.replace(/\*(.+?)\*/g, '<em>$1</em>')
|
||||
.replace(/^- (.+)$/gm, '<li class="ml-4 list-disc">$1</li>')
|
||||
.replace(/^(\d+)\. (.+)$/gm, '<li class="ml-4 list-decimal">$2</li>')
|
||||
.replace(/\n\n/g, '<br/><br/>')
|
||||
}
|
||||
|
||||
interface ContentViewProps {
|
||||
selectedAssignment: TrainingAssignment | null
|
||||
content: ModuleContent | null
|
||||
media: TrainingMedia[]
|
||||
interactiveManifest: InteractiveVideoManifest | null
|
||||
onStartQuiz: () => void
|
||||
onAllCheckpointsPassed: () => void
|
||||
}
|
||||
|
||||
export function ContentView({
|
||||
selectedAssignment, content, media, interactiveManifest, onStartQuiz, onAllCheckpointsPassed,
|
||||
}: ContentViewProps) {
|
||||
if (!selectedAssignment) {
|
||||
return <div className="text-center py-12 text-gray-400">Waehlen Sie eine Schulung aus dem Tab "Meine Schulungen"</div>
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<h2 className="text-lg font-semibold">{selectedAssignment.module_title}</h2>
|
||||
<button onClick={onStartQuiz} className="px-4 py-2 bg-indigo-600 text-white text-sm rounded-lg hover:bg-indigo-700">Quiz starten</button>
|
||||
</div>
|
||||
|
||||
{interactiveManifest && (
|
||||
<div className="mb-6">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<p className="text-sm font-medium text-gray-700">Interaktive Video-Schulung</p>
|
||||
<span className="px-2 py-0.5 text-xs bg-purple-100 text-purple-700 rounded-full">Interaktiv</span>
|
||||
</div>
|
||||
<InteractiveVideoPlayer
|
||||
manifest={interactiveManifest}
|
||||
assignmentId={selectedAssignment.id}
|
||||
onAllCheckpointsPassed={onAllCheckpointsPassed}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{media.length > 0 && (
|
||||
<div className="mb-6 grid gap-4 md:grid-cols-2">
|
||||
{media.filter(m => m.media_type === 'audio' && m.status === 'completed').map(m => (
|
||||
<div key={m.id} className="bg-gray-50 p-4 rounded-lg">
|
||||
<p className="text-sm font-medium text-gray-700 mb-2">Audio-Schulung</p>
|
||||
<audio controls className="w-full" src={getMediaStreamURL(m.id)}>Ihr Browser unterstuetzt kein Audio.</audio>
|
||||
</div>
|
||||
))}
|
||||
{media.filter(m => m.media_type === 'video' && m.status === 'completed' && m.generated_by !== 'tts_ffmpeg_interactive').map(m => (
|
||||
<div key={m.id} className="bg-gray-50 p-4 rounded-lg">
|
||||
<p className="text-sm font-medium text-gray-700 mb-2">Video-Schulung</p>
|
||||
<video controls className="w-full rounded" src={getMediaStreamURL(m.id)}>Ihr Browser unterstuetzt kein Video.</video>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{content ? (
|
||||
<div className="bg-white border border-gray-200 rounded-lg p-6">
|
||||
<div className="prose max-w-none text-gray-800" dangerouslySetInnerHTML={{ __html: simpleMarkdownToHtml(content.content_body) }} />
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center py-8 text-gray-400">Kein Schulungsinhalt verfuegbar</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
'use client'
|
||||
|
||||
import type { TrainingAssignment, QuizSubmitResponse } from '@/lib/sdk/training/types'
|
||||
|
||||
interface QuizQuestion {
|
||||
id: string
|
||||
question: string
|
||||
options: string[]
|
||||
difficulty: string
|
||||
}
|
||||
|
||||
function formatTimer(seconds: number): string {
|
||||
const m = Math.floor(seconds / 60)
|
||||
const s = seconds % 60
|
||||
return `${m}:${s.toString().padStart(2, '0')}`
|
||||
}
|
||||
|
||||
interface QuizViewProps {
|
||||
questions: QuizQuestion[]
|
||||
answers: Record<string, number>
|
||||
quizResult: QuizSubmitResponse | null
|
||||
quizSubmitting: boolean
|
||||
quizTimer: number
|
||||
selectedAssignment: TrainingAssignment | null
|
||||
certGenerating: boolean
|
||||
onAnswerChange: (questionId: string, optionIndex: number) => void
|
||||
onSubmitQuiz: () => void
|
||||
onRetryQuiz: () => void
|
||||
onGenerateCertificate: (assignmentId: string) => void
|
||||
}
|
||||
|
||||
export function QuizView({
|
||||
questions, answers, quizResult, quizSubmitting, quizTimer,
|
||||
selectedAssignment, certGenerating, onAnswerChange, onSubmitQuiz, onRetryQuiz, onGenerateCertificate,
|
||||
}: QuizViewProps) {
|
||||
if (questions.length === 0) {
|
||||
return <div className="text-center py-12 text-gray-400">Starten Sie ein Quiz aus dem Schulungsinhalt-Tab</div>
|
||||
}
|
||||
|
||||
if (quizResult) {
|
||||
return (
|
||||
<div className="max-w-lg mx-auto">
|
||||
<div className={`text-center p-8 rounded-lg border-2 ${quizResult.passed ? 'border-green-300 bg-green-50' : 'border-red-300 bg-red-50'}`}>
|
||||
<div className="text-4xl mb-3">{quizResult.passed ? '\u2705' : '\u274C'}</div>
|
||||
<h2 className="text-2xl font-bold mb-2">{quizResult.passed ? 'Bestanden!' : 'Nicht bestanden'}</h2>
|
||||
<p className="text-lg text-gray-700">{quizResult.correct_count} von {quizResult.total_count} richtig ({Math.round(quizResult.score)}%)</p>
|
||||
<p className="text-sm text-gray-500 mt-1">Bestehensgrenze: {quizResult.threshold}% | Zeit: {formatTimer(quizTimer)}</p>
|
||||
{quizResult.passed && selectedAssignment && !selectedAssignment.certificate_id && (
|
||||
<button onClick={() => onGenerateCertificate(selectedAssignment.id)} disabled={certGenerating}
|
||||
className="mt-4 px-6 py-2 bg-green-600 text-white rounded-lg hover:bg-green-700 disabled:opacity-50">
|
||||
{certGenerating ? 'Erstelle Zertifikat...' : 'Zertifikat generieren & herunterladen'}
|
||||
</button>
|
||||
)}
|
||||
{!quizResult.passed && (
|
||||
<button onClick={onRetryQuiz} className="mt-4 px-6 py-2 bg-indigo-600 text-white rounded-lg hover:bg-indigo-700">
|
||||
Quiz erneut versuchen
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h2 className="text-lg font-semibold">Quiz — {selectedAssignment?.module_title}</h2>
|
||||
<span className="text-sm text-gray-500 font-mono bg-gray-100 px-3 py-1 rounded">{formatTimer(quizTimer)}</span>
|
||||
</div>
|
||||
<div className="space-y-6">
|
||||
{questions.map((q, idx) => (
|
||||
<div key={q.id} className="bg-white border border-gray-200 rounded-lg p-5">
|
||||
<p className="font-medium text-gray-900 mb-3">
|
||||
<span className="text-indigo-600 mr-2">Frage {idx + 1}.</span>{q.question}
|
||||
</p>
|
||||
<div className="space-y-2">
|
||||
{q.options.map((opt, oi) => (
|
||||
<label key={oi} className={`flex items-center gap-3 p-3 rounded-lg border cursor-pointer transition-colors ${
|
||||
answers[q.id] === oi ? 'border-indigo-500 bg-indigo-50' : 'border-gray-200 hover:bg-gray-50'
|
||||
}`}>
|
||||
<input type="radio" name={q.id} checked={answers[q.id] === oi}
|
||||
onChange={() => onAnswerChange(q.id, oi)} className="text-indigo-600" />
|
||||
<span className="text-sm text-gray-700">{opt}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="mt-6 flex justify-end">
|
||||
<button onClick={onSubmitQuiz} disabled={quizSubmitting || Object.keys(answers).length < questions.length}
|
||||
className="px-6 py-2 bg-indigo-600 text-white rounded-lg hover:bg-indigo-700 disabled:opacity-50">
|
||||
{quizSubmitting ? 'Wird ausgewertet...' : `Quiz abgeben (${Object.keys(answers).length}/${questions.length})`}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -4,16 +4,16 @@ import { useEffect, useState, useCallback } from 'react'
|
||||
import {
|
||||
getAssignments, getContent, getModuleMedia, getQuiz, submitQuiz,
|
||||
startAssignment, generateCertificate, listCertificates, downloadCertificatePDF,
|
||||
getMediaStreamURL, getInteractiveManifest, completeAssignment,
|
||||
getInteractiveManifest, completeAssignment,
|
||||
} from '@/lib/sdk/training/api'
|
||||
import type {
|
||||
TrainingAssignment, ModuleContent, TrainingMedia, QuizSubmitResponse,
|
||||
InteractiveVideoManifest,
|
||||
} from '@/lib/sdk/training/types'
|
||||
import {
|
||||
STATUS_LABELS, STATUS_COLORS, REGULATION_LABELS,
|
||||
} from '@/lib/sdk/training/types'
|
||||
import InteractiveVideoPlayer from '@/components/training/InteractiveVideoPlayer'
|
||||
import { AssignmentsList } from './_components/AssignmentsList'
|
||||
import { ContentView } from './_components/ContentView'
|
||||
import { QuizView } from './_components/QuizView'
|
||||
import { CertificatesView } from './_components/CertificatesView'
|
||||
|
||||
type Tab = 'assignments' | 'content' | 'quiz' | 'certificates'
|
||||
|
||||
@@ -24,64 +24,44 @@ interface QuizQuestionItem {
|
||||
difficulty: string
|
||||
}
|
||||
|
||||
const TABS: { key: Tab; label: string }[] = [
|
||||
{ key: 'assignments', label: 'Meine Schulungen' },
|
||||
{ key: 'content', label: 'Schulungsinhalt' },
|
||||
{ key: 'quiz', label: 'Quiz' },
|
||||
{ key: 'certificates', label: 'Zertifikate' },
|
||||
]
|
||||
|
||||
export default function LearnerPage() {
|
||||
const [activeTab, setActiveTab] = useState<Tab>('assignments')
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
// Assignments
|
||||
const [assignments, setAssignments] = useState<TrainingAssignment[]>([])
|
||||
|
||||
// Content
|
||||
const [selectedAssignment, setSelectedAssignment] = useState<TrainingAssignment | null>(null)
|
||||
const [content, setContent] = useState<ModuleContent | null>(null)
|
||||
const [media, setMedia] = useState<TrainingMedia[]>([])
|
||||
|
||||
// Quiz
|
||||
const [questions, setQuestions] = useState<QuizQuestionItem[]>([])
|
||||
const [answers, setAnswers] = useState<Record<string, number>>({})
|
||||
const [quizResult, setQuizResult] = useState<QuizSubmitResponse | null>(null)
|
||||
const [quizSubmitting, setQuizSubmitting] = useState(false)
|
||||
const [quizTimer, setQuizTimer] = useState(0)
|
||||
const [quizActive, setQuizActive] = useState(false)
|
||||
|
||||
// Certificates
|
||||
const [certificates, setCertificates] = useState<TrainingAssignment[]>([])
|
||||
const [certGenerating, setCertGenerating] = useState(false)
|
||||
|
||||
// Interactive Video
|
||||
const [interactiveManifest, setInteractiveManifest] = useState<InteractiveVideoManifest | null>(null)
|
||||
|
||||
// User simulation
|
||||
const [userId] = useState('00000000-0000-0000-0000-000000000001')
|
||||
|
||||
const loadAssignments = useCallback(async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const data = await getAssignments({ user_id: userId, limit: 100 })
|
||||
setAssignments(data.assignments || [])
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : 'Fehler beim Laden')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
try { const d = await getAssignments({ user_id: userId, limit: 100 }); setAssignments(d.assignments || []) }
|
||||
catch (e) { setError(e instanceof Error ? e.message : 'Fehler beim Laden') }
|
||||
finally { setLoading(false) }
|
||||
}, [userId])
|
||||
|
||||
const loadCertificates = useCallback(async () => {
|
||||
try {
|
||||
const data = await listCertificates()
|
||||
setCertificates(data.certificates || [])
|
||||
} catch {
|
||||
// Certificates may not exist yet
|
||||
}
|
||||
try { const d = await listCertificates(); setCertificates(d.certificates || []) } catch { /* may not exist */ }
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
loadAssignments()
|
||||
loadCertificates()
|
||||
}, [loadAssignments, loadCertificates])
|
||||
|
||||
// Quiz timer
|
||||
useEffect(() => { loadAssignments(); loadCertificates() }, [loadAssignments, loadCertificates])
|
||||
useEffect(() => {
|
||||
if (!quizActive) return
|
||||
const interval = setInterval(() => setQuizTimer(t => t + 1), 1000)
|
||||
@@ -91,33 +71,22 @@ export default function LearnerPage() {
|
||||
async function loadInteractiveManifest(moduleId: string, assignmentId: string) {
|
||||
try {
|
||||
const manifest = await getInteractiveManifest(moduleId, assignmentId)
|
||||
if (manifest && manifest.checkpoints && manifest.checkpoints.length > 0) {
|
||||
setInteractiveManifest(manifest)
|
||||
} else {
|
||||
setInteractiveManifest(null)
|
||||
}
|
||||
} catch {
|
||||
setInteractiveManifest(null)
|
||||
}
|
||||
setInteractiveManifest(manifest?.checkpoints?.length > 0 ? manifest : null)
|
||||
} catch { setInteractiveManifest(null) }
|
||||
}
|
||||
|
||||
async function handleStartAssignment(assignment: TrainingAssignment) {
|
||||
try {
|
||||
await startAssignment(assignment.id)
|
||||
setSelectedAssignment({ ...assignment, status: 'in_progress' })
|
||||
// Load content
|
||||
const [contentData, mediaData] = await Promise.all([
|
||||
getContent(assignment.module_id).catch(() => null),
|
||||
getModuleMedia(assignment.module_id).catch(() => ({ media: [] })),
|
||||
])
|
||||
setContent(contentData)
|
||||
setMedia(mediaData.media || [])
|
||||
setContent(contentData); setMedia(mediaData.media || [])
|
||||
await loadInteractiveManifest(assignment.module_id, assignment.id)
|
||||
setActiveTab('content')
|
||||
loadAssignments()
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : 'Fehler beim Starten')
|
||||
}
|
||||
setActiveTab('content'); loadAssignments()
|
||||
} catch (e) { setError(e instanceof Error ? e.message : 'Fehler beim Starten') }
|
||||
}
|
||||
|
||||
async function handleResumeContent(assignment: TrainingAssignment) {
|
||||
@@ -127,13 +96,10 @@ export default function LearnerPage() {
|
||||
getContent(assignment.module_id).catch(() => null),
|
||||
getModuleMedia(assignment.module_id).catch(() => ({ media: [] })),
|
||||
])
|
||||
setContent(contentData)
|
||||
setMedia(mediaData.media || [])
|
||||
setContent(contentData); setMedia(mediaData.media || [])
|
||||
await loadInteractiveManifest(assignment.module_id, assignment.id)
|
||||
setActiveTab('content')
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : 'Fehler beim Laden')
|
||||
}
|
||||
} catch (e) { setError(e instanceof Error ? e.message : 'Fehler beim Laden') }
|
||||
}
|
||||
|
||||
async function handleAllCheckpointsPassed() {
|
||||
@@ -142,47 +108,30 @@ export default function LearnerPage() {
|
||||
await completeAssignment(selectedAssignment.id)
|
||||
setSelectedAssignment({ ...selectedAssignment, status: 'completed' })
|
||||
loadAssignments()
|
||||
} catch {
|
||||
// Assignment completion may already be handled
|
||||
}
|
||||
} catch { /* already completed */ }
|
||||
}
|
||||
|
||||
async function handleStartQuiz() {
|
||||
if (!selectedAssignment) return
|
||||
try {
|
||||
const data = await getQuiz(selectedAssignment.module_id)
|
||||
setQuestions(data.questions || [])
|
||||
setAnswers({})
|
||||
setQuizResult(null)
|
||||
setQuizTimer(0)
|
||||
setQuizActive(true)
|
||||
setActiveTab('quiz')
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : 'Fehler beim Quiz-Laden')
|
||||
}
|
||||
setQuestions(data.questions || []); setAnswers({}); setQuizResult(null)
|
||||
setQuizTimer(0); setQuizActive(true); setActiveTab('quiz')
|
||||
} catch (e) { setError(e instanceof Error ? e.message : 'Fehler beim Quiz-Laden') }
|
||||
}
|
||||
|
||||
async function handleSubmitQuiz() {
|
||||
if (!selectedAssignment || questions.length === 0) return
|
||||
setQuizSubmitting(true)
|
||||
setQuizActive(false)
|
||||
setQuizSubmitting(true); setQuizActive(false)
|
||||
try {
|
||||
const answerList = questions.map(q => ({
|
||||
question_id: q.id,
|
||||
selected_index: answers[q.id] ?? -1,
|
||||
}))
|
||||
const result = await submitQuiz(selectedAssignment.module_id, {
|
||||
assignment_id: selectedAssignment.id,
|
||||
answers: answerList,
|
||||
answers: questions.map(q => ({ question_id: q.id, selected_index: answers[q.id] ?? -1 })),
|
||||
duration_seconds: quizTimer,
|
||||
})
|
||||
setQuizResult(result)
|
||||
loadAssignments()
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : 'Quiz-Abgabe fehlgeschlagen')
|
||||
} finally {
|
||||
setQuizSubmitting(false)
|
||||
}
|
||||
setQuizResult(result); loadAssignments()
|
||||
} catch (e) { setError(e instanceof Error ? e.message : 'Quiz-Abgabe fehlgeschlagen') }
|
||||
finally { setQuizSubmitting(false) }
|
||||
}
|
||||
|
||||
async function handleGenerateCertificate(assignmentId: string) {
|
||||
@@ -192,60 +141,25 @@ export default function LearnerPage() {
|
||||
if (data.certificate_id) {
|
||||
const blob = await downloadCertificatePDF(data.certificate_id)
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
const a = document.createElement('a'); a.href = url
|
||||
a.download = `zertifikat-${data.certificate_id.substring(0, 8)}.pdf`
|
||||
a.click()
|
||||
URL.revokeObjectURL(url)
|
||||
a.click(); URL.revokeObjectURL(url)
|
||||
}
|
||||
loadAssignments()
|
||||
loadCertificates()
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : 'Zertifikat-Erstellung fehlgeschlagen')
|
||||
} finally {
|
||||
setCertGenerating(false)
|
||||
}
|
||||
loadAssignments(); loadCertificates()
|
||||
} catch (e) { setError(e instanceof Error ? e.message : 'Zertifikat-Erstellung fehlgeschlagen') }
|
||||
finally { setCertGenerating(false) }
|
||||
}
|
||||
|
||||
async function handleDownloadPDF(certId: string) {
|
||||
try {
|
||||
const blob = await downloadCertificatePDF(certId)
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
const a = document.createElement('a'); a.href = url
|
||||
a.download = `zertifikat-${certId.substring(0, 8)}.pdf`
|
||||
a.click()
|
||||
URL.revokeObjectURL(url)
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : 'PDF-Download fehlgeschlagen')
|
||||
}
|
||||
a.click(); URL.revokeObjectURL(url)
|
||||
} catch (e) { setError(e instanceof Error ? e.message : 'PDF-Download fehlgeschlagen') }
|
||||
}
|
||||
|
||||
function simpleMarkdownToHtml(md: string): string {
|
||||
return md
|
||||
.replace(/^### (.+)$/gm, '<h3 class="text-lg font-semibold mt-4 mb-2">$1</h3>')
|
||||
.replace(/^## (.+)$/gm, '<h2 class="text-xl font-bold mt-6 mb-3">$1</h2>')
|
||||
.replace(/^# (.+)$/gm, '<h1 class="text-2xl font-bold mt-6 mb-3">$1</h1>')
|
||||
.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>')
|
||||
.replace(/\*(.+?)\*/g, '<em>$1</em>')
|
||||
.replace(/^- (.+)$/gm, '<li class="ml-4 list-disc">$1</li>')
|
||||
.replace(/^(\d+)\. (.+)$/gm, '<li class="ml-4 list-decimal">$2</li>')
|
||||
.replace(/\n\n/g, '<br/><br/>')
|
||||
}
|
||||
|
||||
function formatTimer(seconds: number): string {
|
||||
const m = Math.floor(seconds / 60)
|
||||
const s = seconds % 60
|
||||
return `${m}:${s.toString().padStart(2, '0')}`
|
||||
}
|
||||
|
||||
const tabs: { key: Tab; label: string }[] = [
|
||||
{ key: 'assignments', label: 'Meine Schulungen' },
|
||||
{ key: 'content', label: 'Schulungsinhalt' },
|
||||
{ key: 'quiz', label: 'Quiz' },
|
||||
{ key: 'certificates', label: 'Zertifikate' },
|
||||
]
|
||||
|
||||
return (
|
||||
<div className="max-w-7xl mx-auto p-6">
|
||||
<div className="mb-6">
|
||||
@@ -255,305 +169,47 @@ export default function LearnerPage() {
|
||||
|
||||
{error && (
|
||||
<div className="mb-4 p-3 bg-red-50 border border-red-200 rounded-lg text-red-700 text-sm">
|
||||
{error}
|
||||
<button onClick={() => setError(null)} className="ml-2 text-red-500 hover:text-red-700">x</button>
|
||||
{error}<button onClick={() => setError(null)} className="ml-2 text-red-500 hover:text-red-700">x</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Tabs */}
|
||||
<div className="border-b border-gray-200 mb-6">
|
||||
<div className="flex gap-6">
|
||||
{tabs.map(tab => (
|
||||
<button
|
||||
key={tab.key}
|
||||
onClick={() => setActiveTab(tab.key)}
|
||||
className={`pb-3 text-sm font-medium border-b-2 transition-colors ${
|
||||
activeTab === tab.key
|
||||
? 'border-indigo-500 text-indigo-600'
|
||||
: 'border-transparent text-gray-500 hover:text-gray-700'
|
||||
}`}
|
||||
>
|
||||
{TABS.map(tab => (
|
||||
<button key={tab.key} onClick={() => setActiveTab(tab.key)}
|
||||
className={`pb-3 text-sm font-medium border-b-2 transition-colors ${activeTab === tab.key ? 'border-indigo-500 text-indigo-600' : 'border-transparent text-gray-500 hover:text-gray-700'}`}>
|
||||
{tab.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tab: Meine Schulungen */}
|
||||
{activeTab === 'assignments' && (
|
||||
<div>
|
||||
{loading ? (
|
||||
<div className="text-center py-12 text-gray-400">Lade Schulungen...</div>
|
||||
) : assignments.length === 0 ? (
|
||||
<div className="text-center py-12 text-gray-400">Keine Schulungen zugewiesen</div>
|
||||
) : (
|
||||
<div className="grid gap-4">
|
||||
{assignments.map(a => (
|
||||
<div key={a.id} className="bg-white border border-gray-200 rounded-lg p-5 hover:shadow-sm transition-shadow">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-3">
|
||||
<h3 className="font-semibold text-gray-900">{a.module_title || a.module_code}</h3>
|
||||
<span className={`px-2 py-0.5 text-xs rounded-full ${STATUS_COLORS[a.status]?.bg || 'bg-gray-100'} ${STATUS_COLORS[a.status]?.text || 'text-gray-700'}`}>
|
||||
{STATUS_LABELS[a.status] || a.status}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-sm text-gray-500 mt-1">
|
||||
Code: {a.module_code} | Deadline: {new Date(a.deadline).toLocaleDateString('de-DE')}
|
||||
{a.quiz_score != null && ` | Quiz: ${Math.round(a.quiz_score)}%`}
|
||||
</p>
|
||||
{/* Progress bar */}
|
||||
<div className="mt-3 w-full bg-gray-200 rounded-full h-2">
|
||||
<div
|
||||
className={`h-2 rounded-full transition-all ${a.status === 'completed' ? 'bg-green-500' : 'bg-indigo-500'}`}
|
||||
style={{ width: `${a.progress_percent}%` }}
|
||||
/>
|
||||
</div>
|
||||
<p className="text-xs text-gray-400 mt-1">{a.progress_percent}% abgeschlossen</p>
|
||||
</div>
|
||||
<div className="flex gap-2 ml-4">
|
||||
{a.status === 'pending' && (
|
||||
<button
|
||||
onClick={() => handleStartAssignment(a)}
|
||||
className="px-3 py-1.5 bg-indigo-600 text-white text-sm rounded-lg hover:bg-indigo-700"
|
||||
>
|
||||
Starten
|
||||
</button>
|
||||
)}
|
||||
{a.status === 'in_progress' && (
|
||||
<button
|
||||
onClick={() => handleResumeContent(a)}
|
||||
className="px-3 py-1.5 bg-indigo-600 text-white text-sm rounded-lg hover:bg-indigo-700"
|
||||
>
|
||||
Fortsetzen
|
||||
</button>
|
||||
)}
|
||||
{a.status === 'completed' && a.quiz_passed && !a.certificate_id && (
|
||||
<button
|
||||
onClick={() => handleGenerateCertificate(a.id)}
|
||||
disabled={certGenerating}
|
||||
className="px-3 py-1.5 bg-green-600 text-white text-sm rounded-lg hover:bg-green-700 disabled:opacity-50"
|
||||
>
|
||||
{certGenerating ? 'Erstelle...' : 'Zertifikat'}
|
||||
</button>
|
||||
)}
|
||||
{a.certificate_id && (
|
||||
<button
|
||||
onClick={() => handleDownloadPDF(a.certificate_id!)}
|
||||
className="px-3 py-1.5 bg-green-100 text-green-700 text-sm rounded-lg hover:bg-green-200"
|
||||
>
|
||||
PDF
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<AssignmentsList
|
||||
assignments={assignments} loading={loading} certGenerating={certGenerating}
|
||||
onStart={handleStartAssignment} onResume={handleResumeContent}
|
||||
onGenerateCertificate={handleGenerateCertificate} onDownloadPDF={handleDownloadPDF}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Tab: Schulungsinhalt */}
|
||||
{activeTab === 'content' && (
|
||||
<div>
|
||||
{!selectedAssignment ? (
|
||||
<div className="text-center py-12 text-gray-400">
|
||||
Waehlen Sie eine Schulung aus dem Tab "Meine Schulungen"
|
||||
</div>
|
||||
) : (
|
||||
<div>
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<h2 className="text-lg font-semibold">{selectedAssignment.module_title}</h2>
|
||||
<button
|
||||
onClick={handleStartQuiz}
|
||||
className="px-4 py-2 bg-indigo-600 text-white text-sm rounded-lg hover:bg-indigo-700"
|
||||
>
|
||||
Quiz starten
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Interactive Video Player */}
|
||||
{interactiveManifest && selectedAssignment && (
|
||||
<div className="mb-6">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<p className="text-sm font-medium text-gray-700">Interaktive Video-Schulung</p>
|
||||
<span className="px-2 py-0.5 text-xs bg-purple-100 text-purple-700 rounded-full">Interaktiv</span>
|
||||
</div>
|
||||
<InteractiveVideoPlayer
|
||||
manifest={interactiveManifest}
|
||||
assignmentId={selectedAssignment.id}
|
||||
onAllCheckpointsPassed={handleAllCheckpointsPassed}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Media players (standard audio/video) */}
|
||||
{media.length > 0 && (
|
||||
<div className="mb-6 grid gap-4 md:grid-cols-2">
|
||||
{media.filter(m => m.media_type === 'audio' && m.status === 'completed').map(m => (
|
||||
<div key={m.id} className="bg-gray-50 p-4 rounded-lg">
|
||||
<p className="text-sm font-medium text-gray-700 mb-2">Audio-Schulung</p>
|
||||
<audio controls className="w-full" src={getMediaStreamURL(m.id)}>
|
||||
Ihr Browser unterstuetzt kein Audio.
|
||||
</audio>
|
||||
</div>
|
||||
))}
|
||||
{media.filter(m => m.media_type === 'video' && m.status === 'completed' && m.generated_by !== 'tts_ffmpeg_interactive').map(m => (
|
||||
<div key={m.id} className="bg-gray-50 p-4 rounded-lg">
|
||||
<p className="text-sm font-medium text-gray-700 mb-2">Video-Schulung</p>
|
||||
<video controls className="w-full rounded" src={getMediaStreamURL(m.id)}>
|
||||
Ihr Browser unterstuetzt kein Video.
|
||||
</video>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Content body */}
|
||||
{content ? (
|
||||
<div className="bg-white border border-gray-200 rounded-lg p-6">
|
||||
<div
|
||||
className="prose max-w-none text-gray-800"
|
||||
dangerouslySetInnerHTML={{ __html: simpleMarkdownToHtml(content.content_body) }}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center py-8 text-gray-400">Kein Schulungsinhalt verfuegbar</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<ContentView
|
||||
selectedAssignment={selectedAssignment} content={content} media={media}
|
||||
interactiveManifest={interactiveManifest} onStartQuiz={handleStartQuiz}
|
||||
onAllCheckpointsPassed={handleAllCheckpointsPassed}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Tab: Quiz */}
|
||||
{activeTab === 'quiz' && (
|
||||
<div>
|
||||
{questions.length === 0 ? (
|
||||
<div className="text-center py-12 text-gray-400">
|
||||
Starten Sie ein Quiz aus dem Schulungsinhalt-Tab
|
||||
</div>
|
||||
) : quizResult ? (
|
||||
/* Quiz Results */
|
||||
<div className="max-w-lg mx-auto">
|
||||
<div className={`text-center p-8 rounded-lg border-2 ${quizResult.passed ? 'border-green-300 bg-green-50' : 'border-red-300 bg-red-50'}`}>
|
||||
<div className="text-4xl mb-3">{quizResult.passed ? '\u2705' : '\u274C'}</div>
|
||||
<h2 className="text-2xl font-bold mb-2">
|
||||
{quizResult.passed ? 'Bestanden!' : 'Nicht bestanden'}
|
||||
</h2>
|
||||
<p className="text-lg text-gray-700">
|
||||
{quizResult.correct_count} von {quizResult.total_count} richtig ({Math.round(quizResult.score)}%)
|
||||
</p>
|
||||
<p className="text-sm text-gray-500 mt-1">
|
||||
Bestehensgrenze: {quizResult.threshold}% | Zeit: {formatTimer(quizTimer)}
|
||||
</p>
|
||||
{quizResult.passed && selectedAssignment && !selectedAssignment.certificate_id && (
|
||||
<button
|
||||
onClick={() => handleGenerateCertificate(selectedAssignment.id)}
|
||||
disabled={certGenerating}
|
||||
className="mt-4 px-6 py-2 bg-green-600 text-white rounded-lg hover:bg-green-700 disabled:opacity-50"
|
||||
>
|
||||
{certGenerating ? 'Erstelle Zertifikat...' : 'Zertifikat generieren & herunterladen'}
|
||||
</button>
|
||||
)}
|
||||
{!quizResult.passed && (
|
||||
<button
|
||||
onClick={handleStartQuiz}
|
||||
className="mt-4 px-6 py-2 bg-indigo-600 text-white rounded-lg hover:bg-indigo-700"
|
||||
>
|
||||
Quiz erneut versuchen
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
/* Quiz Questions */
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h2 className="text-lg font-semibold">Quiz — {selectedAssignment?.module_title}</h2>
|
||||
<span className="text-sm text-gray-500 font-mono bg-gray-100 px-3 py-1 rounded">
|
||||
{formatTimer(quizTimer)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="space-y-6">
|
||||
{questions.map((q, idx) => (
|
||||
<div key={q.id} className="bg-white border border-gray-200 rounded-lg p-5">
|
||||
<p className="font-medium text-gray-900 mb-3">
|
||||
<span className="text-indigo-600 mr-2">Frage {idx + 1}.</span>
|
||||
{q.question}
|
||||
</p>
|
||||
<div className="space-y-2">
|
||||
{q.options.map((opt, oi) => (
|
||||
<label
|
||||
key={oi}
|
||||
className={`flex items-center gap-3 p-3 rounded-lg border cursor-pointer transition-colors ${
|
||||
answers[q.id] === oi
|
||||
? 'border-indigo-500 bg-indigo-50'
|
||||
: 'border-gray-200 hover:bg-gray-50'
|
||||
}`}
|
||||
>
|
||||
<input
|
||||
type="radio"
|
||||
name={q.id}
|
||||
checked={answers[q.id] === oi}
|
||||
onChange={() => setAnswers(prev => ({ ...prev, [q.id]: oi }))}
|
||||
className="text-indigo-600"
|
||||
/>
|
||||
<span className="text-sm text-gray-700">{opt}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="mt-6 flex justify-end">
|
||||
<button
|
||||
onClick={handleSubmitQuiz}
|
||||
disabled={quizSubmitting || Object.keys(answers).length < questions.length}
|
||||
className="px-6 py-2 bg-indigo-600 text-white rounded-lg hover:bg-indigo-700 disabled:opacity-50"
|
||||
>
|
||||
{quizSubmitting ? 'Wird ausgewertet...' : `Quiz abgeben (${Object.keys(answers).length}/${questions.length})`}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<QuizView
|
||||
questions={questions} answers={answers} quizResult={quizResult}
|
||||
quizSubmitting={quizSubmitting} quizTimer={quizTimer} selectedAssignment={selectedAssignment}
|
||||
certGenerating={certGenerating}
|
||||
onAnswerChange={(qId, oi) => setAnswers(prev => ({ ...prev, [qId]: oi }))}
|
||||
onSubmitQuiz={handleSubmitQuiz} onRetryQuiz={handleStartQuiz}
|
||||
onGenerateCertificate={handleGenerateCertificate}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Tab: Zertifikate */}
|
||||
{activeTab === 'certificates' && (
|
||||
<div>
|
||||
{certificates.length === 0 ? (
|
||||
<div className="text-center py-12 text-gray-400">
|
||||
Noch keine Zertifikate vorhanden. Schliessen Sie eine Schulung mit Quiz ab.
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||
{certificates.map(cert => (
|
||||
<div key={cert.id} className="bg-white border border-gray-200 rounded-lg p-5">
|
||||
<div className="flex items-start justify-between mb-3">
|
||||
<h3 className="font-semibold text-gray-900 text-sm">{cert.module_title}</h3>
|
||||
<span className="text-xs bg-green-100 text-green-700 px-2 py-0.5 rounded-full">Bestanden</span>
|
||||
</div>
|
||||
<div className="text-xs text-gray-500 space-y-1">
|
||||
<p>Mitarbeiter: {cert.user_name}</p>
|
||||
<p>Abschluss: {cert.completed_at ? new Date(cert.completed_at).toLocaleDateString('de-DE') : '-'}</p>
|
||||
{cert.quiz_score != null && <p>Ergebnis: {Math.round(cert.quiz_score)}%</p>}
|
||||
<p className="font-mono text-[10px] text-gray-400">ID: {cert.certificate_id?.substring(0, 12)}</p>
|
||||
</div>
|
||||
{cert.certificate_id && (
|
||||
<button
|
||||
onClick={() => handleDownloadPDF(cert.certificate_id!)}
|
||||
className="mt-3 w-full px-3 py-1.5 bg-indigo-600 text-white text-sm rounded-lg hover:bg-indigo-700"
|
||||
>
|
||||
PDF herunterladen
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<CertificatesView certificates={certificates} onDownloadPDF={handleDownloadPDF} />
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user