feat(training+controls): interactive video pipeline, training blocks, control generator, CE libraries
Some checks failed
CI/CD / go-lint (push) Has been skipped
CI/CD / python-lint (push) Has been skipped
CI/CD / nodejs-lint (push) Has been skipped
CI/CD / test-go-ai-compliance (push) Failing after 37s
CI/CD / test-python-backend-compliance (push) Successful in 39s
CI/CD / test-python-document-crawler (push) Successful in 26s
CI/CD / test-python-dsms-gateway (push) Successful in 23s
CI/CD / validate-canonical-controls (push) Successful in 12s
CI/CD / Deploy (push) Has been skipped

Interactive Training Videos (CP-TRAIN):
- DB migration 022: training_checkpoints + checkpoint_progress tables
- NarratorScript generation via Anthropic (AI Teacher persona, German)
- TTS batch synthesis + interactive video pipeline (slides + checkpoint slides + FFmpeg)
- 4 new API endpoints: generate-interactive, interactive-manifest, checkpoint submit, checkpoint progress
- InteractiveVideoPlayer component (HTML5 Video, quiz overlay, seek protection, progress tracking)
- Learner portal integration with automatic completion on all checkpoints passed
- 30 new tests (handler validation + grading logic + manifest/progress + seek protection)

Training Blocks:
- Block generator, block store, block config CRUD + preview/generate endpoints
- Migration 021: training_blocks schema

Control Generator + Canonical Library:
- Control generator routes + service enhancements
- Canonical control library helpers, sidebar entry
- Citation backfill service + tests
- CE libraries data (hazard, protection, evidence, lifecycle, components)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Benjamin Admin
2026-03-16 21:41:48 +01:00
parent d2133dbfa2
commit 4f6bc8f6f6
50 changed files with 17299 additions and 198 deletions

View File

