Files
breakpilot-compliance/admin-compliance/app/sdk/training/learner/page.tsx
Sharang Parnerkar e3a1822883 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>
2026-04-17 12:26:39 +02:00

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>
)
}