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>
217 lines
9.1 KiB
TypeScript
217 lines
9.1 KiB
TypeScript
'use client'
|
|
|
|
import { useEffect, useState, useCallback } from 'react'
|
|
import {
|
|
getAssignments, getContent, getModuleMedia, getQuiz, submitQuiz,
|
|
startAssignment, generateCertificate, listCertificates, downloadCertificatePDF,
|
|
getInteractiveManifest, completeAssignment,
|
|
} from '@/lib/sdk/training/api'
|
|
import type {
|
|
TrainingAssignment, ModuleContent, TrainingMedia, QuizSubmitResponse,
|
|
InteractiveVideoManifest,
|
|
} from '@/lib/sdk/training/types'
|
|
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'
|
|
|
|
interface QuizQuestionItem {
|
|
id: string
|
|
question: string
|
|
options: string[]
|
|
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)
|
|
const [assignments, setAssignments] = useState<TrainingAssignment[]>([])
|
|
const [selectedAssignment, setSelectedAssignment] = useState<TrainingAssignment | null>(null)
|
|
const [content, setContent] = useState<ModuleContent | null>(null)
|
|
const [media, setMedia] = useState<TrainingMedia[]>([])
|
|
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)
|
|
const [certificates, setCertificates] = useState<TrainingAssignment[]>([])
|
|
const [certGenerating, setCertGenerating] = useState(false)
|
|
const [interactiveManifest, setInteractiveManifest] = useState<InteractiveVideoManifest | null>(null)
|
|
const [userId] = useState('00000000-0000-0000-0000-000000000001')
|
|
|
|
const loadAssignments = useCallback(async () => {
|
|
setLoading(true)
|
|
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 d = await listCertificates(); setCertificates(d.certificates || []) } catch { /* may not exist */ }
|
|
}, [])
|
|
|
|
useEffect(() => { loadAssignments(); loadCertificates() }, [loadAssignments, loadCertificates])
|
|
useEffect(() => {
|
|
if (!quizActive) return
|
|
const interval = setInterval(() => setQuizTimer(t => t + 1), 1000)
|
|
return () => clearInterval(interval)
|
|
}, [quizActive])
|
|
|
|
async function loadInteractiveManifest(moduleId: string, assignmentId: string) {
|
|
try {
|
|
const manifest = await getInteractiveManifest(moduleId, assignmentId)
|
|
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' })
|
|
const [contentData, mediaData] = await Promise.all([
|
|
getContent(assignment.module_id).catch(() => null),
|
|
getModuleMedia(assignment.module_id).catch(() => ({ 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') }
|
|
}
|
|
|
|
async function handleResumeContent(assignment: TrainingAssignment) {
|
|
setSelectedAssignment(assignment)
|
|
try {
|
|
const [contentData, mediaData] = await Promise.all([
|
|
getContent(assignment.module_id).catch(() => null),
|
|
getModuleMedia(assignment.module_id).catch(() => ({ 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') }
|
|
}
|
|
|
|
async function handleAllCheckpointsPassed() {
|
|
if (!selectedAssignment) return
|
|
try {
|
|
await completeAssignment(selectedAssignment.id)
|
|
setSelectedAssignment({ ...selectedAssignment, status: 'completed' })
|
|
loadAssignments()
|
|
} 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') }
|
|
}
|
|
|
|
async function handleSubmitQuiz() {
|
|
if (!selectedAssignment || questions.length === 0) return
|
|
setQuizSubmitting(true); setQuizActive(false)
|
|
try {
|
|
const result = await submitQuiz(selectedAssignment.module_id, {
|
|
assignment_id: selectedAssignment.id,
|
|
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) }
|
|
}
|
|
|
|
async function handleGenerateCertificate(assignmentId: string) {
|
|
setCertGenerating(true)
|
|
try {
|
|
const data = await generateCertificate(assignmentId)
|
|
if (data.certificate_id) {
|
|
const blob = await downloadCertificatePDF(data.certificate_id)
|
|
const url = URL.createObjectURL(blob)
|
|
const a = document.createElement('a'); a.href = url
|
|
a.download = `zertifikat-${data.certificate_id.substring(0, 8)}.pdf`
|
|
a.click(); URL.revokeObjectURL(url)
|
|
}
|
|
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
|
|
a.download = `zertifikat-${certId.substring(0, 8)}.pdf`
|
|
a.click(); URL.revokeObjectURL(url)
|
|
} catch (e) { setError(e instanceof Error ? e.message : 'PDF-Download fehlgeschlagen') }
|
|
}
|
|
|
|
return (
|
|
<div className="max-w-7xl mx-auto p-6">
|
|
<div className="mb-6">
|
|
<h1 className="text-2xl font-bold text-gray-900">Learner Portal</h1>
|
|
<p className="text-gray-500 mt-1">Absolvieren Sie Ihre Compliance-Schulungen</p>
|
|
</div>
|
|
|
|
{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>
|
|
</div>
|
|
)}
|
|
|
|
<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'}`}>
|
|
{tab.label}
|
|
</button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
|
|
{activeTab === 'assignments' && (
|
|
<AssignmentsList
|
|
assignments={assignments} loading={loading} certGenerating={certGenerating}
|
|
onStart={handleStartAssignment} onResume={handleResumeContent}
|
|
onGenerateCertificate={handleGenerateCertificate} onDownloadPDF={handleDownloadPDF}
|
|
/>
|
|
)}
|
|
{activeTab === 'content' && (
|
|
<ContentView
|
|
selectedAssignment={selectedAssignment} content={content} media={media}
|
|
interactiveManifest={interactiveManifest} onStartQuiz={handleStartQuiz}
|
|
onAllCheckpointsPassed={handleAllCheckpointsPassed}
|
|
/>
|
|
)}
|
|
{activeTab === 'quiz' && (
|
|
<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}
|
|
/>
|
|
)}
|
|
{activeTab === 'certificates' && (
|
|
<CertificatesView certificates={certificates} onDownloadPDF={handleDownloadPDF} />
|
|
)}
|
|
</div>
|
|
)
|
|
}
|