@@ -0,0 +1,560 @@
'use client'
import { useEffect, useState, useCallback } from 'react'
import {
getAssignments, getContent, getModuleMedia, getQuiz, submitQuiz,
startAssignment, generateCertificate, listCertificates, downloadCertificatePDF,
getMediaStreamURL, 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'
type Tab = 'assignments' | 'content' | 'quiz' | 'certificates'
interface QuizQuestionItem {
id: string
question: string
options: string[]
difficulty: string
}
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)
}
}, [userId])
const loadCertificates = useCallback(async () => {
try {
const data = await listCertificates()
setCertificates(data.certificates || [])
} catch {
// Certificates may not exist yet
}
}, [])
useEffect(() => {
loadAssignments()
loadCertificates()
}, [loadAssignments, loadCertificates])
// Quiz timer
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)
if (manifest && manifest.checkpoints && manifest.checkpoints.length > 0) {
setInteractiveManifest(manifest)
} else {
setInteractiveManifest(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 || [])
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 {
// Assignment completion may already be handled
}
}
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 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,
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')
}
}
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">
<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>
)}
{/* 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'
}`}
>
{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>
)}
{/* Tab: Schulungsinhalt */}
{activeTab === 'content' && (
<div>
{!selectedAssignment ? (
<div className="text-center py-12 text-gray-400">
Waehlen Sie eine Schulung aus dem Tab &quot;Meine Schulungen&quot;
</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>
)}
{/* 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>
)}
{/* 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>
)}
</div>
)
}

View File

@@ -9,14 +9,18 @@ import {
createModule, updateModule, deleteModule,
deleteMatrixEntry, setMatrixEntry,
startAssignment, completeAssignment, updateAssignment,
listBlockConfigs, createBlockConfig, deleteBlockConfig,
previewBlock, generateBlock, getCanonicalMeta,
generateInteractiveVideo,
} from '@/lib/sdk/training/api'
import type {
TrainingModule, TrainingAssignment,
MatrixResponse, TrainingStats, DeadlineInfo, AuditLogEntry, ModuleContent, TrainingMedia,
TrainingBlockConfig, CanonicalControlMeta, BlockPreview, BlockGenerateResult,
} from '@/lib/sdk/training/types'
import {
REGULATION_LABELS, REGULATION_COLORS, FREQUENCY_LABELS,
STATUS_LABELS, STATUS_COLORS, ROLE_LABELS, ALL_ROLES,
STATUS_LABELS, STATUS_COLORS, ROLE_LABELS, ALL_ROLES, TARGET_AUDIENCE_LABELS,
} from '@/lib/sdk/training/types'
import AudioPlayer from '@/components/training/AudioPlayer'
import VideoPlayer from '@/components/training/VideoPlayer'
@@ -41,6 +45,7 @@ export default function TrainingPage() {
const [bulkGenerating, setBulkGenerating] = useState(false)
const [bulkResult, setBulkResult] = useState<{ generated: number; skipped: number; errors: string[] } | null>(null)
const [moduleMedia, setModuleMedia] = useState<TrainingMedia[]>([])
const [interactiveGenerating, setInteractiveGenerating] = useState(false)
const [statusFilter, setStatusFilter] = useState<string>('')
const [regulationFilter, setRegulationFilter] = useState<string>('')
@@ -52,6 +57,15 @@ export default function TrainingPage() {
const [selectedAssignment, setSelectedAssignment] = useState<TrainingAssignment | null>(null)
const [escalationResult, setEscalationResult] = useState<{ total_checked: number; escalated: number } | null>(null)
// Block (Controls → Module) state
const [blocks, setBlocks] = useState<TrainingBlockConfig[]>([])
const [canonicalMeta, setCanonicalMeta] = useState<CanonicalControlMeta | null>(null)
const [showBlockCreate, setShowBlockCreate] = useState(false)
const [blockPreview, setBlockPreview] = useState<BlockPreview | null>(null)
const [blockPreviewId, setBlockPreviewId] = useState<string>('')
const [blockGenerating, setBlockGenerating] = useState(false)
const [blockResult, setBlockResult] = useState<BlockGenerateResult | null>(null)
useEffect(() => {
loadData()
}, [])
@@ -66,13 +80,15 @@ export default function TrainingPage() {
setLoading(true)
setError(null)
try {
const [statsRes, modulesRes, matrixRes, assignmentsRes, deadlinesRes, auditRes] = await Promise.allSettled([
const [statsRes, modulesRes, matrixRes, assignmentsRes, deadlinesRes, auditRes, blocksRes, metaRes] = await Promise.allSettled([
getStats(),
getModules(),
getMatrix(),
getAssignments({ limit: 50 }),
getDeadlines(10),
getAuditLog({ limit: 30 }),
listBlockConfigs(),
getCanonicalMeta(),
])
if (statsRes.status === 'fulfilled') setStats(statsRes.value)
@@ -81,6 +97,8 @@ export default function TrainingPage() {
if (assignmentsRes.status === 'fulfilled') setAssignments(assignmentsRes.value.assignments)
if (deadlinesRes.status === 'fulfilled') setDeadlines(deadlinesRes.value.deadlines)
if (auditRes.status === 'fulfilled') setAuditLog(auditRes.value.entries)
if (blocksRes.status === 'fulfilled') setBlocks(blocksRes.value.blocks)
if (metaRes.status === 'fulfilled') setCanonicalMeta(metaRes.value)
} catch (e) {
setError(e instanceof Error ? e.message : 'Fehler beim Laden')
} finally {
@@ -114,6 +132,19 @@ export default function TrainingPage() {
}
}
async function handleGenerateInteractiveVideo() {
if (!selectedModuleId) return
setInteractiveGenerating(true)
try {
await generateInteractiveVideo(selectedModuleId)
await loadModuleMedia(selectedModuleId)
} catch (e) {
setError(e instanceof Error ? e.message : 'Fehler bei der interaktiven Video-Generierung')
} finally {
setInteractiveGenerating(false)
}
}
async function handlePublishContent(contentId: string) {
try {
await publishContent(contentId)
@@ -190,6 +221,59 @@ export default function TrainingPage() {
}
}
// Block handlers
async function handleCreateBlock(data: {
name: string; description?: string; domain_filter?: string; category_filter?: string;
severity_filter?: string; target_audience_filter?: string; regulation_area: string;
module_code_prefix: string; max_controls_per_module?: number;
}) {
try {
await createBlockConfig(data)
setShowBlockCreate(false)
const res = await listBlockConfigs()
setBlocks(res.blocks)
} catch (e) {
setError(e instanceof Error ? e.message : 'Fehler beim Erstellen')
}
}
async function handleDeleteBlock(id: string) {
if (!confirm('Block-Konfiguration wirklich loeschen?')) return
try {
await deleteBlockConfig(id)
const res = await listBlockConfigs()
setBlocks(res.blocks)
} catch (e) {
setError(e instanceof Error ? e.message : 'Fehler beim Loeschen')
}
}
async function handlePreviewBlock(id: string) {
setBlockPreviewId(id)
setBlockPreview(null)
setBlockResult(null)
try {
const preview = await previewBlock(id)
setBlockPreview(preview)
} catch (e) {
setError(e instanceof Error ? e.message : 'Fehler beim Preview')
}
}
async function handleGenerateBlock(id: string) {
setBlockGenerating(true)
setBlockResult(null)
try {
const result = await generateBlock(id, { language: 'de', auto_matrix: true })
setBlockResult(result)
await loadData()
} catch (e) {
setError(e instanceof Error ? e.message : 'Fehler bei der Block-Generierung')
} finally {
setBlockGenerating(false)
}
}
const tabs: { id: Tab; label: string }[] = [
{ id: 'overview', label: 'Uebersicht' },
{ id: 'modules', label: 'Modulkatalog' },
@@ -521,6 +605,228 @@ export default function TrainingPage() {
{activeTab === 'content' && (
<div className="space-y-6">
{/* Training Blocks — Controls → Schulungsmodule */}
<div className="bg-white border rounded-lg p-4">
<div className="flex items-center justify-between mb-3">
<div>
<h3 className="text-sm font-medium text-gray-700">Schulungsbloecke aus Controls</h3>
<p className="text-xs text-gray-500">
Canonical Controls nach Kriterien filtern und automatisch Schulungsmodule generieren
{canonicalMeta && <span className="ml-2 text-gray-400">({canonicalMeta.total} Controls verfuegbar)</span>}
</p>
</div>
<button
onClick={() => setShowBlockCreate(true)}
className="px-3 py-1.5 text-xs bg-blue-600 text-white rounded-lg hover:bg-blue-700"
>
+ Neuen Block erstellen
</button>
</div>
{/* Block list */}
{blocks.length > 0 ? (
<div className="border rounded-lg overflow-hidden">
<table className="w-full text-sm">
<thead className="bg-gray-50">
<tr>
<th className="px-3 py-2 text-left font-medium text-gray-600">Name</th>
<th className="px-3 py-2 text-left font-medium text-gray-600">Domain</th>
<th className="px-3 py-2 text-left font-medium text-gray-600">Zielgruppe</th>
<th className="px-3 py-2 text-left font-medium text-gray-600">Severity</th>
<th className="px-3 py-2 text-left font-medium text-gray-600">Prefix</th>
<th className="px-3 py-2 text-left font-medium text-gray-600">Letzte Generierung</th>
<th className="px-3 py-2 text-right font-medium text-gray-600">Aktionen</th>
</tr>
</thead>
<tbody className="divide-y">
{blocks.map(block => (
<tr key={block.id} className="hover:bg-gray-50">
<td className="px-3 py-2">
<div className="font-medium text-gray-900">{block.name}</div>
{block.description && <div className="text-xs text-gray-500">{block.description}</div>}
</td>
<td className="px-3 py-2 text-gray-600">{block.domain_filter || 'Alle'}</td>
<td className="px-3 py-2 text-gray-600">{block.target_audience_filter ? (TARGET_AUDIENCE_LABELS[block.target_audience_filter] || block.target_audience_filter) : 'Alle'}</td>
<td className="px-3 py-2 text-gray-600">{block.severity_filter || 'Alle'}</td>
<td className="px-3 py-2"><code className="text-xs bg-gray-100 px-1.5 py-0.5 rounded">{block.module_code_prefix}</code></td>
<td className="px-3 py-2 text-gray-500 text-xs">{block.last_generated_at ? new Date(block.last_generated_at).toLocaleString('de-DE') : 'Noch nie'}</td>
<td className="px-3 py-2 text-right">
<div className="flex gap-1 justify-end">
<button
onClick={() => handlePreviewBlock(block.id)}
className="px-2 py-1 text-xs bg-gray-100 text-gray-700 rounded hover:bg-gray-200"
>
Preview
</button>
<button
onClick={() => handleGenerateBlock(block.id)}
disabled={blockGenerating}
className="px-2 py-1 text-xs bg-green-600 text-white rounded hover:bg-green-700 disabled:opacity-50"
>
{blockGenerating ? 'Generiert...' : 'Generieren'}
</button>
<button
onClick={() => handleDeleteBlock(block.id)}
className="px-2 py-1 text-xs bg-red-100 text-red-700 rounded hover:bg-red-200"
>
Loeschen
</button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
) : (
<div className="text-center py-8 text-gray-500 text-sm">
Noch keine Schulungsbloecke konfiguriert. Erstelle einen Block, um Controls automatisch in Module umzuwandeln.
</div>
)}
{/* Preview result */}
{blockPreview && blockPreviewId && (
<div className="mt-4 p-4 bg-blue-50 border border-blue-200 rounded-lg">
<h4 className="text-sm font-medium text-blue-800 mb-2">Preview: {blocks.find(b => b.id === blockPreviewId)?.name}</h4>
<div className="flex gap-6 text-sm mb-3">
<span className="text-blue-700">Controls: <strong>{blockPreview.control_count}</strong></span>
<span className="text-blue-700">Module: <strong>{blockPreview.module_count}</strong></span>
<span className="text-blue-700">Rollen: <strong>{blockPreview.proposed_roles.map(r => ROLE_LABELS[r] || r).join(', ')}</strong></span>
</div>
{blockPreview.controls.length > 0 && (
<details className="text-xs">
<summary className="cursor-pointer text-blue-600 hover:text-blue-800">Passende Controls anzeigen ({blockPreview.control_count})</summary>
<div className="mt-2 max-h-48 overflow-y-auto">
{blockPreview.controls.slice(0, 50).map(ctrl => (
<div key={ctrl.control_id} className="flex gap-2 py-1 border-b border-blue-100">
<code className="text-xs bg-blue-100 px-1 rounded shrink-0">{ctrl.control_id}</code>
<span className="text-gray-700 truncate">{ctrl.title}</span>
<span className={`text-xs px-1.5 rounded shrink-0 ${ctrl.severity === 'critical' ? 'bg-red-100 text-red-700' : ctrl.severity === 'high' ? 'bg-orange-100 text-orange-700' : 'bg-gray-100 text-gray-600'}`}>{ctrl.severity}</span>
</div>
))}
{blockPreview.control_count > 50 && <div className="text-gray-500 py-1">... und {blockPreview.control_count - 50} weitere</div>}
</div>
</details>
)}
</div>
)}
{/* Generate result */}
{blockResult && (
<div className="mt-4 p-4 bg-green-50 border border-green-200 rounded-lg">
<h4 className="text-sm font-medium text-green-800 mb-2">Generierung abgeschlossen</h4>
<div className="flex gap-6 text-sm">
<span className="text-green-700">Module erstellt: <strong>{blockResult.modules_created}</strong></span>
<span className="text-green-700">Controls verknuepft: <strong>{blockResult.controls_linked}</strong></span>
<span className="text-green-700">Matrix-Eintraege: <strong>{blockResult.matrix_entries_created}</strong></span>
<span className="text-green-700">Content generiert: <strong>{blockResult.content_generated}</strong></span>
</div>
{blockResult.errors && blockResult.errors.length > 0 && (
<div className="mt-2 text-xs text-red-600">
{blockResult.errors.map((err, i) => <div key={i}>{err}</div>)}
</div>
)}
</div>
)}
</div>
{/* Block Create Modal */}
{showBlockCreate && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40">
<div className="bg-white rounded-xl shadow-xl w-full max-w-lg p-6">
<h3 className="text-lg font-semibold mb-4">Neuen Schulungsblock erstellen</h3>
<form onSubmit={e => {
e.preventDefault()
const fd = new FormData(e.currentTarget)
handleCreateBlock({
name: fd.get('name') as string,
description: fd.get('description') as string || undefined,
domain_filter: fd.get('domain_filter') as string || undefined,
category_filter: fd.get('category_filter') as string || undefined,
severity_filter: fd.get('severity_filter') as string || undefined,
target_audience_filter: fd.get('target_audience_filter') as string || undefined,
regulation_area: fd.get('regulation_area') as string,
module_code_prefix: fd.get('module_code_prefix') as string,
max_controls_per_module: parseInt(fd.get('max_controls_per_module') as string) || 20,
})
}} className="space-y-3">
<div>
<label className="text-xs text-gray-600 block mb-1">Name *</label>
<input name="name" required className="w-full px-3 py-2 text-sm border rounded-lg" placeholder="z.B. Authentifizierung fuer Geschaeftsfuehrung" />
</div>
<div>
<label className="text-xs text-gray-600 block mb-1">Beschreibung</label>
<textarea name="description" className="w-full px-3 py-2 text-sm border rounded-lg" rows={2} />
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<label className="text-xs text-gray-600 block mb-1">Domain-Filter</label>
<select name="domain_filter" className="w-full px-3 py-2 text-sm border rounded-lg bg-white">
<option value="">Alle Domains</option>
{canonicalMeta?.domains.map(d => (
<option key={d.domain} value={d.domain}>{d.domain} ({d.count})</option>
))}
</select>
</div>
<div>
<label className="text-xs text-gray-600 block mb-1">Kategorie-Filter</label>
<select name="category_filter" className="w-full px-3 py-2 text-sm border rounded-lg bg-white">
<option value="">Alle Kategorien</option>
{canonicalMeta?.categories.filter(c => c.category !== 'uncategorized').map(c => (
<option key={c.category} value={c.category}>{c.category} ({c.count})</option>
))}
</select>
</div>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<label className="text-xs text-gray-600 block mb-1">Zielgruppe</label>
<select name="target_audience_filter" className="w-full px-3 py-2 text-sm border rounded-lg bg-white">
<option value="">Alle Zielgruppen</option>
{canonicalMeta?.audiences.filter(a => a.audience !== 'unset').map(a => (
<option key={a.audience} value={a.audience}>{TARGET_AUDIENCE_LABELS[a.audience] || a.audience} ({a.count})</option>
))}
</select>
</div>
<div>
<label className="text-xs text-gray-600 block mb-1">Severity</label>
<select name="severity_filter" className="w-full px-3 py-2 text-sm border rounded-lg bg-white">
<option value="">Alle</option>
<option value="critical">Critical</option>
<option value="high">High</option>
<option value="medium">Medium</option>
<option value="low">Low</option>
</select>
</div>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<label className="text-xs text-gray-600 block mb-1">Regulierungsbereich *</label>
<select name="regulation_area" required className="w-full px-3 py-2 text-sm border rounded-lg bg-white">
{Object.entries(REGULATION_LABELS).map(([k, v]) => (
<option key={k} value={k}>{v}</option>
))}
</select>
</div>
<div>
<label className="text-xs text-gray-600 block mb-1">Modul-Code-Prefix *</label>
<input name="module_code_prefix" required className="w-full px-3 py-2 text-sm border rounded-lg" placeholder="z.B. CB-AUTH" />
</div>
</div>
<div>
<label className="text-xs text-gray-600 block mb-1">Max. Controls pro Modul</label>
<input name="max_controls_per_module" type="number" defaultValue={20} min={1} max={50} className="w-full px-3 py-2 text-sm border rounded-lg" />
</div>
<div className="flex gap-3 pt-2">
<button type="submit" className="px-4 py-2 text-sm bg-blue-600 text-white rounded-lg hover:bg-blue-700">Erstellen</button>
<button type="button" onClick={() => setShowBlockCreate(false)} className="px-4 py-2 text-sm bg-gray-100 text-gray-700 rounded-lg hover:bg-gray-200">Abbrechen</button>
</div>
</form>
</div>
</div>
)}
{/* Bulk Generation */}
<div className="bg-white border rounded-lg p-4">
<h3 className="text-sm font-medium text-gray-700 mb-3">Bulk-Generierung</h3>
@@ -620,6 +926,35 @@ export default function TrainingPage() {
/>
)}
{/* Interactive Video */}
{selectedModuleId && generatedContent?.is_published && (
<div className="bg-white border rounded-lg p-4">
<div className="flex items-center justify-between mb-3">
<div>
<h3 className="text-sm font-medium text-gray-700">Interaktives Video</h3>
<p className="text-xs text-gray-500">Video mit Narrator-Persona und Checkpoint-Quizzes</p>
</div>
{moduleMedia.some(m => m.media_type === 'interactive_video' && m.status === 'completed') ? (
<span className="px-3 py-1.5 text-xs bg-purple-100 text-purple-700 rounded-full">Interaktiv erstellt</span>
) : (
<button
onClick={handleGenerateInteractiveVideo}
disabled={interactiveGenerating}
className="px-4 py-2 text-sm bg-purple-600 text-white rounded-lg hover:bg-purple-700 disabled:opacity-50"
>
{interactiveGenerating ? 'Generiere interaktives Video...' : 'Interaktives Video generieren'}
</button>
)}
</div>
{moduleMedia.filter(m => m.media_type === 'interactive_video' && m.status === 'completed').map(m => (
<div key={m.id} className="text-xs text-gray-500 space-y-1 bg-gray-50 rounded p-3">
<p>Dauer: {Math.round(m.duration_seconds / 60)} Min | Groesse: {(m.file_size_bytes / 1024 / 1024).toFixed(1)} MB</p>
<p>Generiert: {new Date(m.created_at).toLocaleString('de-DE')}</p>
</div>
))}
</div>
)}
{/* Script Preview */}
{selectedModuleId && generatedContent?.is_published && (
<ScriptPreview moduleId={selectedModuleId} />