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
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:
@@ -26,7 +26,7 @@ export async function GET(request: NextRequest) {
|
||||
|
||||
case 'controls': {
|
||||
const controlParams = new URLSearchParams()
|
||||
const passthrough = ['severity', 'domain', 'verification_method', 'category',
|
||||
const passthrough = ['severity', 'domain', 'release_state', 'verification_method', 'category',
|
||||
'target_audience', 'source', 'search', 'sort', 'order', 'limit', 'offset']
|
||||
for (const key of passthrough) {
|
||||
const val = searchParams.get(key)
|
||||
@@ -39,7 +39,7 @@ export async function GET(request: NextRequest) {
|
||||
|
||||
case 'controls-count': {
|
||||
const countParams = new URLSearchParams()
|
||||
const countPassthrough = ['severity', 'domain', 'verification_method', 'category',
|
||||
const countPassthrough = ['severity', 'domain', 'release_state', 'verification_method', 'category',
|
||||
'target_audience', 'source', 'search']
|
||||
for (const key of countPassthrough) {
|
||||
const val = searchParams.get(key)
|
||||
@@ -175,6 +175,8 @@ export async function POST(request: NextRequest) {
|
||||
return NextResponse.json({ error: 'Missing control id' }, { status: 400 })
|
||||
}
|
||||
backendPath = `/api/compliance/v1/canonical/generate/review/${encodeURIComponent(controlId)}`
|
||||
} else if (endpoint === 'bulk-review') {
|
||||
backendPath = '/api/compliance/v1/canonical/generate/bulk-review'
|
||||
} else if (endpoint === 'blocked-sources-cleanup') {
|
||||
backendPath = '/api/compliance/v1/canonical/blocked-sources/cleanup'
|
||||
} else if (endpoint === 'similarity-check') {
|
||||
|
||||
@@ -53,7 +53,18 @@ async function proxyRequest(
|
||||
}
|
||||
}
|
||||
|
||||
const response = await fetch(url, fetchOptions)
|
||||
const response = await fetch(url, {
|
||||
...fetchOptions,
|
||||
redirect: 'manual',
|
||||
})
|
||||
|
||||
// Handle redirects (e.g. media stream presigned URL)
|
||||
if (response.status === 307 || response.status === 302) {
|
||||
const location = response.headers.get('location')
|
||||
if (location) {
|
||||
return NextResponse.redirect(location)
|
||||
}
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text()
|
||||
@@ -69,6 +80,19 @@ async function proxyRequest(
|
||||
)
|
||||
}
|
||||
|
||||
// Handle binary responses (PDF, octet-stream)
|
||||
const contentType = response.headers.get('content-type') || ''
|
||||
if (contentType.includes('application/pdf') || contentType.includes('application/octet-stream')) {
|
||||
const buffer = await response.arrayBuffer()
|
||||
return new NextResponse(buffer, {
|
||||
status: response.status,
|
||||
headers: {
|
||||
'Content-Type': contentType,
|
||||
'Content-Disposition': response.headers.get('content-disposition') || '',
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
return NextResponse.json(data)
|
||||
} catch (error) {
|
||||
|
||||
@@ -44,7 +44,7 @@ export interface CanonicalControl {
|
||||
customer_visible?: boolean
|
||||
verification_method: string | null
|
||||
category: string | null
|
||||
target_audience: string | null
|
||||
target_audience: string | string[] | null
|
||||
generation_metadata?: Record<string, unknown> | null
|
||||
generation_strategy?: string | null
|
||||
created_at: string
|
||||
@@ -142,10 +142,27 @@ export const CATEGORY_OPTIONS = [
|
||||
]
|
||||
|
||||
export const TARGET_AUDIENCE_OPTIONS: Record<string, { bg: string; label: string }> = {
|
||||
// Legacy English keys
|
||||
enterprise: { bg: 'bg-cyan-100 text-cyan-700', label: 'Unternehmen' },
|
||||
authority: { bg: 'bg-rose-100 text-rose-700', label: 'Behoerden' },
|
||||
provider: { bg: 'bg-violet-100 text-violet-700', label: 'Anbieter' },
|
||||
all: { bg: 'bg-gray-100 text-gray-700', label: 'Alle' },
|
||||
// German keys from LLM generation
|
||||
unternehmen: { bg: 'bg-cyan-100 text-cyan-700', label: 'Unternehmen' },
|
||||
behoerden: { bg: 'bg-rose-100 text-rose-700', label: 'Behoerden' },
|
||||
entwickler: { bg: 'bg-sky-100 text-sky-700', label: 'Entwickler' },
|
||||
datenschutzbeauftragte: { bg: 'bg-purple-100 text-purple-700', label: 'DSB' },
|
||||
geschaeftsfuehrung: { bg: 'bg-amber-100 text-amber-700', label: 'GF' },
|
||||
'it-abteilung': { bg: 'bg-blue-100 text-blue-700', label: 'IT' },
|
||||
rechtsabteilung: { bg: 'bg-fuchsia-100 text-fuchsia-700', label: 'Recht' },
|
||||
'compliance-officer': { bg: 'bg-indigo-100 text-indigo-700', label: 'Compliance' },
|
||||
personalwesen: { bg: 'bg-pink-100 text-pink-700', label: 'Personal' },
|
||||
einkauf: { bg: 'bg-lime-100 text-lime-700', label: 'Einkauf' },
|
||||
produktion: { bg: 'bg-orange-100 text-orange-700', label: 'Produktion' },
|
||||
vertrieb: { bg: 'bg-teal-100 text-teal-700', label: 'Vertrieb' },
|
||||
gesundheitswesen: { bg: 'bg-red-100 text-red-700', label: 'Gesundheit' },
|
||||
finanzwesen: { bg: 'bg-emerald-100 text-emerald-700', label: 'Finanzen' },
|
||||
oeffentlicher_dienst: { bg: 'bg-rose-100 text-rose-700', label: 'Oeffentl. Dienst' },
|
||||
}
|
||||
|
||||
export const COLLECTION_OPTIONS = [
|
||||
@@ -223,11 +240,32 @@ export function CategoryBadge({ category }: { category: string | null }) {
|
||||
)
|
||||
}
|
||||
|
||||
export function TargetAudienceBadge({ audience }: { audience: string | null }) {
|
||||
export function TargetAudienceBadge({ audience }: { audience: string | string[] | null }) {
|
||||
if (!audience) return null
|
||||
const config = TARGET_AUDIENCE_OPTIONS[audience]
|
||||
if (!config) return null
|
||||
return <span className={`inline-flex items-center px-2 py-0.5 rounded text-xs font-medium ${config.bg}`}>{config.label}</span>
|
||||
|
||||
// Parse JSON array string from DB (e.g. '["unternehmen", "einkauf"]')
|
||||
let items: string[] = []
|
||||
if (typeof audience === 'string') {
|
||||
if (audience.startsWith('[')) {
|
||||
try { items = JSON.parse(audience) } catch { items = [audience] }
|
||||
} else {
|
||||
items = [audience]
|
||||
}
|
||||
} else if (Array.isArray(audience)) {
|
||||
items = audience
|
||||
}
|
||||
|
||||
if (items.length === 0) return null
|
||||
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1 flex-wrap">
|
||||
{items.map((item, i) => {
|
||||
const config = TARGET_AUDIENCE_OPTIONS[item]
|
||||
if (!config) return <span key={i} className="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-gray-100 text-gray-600">{item}</span>
|
||||
return <span key={i} className={`inline-flex items-center px-2 py-0.5 rounded text-xs font-medium ${config.bg}`}>{config.label}</span>
|
||||
})}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
export function GenerationStrategyBadge({ strategy }: { strategy: string | null | undefined }) {
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import { useState, useEffect, useCallback, useRef } from 'react'
|
||||
import {
|
||||
Shield, Search, ChevronRight, ChevronLeft, Filter, Lock,
|
||||
BookOpen, Plus, Zap, BarChart3, ListChecks,
|
||||
BookOpen, Plus, Zap, BarChart3, ListChecks, Trash2,
|
||||
ChevronsLeft, ChevronsRight, ArrowUpDown, Clock, RefreshCw,
|
||||
} from 'lucide-react'
|
||||
import {
|
||||
@@ -263,6 +263,33 @@ export default function ControlLibraryPage() {
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
|
||||
const [bulkProcessing, setBulkProcessing] = useState(false)
|
||||
|
||||
const handleBulkReject = async (sourceState: string) => {
|
||||
const count = stateFilter === sourceState ? totalCount : reviewCount
|
||||
if (!confirm(`Alle ${count} Controls mit Status "${sourceState}" auf "deprecated" setzen? Diese Aktion kann nicht rueckgaengig gemacht werden.`)) return
|
||||
setBulkProcessing(true)
|
||||
try {
|
||||
const res = await fetch(`${BACKEND_URL}?endpoint=bulk-review`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ release_state: sourceState, action: 'reject' }),
|
||||
})
|
||||
if (res.ok) {
|
||||
const data = await res.json()
|
||||
alert(`${data.affected_count} Controls auf "deprecated" gesetzt.`)
|
||||
await fullReload()
|
||||
} else {
|
||||
const err = await res.json()
|
||||
alert(`Fehler: ${err.error || err.details || 'Unbekannt'}`)
|
||||
}
|
||||
} catch {
|
||||
alert('Netzwerkfehler')
|
||||
} finally {
|
||||
setBulkProcessing(false)
|
||||
}
|
||||
}
|
||||
|
||||
const loadProcessedStats = async () => {
|
||||
try {
|
||||
const res = await fetch(`${BACKEND_URL}?endpoint=processed-stats`)
|
||||
@@ -381,13 +408,23 @@ export default function ControlLibraryPage() {
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{reviewCount > 0 && (
|
||||
<button
|
||||
onClick={enterReviewMode}
|
||||
className="flex items-center gap-1.5 px-3 py-2 text-sm text-white bg-yellow-600 rounded-lg hover:bg-yellow-700"
|
||||
>
|
||||
<ListChecks className="w-4 h-4" />
|
||||
Review ({reviewCount})
|
||||
</button>
|
||||
<>
|
||||
<button
|
||||
onClick={enterReviewMode}
|
||||
className="flex items-center gap-1.5 px-3 py-2 text-sm text-white bg-yellow-600 rounded-lg hover:bg-yellow-700"
|
||||
>
|
||||
<ListChecks className="w-4 h-4" />
|
||||
Review ({reviewCount})
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleBulkReject('needs_review')}
|
||||
disabled={bulkProcessing}
|
||||
className="flex items-center gap-1.5 px-3 py-2 text-sm text-white bg-red-600 rounded-lg hover:bg-red-700 disabled:opacity-50"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
{bulkProcessing ? 'Wird verarbeitet...' : `Alle ${reviewCount} ablehnen`}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
<button
|
||||
onClick={() => { setShowStats(!showStats); if (!showStats) loadProcessedStats() }}
|
||||
@@ -480,6 +517,7 @@ export default function ControlLibraryPage() {
|
||||
<option value="needs_review">Review noetig</option>
|
||||
<option value="too_close">Zu aehnlich</option>
|
||||
<option value="duplicate">Duplikat</option>
|
||||
<option value="deprecated">Deprecated</option>
|
||||
</select>
|
||||
<select
|
||||
value={verificationFilter}
|
||||
@@ -507,9 +545,21 @@ export default function ControlLibraryPage() {
|
||||
className="text-sm border border-gray-300 rounded-lg px-2 py-1.5 focus:outline-none focus:ring-2 focus:ring-purple-500"
|
||||
>
|
||||
<option value="">Zielgruppe</option>
|
||||
{Object.entries(TARGET_AUDIENCE_OPTIONS).map(([k, v]) => (
|
||||
<option key={k} value={k}>{v.label}</option>
|
||||
))}
|
||||
<option value="unternehmen">Unternehmen</option>
|
||||
<option value="behoerden">Behoerden</option>
|
||||
<option value="entwickler">Entwickler</option>
|
||||
<option value="datenschutzbeauftragte">DSB</option>
|
||||
<option value="geschaeftsfuehrung">Geschaeftsfuehrung</option>
|
||||
<option value="it-abteilung">IT-Abteilung</option>
|
||||
<option value="rechtsabteilung">Rechtsabteilung</option>
|
||||
<option value="compliance-officer">Compliance Officer</option>
|
||||
<option value="personalwesen">Personalwesen</option>
|
||||
<option value="einkauf">Einkauf</option>
|
||||
<option value="produktion">Produktion</option>
|
||||
<option value="vertrieb">Vertrieb</option>
|
||||
<option value="gesundheitswesen">Gesundheitswesen</option>
|
||||
<option value="finanzwesen">Finanzwesen</option>
|
||||
<option value="oeffentlicher_dienst">Oeffentl. Dienst</option>
|
||||
</select>
|
||||
<select
|
||||
value={sourceFilter}
|
||||
@@ -567,11 +617,23 @@ export default function ControlLibraryPage() {
|
||||
|
||||
{/* Pagination Header */}
|
||||
<div className="px-6 py-2 bg-gray-50 border-b border-gray-200 flex items-center justify-between text-xs text-gray-500">
|
||||
<span>
|
||||
{totalCount} Controls gefunden
|
||||
{totalCount !== (meta?.total ?? totalCount) && ` (von ${meta?.total} gesamt)`}
|
||||
{loading && <span className="ml-2 text-purple-500">Lade...</span>}
|
||||
</span>
|
||||
<div className="flex items-center gap-3">
|
||||
<span>
|
||||
{totalCount} Controls gefunden
|
||||
{totalCount !== (meta?.total ?? totalCount) && ` (von ${meta?.total} gesamt)`}
|
||||
{loading && <span className="ml-2 text-purple-500">Lade...</span>}
|
||||
</span>
|
||||
{stateFilter && ['needs_review', 'too_close', 'duplicate'].includes(stateFilter) && totalCount > 0 && (
|
||||
<button
|
||||
onClick={() => handleBulkReject(stateFilter)}
|
||||
disabled={bulkProcessing}
|
||||
className="flex items-center gap-1 px-2 py-1 text-xs text-white bg-red-600 rounded hover:bg-red-700 disabled:opacity-50"
|
||||
>
|
||||
<Trash2 className="w-3 h-3" />
|
||||
{bulkProcessing ? '...' : `Alle ${totalCount} ablehnen`}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<span>Seite {currentPage} von {totalPages}</span>
|
||||
</div>
|
||||
|
||||
|
||||
560
admin-compliance/app/sdk/training/learner/page.tsx
Normal file
560
admin-compliance/app/sdk/training/learner/page.tsx
Normal 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 "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>
|
||||
)}
|
||||
|
||||
{/* 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>
|
||||
)
|
||||
}
|
||||
@@ -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} />
|
||||
|
||||
@@ -553,6 +553,32 @@ export function SDKSidebar({ collapsed = false, onCollapsedChange }: SDKSidebarP
|
||||
Zusatzmodule
|
||||
</div>
|
||||
)}
|
||||
<AdditionalModuleItem
|
||||
href="/sdk/training"
|
||||
icon={
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2}
|
||||
d="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.247 18 16.5 18c-1.746 0-3.332.477-4.5 1.253" />
|
||||
</svg>
|
||||
}
|
||||
label="Schulung (Admin)"
|
||||
isActive={pathname === '/sdk/training'}
|
||||
collapsed={collapsed}
|
||||
projectId={projectId}
|
||||
/>
|
||||
<AdditionalModuleItem
|
||||
href="/sdk/training/learner"
|
||||
icon={
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2}
|
||||
d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" />
|
||||
</svg>
|
||||
}
|
||||
label="Schulung (Learner)"
|
||||
isActive={pathname === '/sdk/training/learner'}
|
||||
collapsed={collapsed}
|
||||
projectId={projectId}
|
||||
/>
|
||||
<AdditionalModuleItem
|
||||
href="/sdk/rag"
|
||||
icon={
|
||||
|
||||
321
admin-compliance/components/training/InteractiveVideoPlayer.tsx
Normal file
321
admin-compliance/components/training/InteractiveVideoPlayer.tsx
Normal file
@@ -0,0 +1,321 @@
|
||||
'use client'
|
||||
|
||||
import { useRef, useState, useEffect, useCallback } from 'react'
|
||||
import type {
|
||||
InteractiveVideoManifest,
|
||||
CheckpointEntry,
|
||||
CheckpointQuizResult,
|
||||
} from '@/lib/sdk/training/types'
|
||||
import { submitCheckpointQuiz } from '@/lib/sdk/training/api'
|
||||
|
||||
interface Props {
|
||||
manifest: InteractiveVideoManifest
|
||||
assignmentId: string
|
||||
onAllCheckpointsPassed?: () => void
|
||||
}
|
||||
|
||||
export default function InteractiveVideoPlayer({ manifest, assignmentId, onAllCheckpointsPassed }: Props) {
|
||||
const videoRef = useRef<HTMLVideoElement>(null)
|
||||
const [currentCheckpoint, setCurrentCheckpoint] = useState<CheckpointEntry | null>(null)
|
||||
const [showOverlay, setShowOverlay] = useState(false)
|
||||
const [answers, setAnswers] = useState<Record<number, number>>({})
|
||||
const [quizResult, setQuizResult] = useState<CheckpointQuizResult | null>(null)
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
const [passedCheckpoints, setPassedCheckpoints] = useState<Set<string>>(new Set())
|
||||
const [currentTime, setCurrentTime] = useState(0)
|
||||
const [duration, setDuration] = useState(0)
|
||||
|
||||
// Initialize passed checkpoints from manifest progress
|
||||
useEffect(() => {
|
||||
const passed = new Set<string>()
|
||||
for (const cp of manifest.checkpoints) {
|
||||
if (cp.progress?.passed) {
|
||||
passed.add(cp.checkpoint_id)
|
||||
}
|
||||
}
|
||||
setPassedCheckpoints(passed)
|
||||
}, [manifest])
|
||||
|
||||
// Find next unpassed checkpoint
|
||||
const getNextUnpassedCheckpoint = useCallback((): CheckpointEntry | null => {
|
||||
for (const cp of manifest.checkpoints) {
|
||||
if (!passedCheckpoints.has(cp.checkpoint_id)) {
|
||||
return cp
|
||||
}
|
||||
}
|
||||
return null
|
||||
}, [manifest.checkpoints, passedCheckpoints])
|
||||
|
||||
// Time update handler — check for checkpoint triggers
|
||||
const handleTimeUpdate = useCallback(() => {
|
||||
if (!videoRef.current || showOverlay) return
|
||||
const time = videoRef.current.currentTime
|
||||
setCurrentTime(time)
|
||||
|
||||
for (const cp of manifest.checkpoints) {
|
||||
if (passedCheckpoints.has(cp.checkpoint_id)) continue
|
||||
// Trigger checkpoint when video reaches its timestamp (within 0.5s)
|
||||
if (time >= cp.timestamp_seconds && time < cp.timestamp_seconds + 1.0) {
|
||||
videoRef.current.pause()
|
||||
setCurrentCheckpoint(cp)
|
||||
setShowOverlay(true)
|
||||
setAnswers({})
|
||||
setQuizResult(null)
|
||||
break
|
||||
}
|
||||
}
|
||||
}, [manifest.checkpoints, passedCheckpoints, showOverlay])
|
||||
|
||||
// Seek protection — prevent skipping past unpassed checkpoints
|
||||
const handleSeeking = useCallback(() => {
|
||||
if (!videoRef.current) return
|
||||
const seekTarget = videoRef.current.currentTime
|
||||
const nextUnpassed = getNextUnpassedCheckpoint()
|
||||
if (nextUnpassed && seekTarget > nextUnpassed.timestamp_seconds) {
|
||||
videoRef.current.currentTime = nextUnpassed.timestamp_seconds - 0.5
|
||||
}
|
||||
}, [getNextUnpassedCheckpoint])
|
||||
|
||||
// Submit checkpoint quiz
|
||||
async function handleSubmitQuiz() {
|
||||
if (!currentCheckpoint) return
|
||||
setSubmitting(true)
|
||||
try {
|
||||
const answerList = currentCheckpoint.questions.map((_, i) => answers[i] ?? -1)
|
||||
const result = await submitCheckpointQuiz(
|
||||
currentCheckpoint.checkpoint_id,
|
||||
assignmentId,
|
||||
answerList,
|
||||
)
|
||||
setQuizResult(result)
|
||||
|
||||
if (result.passed) {
|
||||
setPassedCheckpoints(prev => {
|
||||
const next = new Set(prev)
|
||||
next.add(currentCheckpoint.checkpoint_id)
|
||||
return next
|
||||
})
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Checkpoint quiz submission failed:', e)
|
||||
} finally {
|
||||
setSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
// Continue video after passing checkpoint
|
||||
function handleContinue() {
|
||||
setShowOverlay(false)
|
||||
setCurrentCheckpoint(null)
|
||||
setQuizResult(null)
|
||||
setAnswers({})
|
||||
if (videoRef.current) {
|
||||
videoRef.current.play()
|
||||
}
|
||||
|
||||
// Check if all checkpoints passed
|
||||
const allPassed = manifest.checkpoints.every(cp => passedCheckpoints.has(cp.checkpoint_id))
|
||||
if (allPassed && onAllCheckpointsPassed) {
|
||||
onAllCheckpointsPassed()
|
||||
}
|
||||
}
|
||||
|
||||
// Retry quiz
|
||||
function handleRetry() {
|
||||
setQuizResult(null)
|
||||
setAnswers({})
|
||||
}
|
||||
|
||||
// Resume to last unpassed checkpoint
|
||||
useEffect(() => {
|
||||
if (!videoRef.current || !manifest.checkpoints.length) return
|
||||
const nextUnpassed = getNextUnpassedCheckpoint()
|
||||
if (nextUnpassed && nextUnpassed.timestamp_seconds > 0) {
|
||||
// Start a bit before the checkpoint
|
||||
const startTime = Math.max(0, nextUnpassed.timestamp_seconds - 2)
|
||||
videoRef.current.currentTime = startTime
|
||||
}
|
||||
}, []) // Only on mount
|
||||
|
||||
const handleLoadedMetadata = useCallback(() => {
|
||||
if (videoRef.current) {
|
||||
setDuration(videoRef.current.duration)
|
||||
}
|
||||
}, [])
|
||||
|
||||
// Progress bar percentage
|
||||
const progressPercent = duration > 0 ? (currentTime / duration) * 100 : 0
|
||||
|
||||
return (
|
||||
<div className="relative bg-black rounded-lg overflow-hidden">
|
||||
{/* Video element */}
|
||||
<video
|
||||
ref={videoRef}
|
||||
className="w-full"
|
||||
src={manifest.stream_url}
|
||||
onTimeUpdate={handleTimeUpdate}
|
||||
onSeeking={handleSeeking}
|
||||
onLoadedMetadata={handleLoadedMetadata}
|
||||
controls={!showOverlay}
|
||||
/>
|
||||
|
||||
{/* Custom progress bar with checkpoint markers */}
|
||||
<div className="relative h-2 bg-gray-700">
|
||||
{/* Progress fill */}
|
||||
<div
|
||||
className="h-full bg-indigo-500 transition-all"
|
||||
style={{ width: `${progressPercent}%` }}
|
||||
/>
|
||||
{/* Checkpoint markers */}
|
||||
{manifest.checkpoints.map(cp => {
|
||||
const pos = duration > 0 ? (cp.timestamp_seconds / duration) * 100 : 0
|
||||
const isPassed = passedCheckpoints.has(cp.checkpoint_id)
|
||||
return (
|
||||
<div
|
||||
key={cp.checkpoint_id}
|
||||
className={`absolute top-1/2 -translate-y-1/2 w-3 h-3 rounded-full border-2 border-white ${
|
||||
isPassed ? 'bg-green-500' : 'bg-red-500'
|
||||
}`}
|
||||
style={{ left: `${pos}%` }}
|
||||
title={`${cp.title} (${isPassed ? 'Bestanden' : 'Ausstehend'})`}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Checkpoint overlay */}
|
||||
{showOverlay && currentCheckpoint && (
|
||||
<div className="absolute inset-0 bg-black/80 flex items-center justify-center p-6 overflow-y-auto">
|
||||
<div className="bg-white rounded-xl p-6 max-w-2xl w-full max-h-[90%] overflow-y-auto">
|
||||
{/* Header */}
|
||||
<div className="flex items-center gap-3 mb-4 pb-3 border-b border-gray-200">
|
||||
<div className="w-8 h-8 bg-red-100 rounded-full flex items-center justify-center">
|
||||
<span className="text-red-600 font-bold text-sm">
|
||||
{currentCheckpoint.index + 1}
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-semibold text-gray-900">Checkpoint: {currentCheckpoint.title}</h3>
|
||||
<p className="text-xs text-gray-500">
|
||||
Beantworten Sie die Fragen, um fortzufahren
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{quizResult ? (
|
||||
/* Quiz result */
|
||||
<div>
|
||||
<div className={`text-center p-6 rounded-lg mb-4 ${
|
||||
quizResult.passed ? 'bg-green-50 border border-green-200' : 'bg-red-50 border border-red-200'
|
||||
}`}>
|
||||
<div className="text-3xl mb-2">{quizResult.passed ? '\u2705' : '\u274C'}</div>
|
||||
<h4 className="text-lg font-bold mb-1">
|
||||
{quizResult.passed ? 'Checkpoint bestanden!' : 'Nicht bestanden'}
|
||||
</h4>
|
||||
<p className="text-sm text-gray-600">
|
||||
Ergebnis: {Math.round(quizResult.score)}%
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Feedback */}
|
||||
<div className="space-y-3 mb-4">
|
||||
{quizResult.feedback.map((fb, i) => (
|
||||
<div key={i} className={`p-3 rounded-lg text-sm ${
|
||||
fb.correct ? 'bg-green-50 border-l-4 border-green-400' : 'bg-red-50 border-l-4 border-red-400'
|
||||
}`}>
|
||||
<p className="font-medium">{fb.question}</p>
|
||||
{!fb.correct && (
|
||||
<p className="text-gray-600 mt-1">{fb.explanation}</p>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{quizResult.passed ? (
|
||||
<button
|
||||
onClick={handleContinue}
|
||||
className="w-full px-4 py-2 bg-green-600 text-white rounded-lg hover:bg-green-700"
|
||||
>
|
||||
Video fortsetzen
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
onClick={handleRetry}
|
||||
className="w-full px-4 py-2 bg-indigo-600 text-white rounded-lg hover:bg-indigo-700"
|
||||
>
|
||||
Erneut versuchen
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
/* Quiz questions */
|
||||
<div>
|
||||
<div className="space-y-4">
|
||||
{currentCheckpoint.questions.map((q, qIdx) => (
|
||||
<div key={qIdx} className="bg-gray-50 rounded-lg p-4">
|
||||
<p className="font-medium text-gray-900 mb-3 text-sm">
|
||||
<span className="text-indigo-600 mr-1">Frage {qIdx + 1}.</span>
|
||||
{q.question}
|
||||
</p>
|
||||
<div className="space-y-2">
|
||||
{q.options.map((opt, oIdx) => (
|
||||
<label
|
||||
key={oIdx}
|
||||
className={`flex items-center gap-3 p-2.5 rounded-lg border cursor-pointer transition-colors text-sm ${
|
||||
answers[qIdx] === oIdx
|
||||
? 'border-indigo-500 bg-indigo-50'
|
||||
: 'border-gray-200 hover:bg-white'
|
||||
}`}
|
||||
>
|
||||
<input
|
||||
type="radio"
|
||||
name={`checkpoint-q-${qIdx}`}
|
||||
checked={answers[qIdx] === oIdx}
|
||||
onChange={() => setAnswers(prev => ({ ...prev, [qIdx]: oIdx }))}
|
||||
className="text-indigo-600"
|
||||
/>
|
||||
<span className="text-gray-700">{opt}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={handleSubmitQuiz}
|
||||
disabled={submitting || Object.keys(answers).length < currentCheckpoint.questions.length}
|
||||
className="w-full mt-4 px-4 py-2 bg-indigo-600 text-white rounded-lg hover:bg-indigo-700 disabled:opacity-50"
|
||||
>
|
||||
{submitting ? 'Wird ausgewertet...' : `Antworten absenden (${Object.keys(answers).length}/${currentCheckpoint.questions.length})`}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Checkpoint status bar */}
|
||||
<div className="bg-gray-800 px-4 py-2 flex items-center gap-2 text-xs text-gray-300">
|
||||
<span>Checkpoints:</span>
|
||||
{manifest.checkpoints.map(cp => (
|
||||
<span
|
||||
key={cp.checkpoint_id}
|
||||
className={`px-2 py-0.5 rounded-full ${
|
||||
passedCheckpoints.has(cp.checkpoint_id)
|
||||
? 'bg-green-700 text-green-100'
|
||||
: 'bg-gray-600 text-gray-300'
|
||||
}`}
|
||||
>
|
||||
{cp.title}
|
||||
</span>
|
||||
))}
|
||||
{manifest.checkpoints.length > 0 && (
|
||||
<span className="ml-auto">
|
||||
{passedCheckpoints.size}/{manifest.checkpoints.length} bestanden
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -320,3 +320,158 @@ export async function generateVideo(moduleId: string): Promise<TrainingMedia> {
|
||||
export async function previewVideoScript(moduleId: string): Promise<{ title: string; sections: Array<{ heading: string; text: string; bullet_points: string[] }> }> {
|
||||
return apiFetch(`/content/${moduleId}/preview-script`, { method: 'POST' })
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// TRAINING BLOCKS (Controls → Schulungsmodule)
|
||||
// =============================================================================
|
||||
|
||||
import type {
|
||||
TrainingBlockConfig,
|
||||
CanonicalControlSummary,
|
||||
CanonicalControlMeta,
|
||||
BlockPreview,
|
||||
BlockGenerateResult,
|
||||
TrainingBlockControlLink,
|
||||
} from './types'
|
||||
|
||||
export async function listBlockConfigs(): Promise<{ blocks: TrainingBlockConfig[]; total: number }> {
|
||||
return apiFetch('/blocks')
|
||||
}
|
||||
|
||||
export async function createBlockConfig(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
|
||||
frequency_type?: string
|
||||
duration_minutes?: number
|
||||
pass_threshold?: number
|
||||
max_controls_per_module?: number
|
||||
}): Promise<TrainingBlockConfig> {
|
||||
return apiFetch<TrainingBlockConfig>('/blocks', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(data),
|
||||
})
|
||||
}
|
||||
|
||||
export async function getBlockConfig(id: string): Promise<TrainingBlockConfig> {
|
||||
return apiFetch<TrainingBlockConfig>(`/blocks/${id}`)
|
||||
}
|
||||
|
||||
export async function updateBlockConfig(id: string, data: Record<string, unknown>): Promise<TrainingBlockConfig> {
|
||||
return apiFetch<TrainingBlockConfig>(`/blocks/${id}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(data),
|
||||
})
|
||||
}
|
||||
|
||||
export async function deleteBlockConfig(id: string): Promise<void> {
|
||||
return apiFetch(`/blocks/${id}`, { method: 'DELETE' })
|
||||
}
|
||||
|
||||
export async function previewBlock(id: string): Promise<BlockPreview> {
|
||||
return apiFetch<BlockPreview>(`/blocks/${id}/preview`, { method: 'POST' })
|
||||
}
|
||||
|
||||
export async function generateBlock(id: string, data?: {
|
||||
language?: string
|
||||
auto_matrix?: boolean
|
||||
}): Promise<BlockGenerateResult> {
|
||||
return apiFetch<BlockGenerateResult>(`/blocks/${id}/generate`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(data || { language: 'de', auto_matrix: true }),
|
||||
})
|
||||
}
|
||||
|
||||
export async function getBlockControls(id: string): Promise<{ controls: TrainingBlockControlLink[]; total: number }> {
|
||||
return apiFetch(`/blocks/${id}/controls`)
|
||||
}
|
||||
|
||||
export async function listCanonicalControls(filters?: {
|
||||
domain?: string
|
||||
category?: string
|
||||
severity?: string
|
||||
target_audience?: string
|
||||
}): Promise<{ controls: CanonicalControlSummary[]; total: number }> {
|
||||
const params = new URLSearchParams()
|
||||
if (filters?.domain) params.set('domain', filters.domain)
|
||||
if (filters?.category) params.set('category', filters.category)
|
||||
if (filters?.severity) params.set('severity', filters.severity)
|
||||
if (filters?.target_audience) params.set('target_audience', filters.target_audience)
|
||||
const qs = params.toString()
|
||||
return apiFetch(`/canonical/controls${qs ? `?${qs}` : ''}`)
|
||||
}
|
||||
|
||||
export async function getCanonicalMeta(): Promise<CanonicalControlMeta> {
|
||||
return apiFetch<CanonicalControlMeta>('/canonical/meta')
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// CERTIFICATES
|
||||
// =============================================================================
|
||||
|
||||
export async function generateCertificate(assignmentId: string): Promise<{ certificate_id: string; assignment: TrainingAssignment }> {
|
||||
return apiFetch(`/certificates/generate/${assignmentId}`, { method: 'POST' })
|
||||
}
|
||||
|
||||
export async function listCertificates(): Promise<{ certificates: TrainingAssignment[]; total: number }> {
|
||||
return apiFetch('/certificates')
|
||||
}
|
||||
|
||||
export async function downloadCertificatePDF(certificateId: string): Promise<Blob> {
|
||||
const res = await fetch(`${BASE_URL}/certificates/${certificateId}/pdf`, {
|
||||
headers: {
|
||||
'X-Tenant-ID': typeof window !== 'undefined'
|
||||
? (localStorage.getItem('bp-tenant-id') || 'default')
|
||||
: 'default',
|
||||
},
|
||||
})
|
||||
if (!res.ok) throw new Error(`PDF download failed: ${res.status}`)
|
||||
return res.blob()
|
||||
}
|
||||
|
||||
export async function verifyCertificate(certificateId: string): Promise<{ valid: boolean; assignment: TrainingAssignment }> {
|
||||
return apiFetch(`/certificates/${certificateId}/verify`)
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// MEDIA STREAMING
|
||||
// =============================================================================
|
||||
|
||||
export function getMediaStreamURL(mediaId: string): string {
|
||||
return `${BASE_URL}/media/${mediaId}/stream`
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// INTERACTIVE VIDEO
|
||||
// =============================================================================
|
||||
|
||||
import type {
|
||||
InteractiveVideoManifest,
|
||||
CheckpointQuizResult,
|
||||
CheckpointProgress,
|
||||
} from './types'
|
||||
|
||||
export async function generateInteractiveVideo(moduleId: string): Promise<TrainingMedia> {
|
||||
return apiFetch<TrainingMedia>(`/content/${moduleId}/generate-interactive`, { method: 'POST' })
|
||||
}
|
||||
|
||||
export async function getInteractiveManifest(moduleId: string, assignmentId?: string): Promise<InteractiveVideoManifest> {
|
||||
const qs = assignmentId ? `?assignment_id=${assignmentId}` : ''
|
||||
return apiFetch<InteractiveVideoManifest>(`/content/${moduleId}/interactive-manifest${qs}`)
|
||||
}
|
||||
|
||||
export async function submitCheckpointQuiz(checkpointId: string, assignmentId: string, answers: number[]): Promise<CheckpointQuizResult> {
|
||||
return apiFetch<CheckpointQuizResult>(`/checkpoints/${checkpointId}/submit`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ assignment_id: assignmentId, answers }),
|
||||
})
|
||||
}
|
||||
|
||||
export async function getCheckpointProgress(assignmentId: string): Promise<{ progress: CheckpointProgress[]; total: number }> {
|
||||
return apiFetch(`/checkpoints/progress/${assignmentId}`)
|
||||
}
|
||||
|
||||
@@ -65,9 +65,17 @@ export const ROLE_LABELS: Record<string, string> = {
|
||||
R7: 'Fachabteilung',
|
||||
R8: 'IT-Administration',
|
||||
R9: 'Alle Mitarbeiter',
|
||||
R10: 'Behoerden / Oeffentlicher Dienst',
|
||||
}
|
||||
|
||||
export const ALL_ROLES = ['R1', 'R2', 'R3', 'R4', 'R5', 'R6', 'R7', 'R8', 'R9'] as const
|
||||
export const ALL_ROLES = ['R1', 'R2', 'R3', 'R4', 'R5', 'R6', 'R7', 'R8', 'R9', 'R10'] as const
|
||||
|
||||
export const TARGET_AUDIENCE_LABELS: Record<string, string> = {
|
||||
enterprise: 'Unternehmen',
|
||||
authority: 'Behoerden',
|
||||
provider: 'IT-Dienstleister',
|
||||
all: 'Alle',
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// MAIN ENTITIES
|
||||
@@ -273,7 +281,7 @@ export interface QuizSubmitResponse {
|
||||
// MEDIA (Audio/Video)
|
||||
// =============================================================================
|
||||
|
||||
export type MediaType = 'audio' | 'video'
|
||||
export type MediaType = 'audio' | 'video' | 'interactive_video'
|
||||
export type MediaStatus = 'processing' | 'completed' | 'failed'
|
||||
|
||||
export interface TrainingMedia {
|
||||
@@ -307,3 +315,121 @@ export interface VideoScriptSection {
|
||||
text: string
|
||||
bullet_points: string[]
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// TRAINING BLOCKS (Controls → Schulungsmodule)
|
||||
// =============================================================================
|
||||
|
||||
export interface TrainingBlockConfig {
|
||||
id: string
|
||||
tenant_id: string
|
||||
name: string
|
||||
description?: string
|
||||
domain_filter?: string
|
||||
category_filter?: string
|
||||
severity_filter?: string
|
||||
target_audience_filter?: string
|
||||
regulation_area: RegulationArea
|
||||
module_code_prefix: string
|
||||
frequency_type: FrequencyType
|
||||
duration_minutes: number
|
||||
pass_threshold: number
|
||||
max_controls_per_module: number
|
||||
is_active: boolean
|
||||
last_generated_at?: string
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
export interface CanonicalControlSummary {
|
||||
control_id: string
|
||||
title: string
|
||||
objective: string
|
||||
rationale: string
|
||||
requirements: string[]
|
||||
severity: string
|
||||
category: string
|
||||
target_audience: string
|
||||
tags: string[]
|
||||
}
|
||||
|
||||
export interface CanonicalControlMeta {
|
||||
domains: { domain: string; count: number }[]
|
||||
categories: { category: string; count: number }[]
|
||||
audiences: { audience: string; count: number }[]
|
||||
total: number
|
||||
}
|
||||
|
||||
export interface BlockPreview {
|
||||
control_count: number
|
||||
module_count: number
|
||||
controls: CanonicalControlSummary[]
|
||||
proposed_roles: string[]
|
||||
}
|
||||
|
||||
export interface BlockGenerateResult {
|
||||
modules_created: number
|
||||
controls_linked: number
|
||||
matrix_entries_created: number
|
||||
content_generated: number
|
||||
errors?: string[]
|
||||
}
|
||||
|
||||
export interface TrainingBlockControlLink {
|
||||
id: string
|
||||
block_config_id: string
|
||||
module_id: string
|
||||
control_id: string
|
||||
control_title: string
|
||||
control_objective: string
|
||||
control_requirements: string[]
|
||||
sort_order: number
|
||||
created_at: string
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// INTERACTIVE VIDEO / CHECKPOINTS
|
||||
// =============================================================================
|
||||
|
||||
export interface InteractiveVideoManifest {
|
||||
media_id: string
|
||||
stream_url: string
|
||||
checkpoints: CheckpointEntry[]
|
||||
}
|
||||
|
||||
export interface CheckpointEntry {
|
||||
checkpoint_id: string
|
||||
index: number
|
||||
title: string
|
||||
timestamp_seconds: number
|
||||
questions: CheckpointQuestion[]
|
||||
progress?: CheckpointProgress
|
||||
}
|
||||
|
||||
export interface CheckpointQuestion {
|
||||
question: string
|
||||
options: string[]
|
||||
correct_index: number
|
||||
explanation: string
|
||||
}
|
||||
|
||||
export interface CheckpointProgress {
|
||||
id: string
|
||||
assignment_id: string
|
||||
checkpoint_id: string
|
||||
passed: boolean
|
||||
attempts: number
|
||||
last_attempt_at?: string
|
||||
}
|
||||
|
||||
export interface CheckpointQuizResult {
|
||||
passed: boolean
|
||||
score: number
|
||||
feedback: CheckpointQuizFeedback[]
|
||||
}
|
||||
|
||||
export interface CheckpointQuizFeedback {
|
||||
question: string
|
||||
correct: boolean
|
||||
explanation: string
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user