feat(training): add Media Pipeline — TTS Audio, Presentation Video, Bulk Generation
All checks were successful
CI / go-lint (push) Has been skipped
CI / python-lint (push) Has been skipped
CI / nodejs-lint (push) Has been skipped
CI / test-go-ai-compliance (push) Successful in 48s
CI / test-python-backend-compliance (push) Successful in 35s
CI / test-python-document-crawler (push) Successful in 22s
CI / test-python-dsms-gateway (push) Successful in 20s

Phase A: 8 new IT-Security training modules (SEC-PWD, SEC-DESK, SEC-KIAI,
SEC-BYOD, SEC-VIDEO, SEC-USB, SEC-INC, SEC-HOME) with CTM entries.
Bulk content and quiz generation endpoints for all 28 modules.

Phase B: Piper TTS service (Python/FastAPI) for local German speech synthesis.
training_media table, TTSClient in Go backend, audio generation endpoints,
AudioPlayer component in frontend. MinIO storage integration.

Phase C: FFmpeg presentation video pipeline — LLM generates slide scripts,
ImageMagick renders 1920x1080 slides, FFmpeg combines with audio to MP4.
VideoPlayer and ScriptPreview components in frontend.

New files: 15 created, 9 modified
- compliance-tts-service/ (Dockerfile, main.py, tts_engine.py, storage.py,
  slide_renderer.py, video_generator.py)
- migrations 014-016 (training engine, IT-security modules, media table)
- training package (models, store, content_generator, media, handlers)
- frontend (AudioPlayer, VideoPlayer, ScriptPreview, api, types, page)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Benjamin Boenisch
2026-02-16 21:42:33 +01:00
parent fba4c411dc
commit 9b8b7ca073
28 changed files with 7088 additions and 0 deletions

View File

@@ -0,0 +1,607 @@
'use client'
import { useEffect, useState } from 'react'
import {
getModules, getMatrix, getAssignments, getStats, getDeadlines, getModuleMedia,
getAuditLog, generateContent, generateQuiz,
publishContent, checkEscalation, getContent,
generateAllContent, generateAllQuizzes,
} from '@/lib/sdk/training/api'
import type {
TrainingModule, TrainingAssignment,
MatrixResponse, TrainingStats, DeadlineInfo, AuditLogEntry, ModuleContent, TrainingMedia, VideoScript,
} from '@/lib/sdk/training/types'
import {
REGULATION_LABELS, REGULATION_COLORS, FREQUENCY_LABELS,
STATUS_LABELS, STATUS_COLORS, ROLE_LABELS, ALL_ROLES,
} from '@/lib/sdk/training/types'
import AudioPlayer from '@/components/training/AudioPlayer'
import VideoPlayer from '@/components/training/VideoPlayer'
import ScriptPreview from '@/components/training/ScriptPreview'
type Tab = 'overview' | 'modules' | 'matrix' | 'assignments' | 'content' | 'audit'
export default function TrainingPage() {
const [activeTab, setActiveTab] = useState<Tab>('overview')
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [stats, setStats] = useState<TrainingStats | null>(null)
const [modules, setModules] = useState<TrainingModule[]>([])
const [matrix, setMatrix] = useState<MatrixResponse | null>(null)
const [assignments, setAssignments] = useState<TrainingAssignment[]>([])
const [deadlines, setDeadlines] = useState<DeadlineInfo[]>([])
const [auditLog, setAuditLog] = useState<AuditLogEntry[]>([])
const [selectedModuleId, setSelectedModuleId] = useState<string>('')
const [generatedContent, setGeneratedContent] = useState<ModuleContent | null>(null)
const [generating, setGenerating] = useState(false)
const [bulkGenerating, setBulkGenerating] = useState(false)
const [bulkResult, setBulkResult] = useState<{ generated: number; skipped: number; errors: string[] } | null>(null)
const [moduleMedia, setModuleMedia] = useState<TrainingMedia[]>([])
const [statusFilter, setStatusFilter] = useState<string>('')
const [regulationFilter, setRegulationFilter] = useState<string>('')
useEffect(() => {
loadData()
}, [])
async function loadData() {
setLoading(true)
setError(null)
try {
const [statsRes, modulesRes, matrixRes, assignmentsRes, deadlinesRes, auditRes] = await Promise.allSettled([
getStats(),
getModules(),
getMatrix(),
getAssignments({ limit: 50 }),
getDeadlines(10),
getAuditLog({ limit: 30 }),
])
if (statsRes.status === 'fulfilled') setStats(statsRes.value)
if (modulesRes.status === 'fulfilled') setModules(modulesRes.value.modules)
if (matrixRes.status === 'fulfilled') setMatrix(matrixRes.value)
if (assignmentsRes.status === 'fulfilled') setAssignments(assignmentsRes.value.assignments)
if (deadlinesRes.status === 'fulfilled') setDeadlines(deadlinesRes.value.deadlines)
if (auditRes.status === 'fulfilled') setAuditLog(auditRes.value.entries)
} catch (e) {
setError(e instanceof Error ? e.message : 'Fehler beim Laden')
} finally {
setLoading(false)
}
}
async function handleGenerateContent() {
if (!selectedModuleId) return
setGenerating(true)
try {
const content = await generateContent(selectedModuleId)
setGeneratedContent(content)
} catch (e) {
setError(e instanceof Error ? e.message : 'Fehler bei der Content-Generierung')
} finally {
setGenerating(false)
}
}
async function handleGenerateQuiz() {
if (!selectedModuleId) return
setGenerating(true)
try {
await generateQuiz(selectedModuleId, 5)
await loadData()
} catch (e) {
setError(e instanceof Error ? e.message : 'Fehler bei der Quiz-Generierung')
} finally {
setGenerating(false)
}
}
async function handlePublishContent(contentId: string) {
try {
await publishContent(contentId)
setGeneratedContent(null)
await loadData()
} catch (e) {
setError(e instanceof Error ? e.message : 'Fehler beim Veroeffentlichen')
}
}
async function handleCheckEscalation() {
try {
const result = await checkEscalation()
alert(`Eskalation geprueft: ${result.total_checked} geprueft, ${result.escalated} eskaliert`)
await loadData()
} catch (e) {
setError(e instanceof Error ? e.message : 'Fehler bei der Eskalationspruefung')
}
}
async function handleLoadContent(moduleId: string) {
try {
const content = await getContent(moduleId)
setGeneratedContent(content)
} catch {
setGeneratedContent(null)
}
}
async function handleBulkContent() {
setBulkGenerating(true)
setBulkResult(null)
try {
const result = await generateAllContent('de')
setBulkResult(result)
await loadData()
} catch (e) {
setError(e instanceof Error ? e.message : 'Fehler bei der Bulk-Generierung')
} finally {
setBulkGenerating(false)
}
}
async function loadModuleMedia(moduleId: string) {
try {
const result = await getModuleMedia(moduleId)
setModuleMedia(result.media)
} catch {
setModuleMedia([])
}
}
async function handleBulkQuiz() {
setBulkGenerating(true)
setBulkResult(null)
try {
const result = await generateAllQuizzes()
setBulkResult(result)
await loadData()
} catch (e) {
setError(e instanceof Error ? e.message : 'Fehler bei der Bulk-Quiz-Generierung')
} finally {
setBulkGenerating(false)
}
}
const tabs: { id: Tab; label: string }[] = [
{ id: 'overview', label: 'Uebersicht' },
{ id: 'modules', label: 'Modulkatalog' },
{ id: 'matrix', label: 'Training Matrix' },
{ id: 'assignments', label: 'Zuweisungen' },
{ id: 'content', label: 'Content-Generator' },
{ id: 'audit', label: 'Audit Trail' },
]
const filteredModules = modules.filter(m =>
(!regulationFilter || m.regulation_area === regulationFilter)
)
const filteredAssignments = assignments.filter(a =>
(!statusFilter || a.status === statusFilter)
)
if (loading) {
return (
<div className="p-6">
<div className="animate-pulse space-y-4">
<div className="h-8 bg-gray-200 rounded w-1/3"></div>
<div className="grid grid-cols-4 gap-4">
{[1,2,3,4].map(i => <div key={i} className="h-24 bg-gray-200 rounded"></div>)}
</div>
</div>
</div>
)
}
return (
<div className="p-6 space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-gray-900">Compliance Training Engine</h1>
<p className="text-sm text-gray-500 mt-1">
Training-Module, Zuweisungen und Compliance-Schulungen verwalten
</p>
</div>
<button
onClick={handleCheckEscalation}
className="px-4 py-2 text-sm bg-orange-50 text-orange-700 border border-orange-200 rounded-lg hover:bg-orange-100"
>
Eskalation pruefen
</button>
</div>
{error && (
<div className="bg-red-50 border border-red-200 text-red-700 px-4 py-3 rounded-lg text-sm">
{error}
<button onClick={() => setError(null)} className="ml-2 underline">Schliessen</button>
</div>
)}
{/* Tabs */}
<div className="border-b border-gray-200">
<nav className="flex -mb-px space-x-6">
{tabs.map(tab => (
<button
key={tab.id}
onClick={() => setActiveTab(tab.id)}
className={`pb-3 px-1 text-sm font-medium border-b-2 transition-colors ${
activeTab === tab.id
? 'border-blue-500 text-blue-600'
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'
}`}
>
{tab.label}
</button>
))}
</nav>
</div>
{/* Tab Content */}
{activeTab === 'overview' && stats && (
<div className="space-y-6">
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
<KPICard label="Module" value={stats.total_modules} />
<KPICard label="Zuweisungen" value={stats.total_assignments} />
<KPICard label="Abschlussrate" value={`${stats.completion_rate.toFixed(1)}%`} color={stats.completion_rate >= 80 ? 'green' : stats.completion_rate >= 50 ? 'yellow' : 'red'} />
<KPICard label="Ueberfaellig" value={stats.overdue_count} color={stats.overdue_count > 0 ? 'red' : 'green'} />
</div>
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
<KPICard label="Ausstehend" value={stats.pending_count} />
<KPICard label="In Bearbeitung" value={stats.in_progress_count} />
<KPICard label="Avg. Quiz-Score" value={`${stats.avg_quiz_score.toFixed(1)}%`} />
<KPICard label="Deadlines (7d)" value={stats.upcoming_deadlines} color={stats.upcoming_deadlines > 5 ? 'yellow' : 'green'} />
</div>
{/* Status Bar */}
<div className="bg-white border rounded-lg p-4">
<h3 className="text-sm font-medium text-gray-700 mb-3">Status-Verteilung</h3>
{stats.total_assignments > 0 && (
<div className="flex gap-1 h-6 rounded-full overflow-hidden bg-gray-100">
{stats.completed_count > 0 && <div className="bg-green-500" style={{ width: `${(stats.completed_count / stats.total_assignments) * 100}%` }} title={`Abgeschlossen: ${stats.completed_count}`} />}
{stats.in_progress_count > 0 && <div className="bg-blue-500" style={{ width: `${(stats.in_progress_count / stats.total_assignments) * 100}%` }} title={`In Bearbeitung: ${stats.in_progress_count}`} />}
{stats.pending_count > 0 && <div className="bg-gray-400" style={{ width: `${(stats.pending_count / stats.total_assignments) * 100}%` }} title={`Ausstehend: ${stats.pending_count}`} />}
{stats.overdue_count > 0 && <div className="bg-red-500" style={{ width: `${(stats.overdue_count / stats.total_assignments) * 100}%` }} title={`Ueberfaellig: ${stats.overdue_count}`} />}
</div>
)}
<div className="flex gap-4 mt-2 text-xs text-gray-500">
<span className="flex items-center gap-1"><span className="w-3 h-3 rounded-full bg-green-500 inline-block" /> Abgeschlossen</span>
<span className="flex items-center gap-1"><span className="w-3 h-3 rounded-full bg-blue-500 inline-block" /> In Bearbeitung</span>
<span className="flex items-center gap-1"><span className="w-3 h-3 rounded-full bg-gray-400 inline-block" /> Ausstehend</span>
<span className="flex items-center gap-1"><span className="w-3 h-3 rounded-full bg-red-500 inline-block" /> Ueberfaellig</span>
</div>
</div>
{/* Deadlines */}
{deadlines.length > 0 && (
<div className="bg-white border rounded-lg p-4">
<h3 className="text-sm font-medium text-gray-700 mb-3">Naechste Deadlines</h3>
<div className="space-y-2">
{deadlines.slice(0, 5).map(d => (
<div key={d.assignment_id} className="flex items-center justify-between text-sm">
<div>
<span className="font-medium">{d.module_title}</span>
<span className="text-gray-500 ml-2">({d.user_name})</span>
</div>
<span className={`px-2 py-0.5 rounded text-xs ${
d.days_left <= 0 ? 'bg-red-100 text-red-700' :
d.days_left <= 7 ? 'bg-yellow-100 text-yellow-700' :
'bg-gray-100 text-gray-600'
}`}>
{d.days_left <= 0 ? `${Math.abs(d.days_left)} Tage ueberfaellig` : `${d.days_left} Tage`}
</span>
</div>
))}
</div>
</div>
)}
</div>
)}
{activeTab === 'modules' && (
<div className="space-y-4">
<div className="flex gap-3">
<select value={regulationFilter} onChange={e => setRegulationFilter(e.target.value)} className="px-3 py-1.5 text-sm border rounded-lg bg-white">
<option value="">Alle Bereiche</option>
{Object.entries(REGULATION_LABELS).map(([key, label]) => (
<option key={key} value={key}>{label}</option>
))}
</select>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{filteredModules.map(m => (
<div key={m.id} className="bg-white border rounded-lg p-4 hover:shadow-sm transition-shadow">
<div className="flex items-start justify-between">
<div>
<span className={`inline-block px-2 py-0.5 rounded text-xs font-medium ${REGULATION_COLORS[m.regulation_area]?.bg || 'bg-gray-100'} ${REGULATION_COLORS[m.regulation_area]?.text || 'text-gray-700'}`}>
{REGULATION_LABELS[m.regulation_area] || m.regulation_area}
</span>
<h3 className="mt-2 font-medium text-gray-900">{m.title}</h3>
<p className="text-xs text-gray-500 mt-0.5">{m.module_code}</p>
</div>
{m.nis2_relevant && (
<span className="text-xs px-1.5 py-0.5 bg-purple-100 text-purple-700 rounded">NIS2</span>
)}
</div>
{m.description && <p className="text-sm text-gray-600 mt-2 line-clamp-2">{m.description}</p>}
<div className="flex items-center gap-3 mt-3 text-xs text-gray-500">
<span>{m.duration_minutes} Min.</span>
<span>{FREQUENCY_LABELS[m.frequency_type]}</span>
<span>Quiz: {m.pass_threshold}%</span>
</div>
</div>
))}
</div>
{filteredModules.length === 0 && <p className="text-center text-gray-500 py-8">Keine Module gefunden</p>}
</div>
)}
{activeTab === 'matrix' && matrix && (
<div className="space-y-4">
<p className="text-sm text-gray-600">Compliance Training Matrix (CTM): Welche Rollen benoetigen welche Schulungsmodule</p>
<div className="overflow-x-auto">
<table className="w-full text-sm border-collapse">
<thead>
<tr className="bg-gray-50">
<th className="text-left p-2 border font-medium text-gray-700 w-48">Rolle</th>
<th className="text-left p-2 border font-medium text-gray-700">Module</th>
<th className="text-center p-2 border font-medium text-gray-700 w-20">Anzahl</th>
</tr>
</thead>
<tbody>
{ALL_ROLES.map(role => {
const entries = matrix.entries[role] || []
return (
<tr key={role} className="border-b hover:bg-gray-50">
<td className="p-2 border font-medium">
<span className="text-gray-900">{role}</span>
<span className="text-gray-500 ml-1 text-xs">{ROLE_LABELS[role]}</span>
</td>
<td className="p-2 border">
<div className="flex flex-wrap gap-1">
{entries.map(e => (
<span key={e.id || e.module_id} className={`inline-block px-2 py-0.5 rounded text-xs ${e.is_mandatory ? 'bg-blue-100 text-blue-700' : 'bg-gray-100 text-gray-600'}`} title={`${e.module_title} (${e.is_mandatory ? 'Pflicht' : 'Optional'})`}>
{e.module_code}
</span>
))}
{entries.length === 0 && <span className="text-gray-400 text-xs">Keine Module</span>}
</div>
</td>
<td className="p-2 border text-center">{entries.length}</td>
</tr>
)
})}
</tbody>
</table>
</div>
</div>
)}
{activeTab === 'assignments' && (
<div className="space-y-4">
<div className="flex gap-3">
<select value={statusFilter} onChange={e => setStatusFilter(e.target.value)} className="px-3 py-1.5 text-sm border rounded-lg bg-white">
<option value="">Alle Status</option>
{Object.entries(STATUS_LABELS).map(([key, label]) => (
<option key={key} value={key}>{label}</option>
))}
</select>
</div>
<div className="overflow-x-auto">
<table className="w-full text-sm border-collapse">
<thead>
<tr className="bg-gray-50">
<th className="text-left p-2 border font-medium text-gray-700">Modul</th>
<th className="text-left p-2 border font-medium text-gray-700">Mitarbeiter</th>
<th className="text-left p-2 border font-medium text-gray-700">Rolle</th>
<th className="text-center p-2 border font-medium text-gray-700">Fortschritt</th>
<th className="text-center p-2 border font-medium text-gray-700">Status</th>
<th className="text-center p-2 border font-medium text-gray-700">Quiz</th>
<th className="text-left p-2 border font-medium text-gray-700">Deadline</th>
<th className="text-center p-2 border font-medium text-gray-700">Eskalation</th>
</tr>
</thead>
<tbody>
{filteredAssignments.map(a => (
<tr key={a.id} className="border-b hover:bg-gray-50">
<td className="p-2 border">
<div className="font-medium">{a.module_title || a.module_code}</div>
<div className="text-xs text-gray-500">{a.module_code}</div>
</td>
<td className="p-2 border">
<div>{a.user_name}</div>
<div className="text-xs text-gray-500">{a.user_email}</div>
</td>
<td className="p-2 border text-xs">{a.role_code || '-'}</td>
<td className="p-2 border text-center">
<div className="flex items-center gap-2">
<div className="flex-1 bg-gray-200 rounded-full h-2">
<div className="bg-blue-500 h-2 rounded-full" style={{ width: `${a.progress_percent}%` }} />
</div>
<span className="text-xs w-8">{a.progress_percent}%</span>
</div>
</td>
<td className="p-2 border text-center">
<span className={`px-2 py-0.5 rounded text-xs ${STATUS_COLORS[a.status]?.bg || ''} ${STATUS_COLORS[a.status]?.text || ''}`}>
{STATUS_LABELS[a.status] || a.status}
</span>
</td>
<td className="p-2 border text-center text-xs">
{a.quiz_score != null ? (
<span className={`font-medium ${a.quiz_passed ? 'text-green-600' : 'text-red-600'}`}>{a.quiz_score.toFixed(0)}%</span>
) : '-'}
</td>
<td className="p-2 border text-xs">{new Date(a.deadline).toLocaleDateString('de-DE')}</td>
<td className="p-2 border text-center">
{a.escalation_level > 0 ? <span className="px-2 py-0.5 rounded text-xs bg-red-100 text-red-700">L{a.escalation_level}</span> : '-'}
</td>
</tr>
))}
</tbody>
</table>
</div>
{filteredAssignments.length === 0 && <p className="text-center text-gray-500 py-8">Keine Zuweisungen</p>}
</div>
)}
{activeTab === 'content' && (
<div className="space-y-6">
{/* 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>
<p className="text-xs text-gray-500 mb-4">Generiere Inhalte und Quiz-Fragen fuer alle Module auf einmal</p>
<div className="flex gap-3">
<button
onClick={handleBulkContent}
disabled={bulkGenerating}
className="px-4 py-2 text-sm bg-green-600 text-white rounded-lg hover:bg-green-700 disabled:opacity-50"
>
{bulkGenerating ? 'Generiere...' : 'Alle Inhalte generieren'}
</button>
<button
onClick={handleBulkQuiz}
disabled={bulkGenerating}
className="px-4 py-2 text-sm bg-indigo-600 text-white rounded-lg hover:bg-indigo-700 disabled:opacity-50"
>
{bulkGenerating ? 'Generiere...' : 'Alle Quizfragen generieren'}
</button>
</div>
{bulkResult && (
<div className="mt-4 p-3 bg-gray-50 rounded-lg text-sm">
<div className="flex gap-6">
<span className="text-green-700">Generiert: {bulkResult.generated}</span>
<span className="text-gray-500">Uebersprungen: {bulkResult.skipped}</span>
{bulkResult.errors.length > 0 && (
<span className="text-red-600">Fehler: {bulkResult.errors.length}</span>
)}
</div>
{bulkResult.errors.length > 0 && (
<div className="mt-2 text-xs text-red-600">
{bulkResult.errors.map((err, i) => <div key={i}>{err}</div>)}
</div>
)}
</div>
)}
</div>
<div className="bg-white border rounded-lg p-4">
<h3 className="text-sm font-medium text-gray-700 mb-3">LLM-Content-Generator</h3>
<p className="text-xs text-gray-500 mb-4">Generiere Schulungsinhalte und Quiz-Fragen automatisch via KI</p>
<div className="flex gap-3 items-end">
<div className="flex-1">
<label className="text-xs text-gray-600 block mb-1">Modul auswaehlen</label>
<select
value={selectedModuleId}
onChange={e => { setSelectedModuleId(e.target.value); setGeneratedContent(null); setModuleMedia([]); if (e.target.value) { handleLoadContent(e.target.value); loadModuleMedia(e.target.value); } }}
className="w-full px-3 py-2 text-sm border rounded-lg bg-white"
>
<option value="">Modul waehlen...</option>
{modules.map(m => <option key={m.id} value={m.id}>{m.module_code} - {m.title}</option>)}
</select>
</div>
<button onClick={handleGenerateContent} disabled={!selectedModuleId || generating} className="px-4 py-2 text-sm bg-blue-600 text-white rounded-lg hover:bg-blue-700 disabled:opacity-50">
{generating ? 'Generiere...' : 'Inhalt generieren'}
</button>
<button onClick={handleGenerateQuiz} disabled={!selectedModuleId || generating} className="px-4 py-2 text-sm bg-purple-600 text-white rounded-lg hover:bg-purple-700 disabled:opacity-50">
{generating ? 'Generiere...' : 'Quiz generieren'}
</button>
</div>
</div>
{generatedContent && (
<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">Generierter Inhalt (v{generatedContent.version})</h3>
<p className="text-xs text-gray-500">Generiert von: {generatedContent.generated_by} ({generatedContent.llm_model})</p>
</div>
{!generatedContent.is_published ? (
<button onClick={() => handlePublishContent(generatedContent.id)} className="px-3 py-1.5 text-xs bg-green-600 text-white rounded hover:bg-green-700">Veroeffentlichen</button>
) : (
<span className="px-3 py-1.5 text-xs bg-green-100 text-green-700 rounded">Veroeffentlicht</span>
)}
</div>
<div className="prose prose-sm max-w-none border rounded p-4 bg-gray-50 max-h-96 overflow-y-auto">
<pre className="whitespace-pre-wrap text-sm text-gray-800">{generatedContent.content_body}</pre>
</div>
</div>
)}
{/* Audio Player */}
{selectedModuleId && generatedContent?.is_published && (
<AudioPlayer
moduleId={selectedModuleId}
audio={moduleMedia.find(m => m.media_type === 'audio') || null}
onMediaUpdate={() => loadModuleMedia(selectedModuleId)}
/>
)}
{/* Video Player */}
{selectedModuleId && generatedContent?.is_published && (
<VideoPlayer
moduleId={selectedModuleId}
video={moduleMedia.find(m => m.media_type === 'video') || null}
onMediaUpdate={() => loadModuleMedia(selectedModuleId)}
/>
)}
{/* Script Preview */}
{selectedModuleId && generatedContent?.is_published && (
<ScriptPreview moduleId={selectedModuleId} />
)}
</div>
)}
{activeTab === 'audit' && (
<div className="space-y-4">
<div className="overflow-x-auto">
<table className="w-full text-sm border-collapse">
<thead>
<tr className="bg-gray-50">
<th className="text-left p-2 border font-medium text-gray-700">Zeitpunkt</th>
<th className="text-left p-2 border font-medium text-gray-700">Aktion</th>
<th className="text-left p-2 border font-medium text-gray-700">Entitaet</th>
<th className="text-left p-2 border font-medium text-gray-700">Details</th>
</tr>
</thead>
<tbody>
{auditLog.map(entry => (
<tr key={entry.id} className="border-b hover:bg-gray-50">
<td className="p-2 border text-xs text-gray-600">{new Date(entry.created_at).toLocaleString('de-DE')}</td>
<td className="p-2 border"><span className="px-2 py-0.5 rounded text-xs bg-gray-100 text-gray-700">{entry.action}</span></td>
<td className="p-2 border text-xs">{entry.entity_type}</td>
<td className="p-2 border text-xs text-gray-600">{JSON.stringify(entry.details).substring(0, 100)}</td>
</tr>
))}
</tbody>
</table>
</div>
{auditLog.length === 0 && <p className="text-center text-gray-500 py-8">Keine Audit-Eintraege</p>}
</div>
)}
</div>
)
}
function KPICard({ label, value, color }: { label: string; value: string | number; color?: string }) {
const colorMap: Record<string, string> = {
green: 'bg-green-50 border-green-200',
yellow: 'bg-yellow-50 border-yellow-200',
red: 'bg-red-50 border-red-200',
}
const textMap: Record<string, string> = {
green: 'text-green-700',
yellow: 'text-yellow-700',
red: 'text-red-700',
}
return (
<div className={`border rounded-lg p-4 ${color ? colorMap[color] || 'bg-white border-gray-200' : 'bg-white border-gray-200'}`}>
<p className="text-xs text-gray-500">{label}</p>
<p className={`text-2xl font-bold mt-1 ${color ? textMap[color] || 'text-gray-900' : 'text-gray-900'}`}>{value}</p>
</div>
)
}

View File

@@ -0,0 +1,105 @@
/**
* Training API Proxy - Catch-all route
* Proxies all /api/sdk/v1/training/* requests to ai-compliance-sdk backend
*/
import { NextRequest, NextResponse } from 'next/server'
const SDK_BACKEND_URL = process.env.SDK_API_URL || 'http://ai-compliance-sdk:8090'
async function proxyRequest(
request: NextRequest,
pathSegments: string[] | undefined,
method: string
) {
const pathStr = pathSegments?.join('/') || ''
const searchParams = request.nextUrl.searchParams.toString()
const basePath = `${SDK_BACKEND_URL}/sdk/v1/training`
const url = pathStr
? `${basePath}/${pathStr}${searchParams ? `?${searchParams}` : ''}`
: `${basePath}${searchParams ? `?${searchParams}` : ''}`
try {
const headers: HeadersInit = {
'Content-Type': 'application/json',
}
const headerNames = ['authorization', 'x-tenant-id', 'x-user-id', 'x-namespace-id', 'x-tenant-slug']
for (const name of headerNames) {
const value = request.headers.get(name)
if (value) {
headers[name] = value
}
}
const fetchOptions: RequestInit = {
method,
headers,
signal: AbortSignal.timeout(60000),
}
if (method === 'POST' || method === 'PUT') {
const body = await request.text()
if (body) {
fetchOptions.body = body
}
}
const response = await fetch(url, fetchOptions)
if (!response.ok) {
const errorText = await response.text()
let errorJson
try {
errorJson = JSON.parse(errorText)
} catch {
errorJson = { error: errorText }
}
return NextResponse.json(
{ error: `Backend Error: ${response.status}`, ...errorJson },
{ status: response.status }
)
}
const data = await response.json()
return NextResponse.json(data)
} catch (error) {
console.error('Training API proxy error:', error)
return NextResponse.json(
{ error: 'Verbindung zum SDK Backend fehlgeschlagen' },
{ status: 503 }
)
}
}
export async function GET(
request: NextRequest,
{ params }: { params: Promise<{ path?: string[] }> }
) {
const { path } = await params
return proxyRequest(request, path, 'GET')
}
export async function POST(
request: NextRequest,
{ params }: { params: Promise<{ path?: string[] }> }
) {
const { path } = await params
return proxyRequest(request, path, 'POST')
}
export async function PUT(
request: NextRequest,
{ params }: { params: Promise<{ path?: string[] }> }
) {
const { path } = await params
return proxyRequest(request, path, 'PUT')
}
export async function DELETE(
request: NextRequest,
{ params }: { params: Promise<{ path?: string[] }> }
) {
const { path } = await params
return proxyRequest(request, path, 'DELETE')
}

View File

@@ -0,0 +1,130 @@
'use client'
import { useState } from 'react'
import type { TrainingMedia } from '@/lib/sdk/training/types'
import { generateAudio, publishMedia } from '@/lib/sdk/training/api'
interface AudioPlayerProps {
moduleId: string
audio: TrainingMedia | null
onMediaUpdate: () => void
}
export default function AudioPlayer({ moduleId, audio, onMediaUpdate }: AudioPlayerProps) {
const [generating, setGenerating] = useState(false)
const [error, setError] = useState<string | null>(null)
async function handleGenerate() {
setGenerating(true)
setError(null)
try {
await generateAudio(moduleId)
onMediaUpdate()
} catch (e) {
setError(e instanceof Error ? e.message : 'Audio-Generierung fehlgeschlagen')
} finally {
setGenerating(false)
}
}
async function handlePublishToggle() {
if (!audio) return
try {
await publishMedia(audio.id, !audio.is_published)
onMediaUpdate()
} catch (e) {
setError(e instanceof Error ? e.message : 'Fehler beim Aendern des Status')
}
}
function formatDuration(seconds: number): string {
const mins = Math.floor(seconds / 60)
const secs = Math.floor(seconds % 60)
return `${mins}:${secs.toString().padStart(2, '0')}`
}
function formatSize(bytes: number): string {
if (bytes < 1024) return `${bytes} B`
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`
}
return (
<div className="border rounded-lg p-4 bg-white">
<div className="flex items-center justify-between mb-3">
<h4 className="text-sm font-medium text-gray-700">Audio</h4>
{!audio && (
<button
onClick={handleGenerate}
disabled={generating}
className="px-3 py-1.5 text-xs bg-blue-600 text-white rounded hover:bg-blue-700 disabled:opacity-50"
>
{generating ? 'Generiere Audio...' : 'Audio generieren'}
</button>
)}
</div>
{error && (
<div className="text-xs text-red-600 mb-2">{error}</div>
)}
{generating && !audio && (
<div className="flex items-center gap-2 text-sm text-gray-500">
<div className="animate-spin h-4 w-4 border-2 border-blue-500 border-t-transparent rounded-full" />
Audio wird generiert (kann einige Minuten dauern)...
</div>
)}
{audio && audio.status === 'processing' && (
<div className="flex items-center gap-2 text-sm text-gray-500">
<div className="animate-spin h-4 w-4 border-2 border-blue-500 border-t-transparent rounded-full" />
Audio wird verarbeitet...
</div>
)}
{audio && audio.status === 'failed' && (
<div className="space-y-2">
<div className="text-xs text-red-600">Generierung fehlgeschlagen: {audio.error_message}</div>
<button
onClick={handleGenerate}
disabled={generating}
className="px-3 py-1.5 text-xs bg-blue-600 text-white rounded hover:bg-blue-700 disabled:opacity-50"
>
Erneut versuchen
</button>
</div>
)}
{audio && audio.status === 'completed' && (
<div className="space-y-3">
<audio controls className="w-full" preload="none">
<source src={`/api/sdk/v1/training/media/${audio.id}/stream`} type="audio/mpeg" />
Ihr Browser unterstuetzt kein Audio.
</audio>
<div className="flex items-center justify-between text-xs text-gray-500">
<div className="flex gap-4">
<span>Dauer: {formatDuration(audio.duration_seconds)}</span>
<span>Groesse: {formatSize(audio.file_size_bytes)}</span>
<span>Stimme: {audio.voice_model}</span>
</div>
<div className="flex items-center gap-2">
<button
onClick={handlePublishToggle}
className={`px-2 py-1 rounded text-xs ${audio.is_published ? 'bg-green-100 text-green-700' : 'bg-gray-100 text-gray-600'}`}
>
{audio.is_published ? 'Veroeffentlicht' : 'Veroeffentlichen'}
</button>
<button
onClick={handleGenerate}
disabled={generating}
className="px-2 py-1 text-xs bg-gray-100 text-gray-600 rounded hover:bg-gray-200 disabled:opacity-50"
>
Neu generieren
</button>
</div>
</div>
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,86 @@
'use client'
import { useState } from 'react'
import type { VideoScript } from '@/lib/sdk/training/types'
import { previewVideoScript } from '@/lib/sdk/training/api'
interface ScriptPreviewProps {
moduleId: string
}
export default function ScriptPreview({ moduleId }: ScriptPreviewProps) {
const [script, setScript] = useState<VideoScript | null>(null)
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
async function handlePreview() {
setLoading(true)
setError(null)
try {
const result = await previewVideoScript(moduleId)
setScript(result as VideoScript)
} catch (e) {
setError(e instanceof Error ? e.message : 'Fehler bei der Script-Vorschau')
} finally {
setLoading(false)
}
}
return (
<div className="border rounded-lg p-4 bg-white">
<div className="flex items-center justify-between mb-3">
<h4 className="text-sm font-medium text-gray-700">Folien-Vorschau</h4>
<button
onClick={handlePreview}
disabled={loading}
className="px-3 py-1.5 text-xs bg-gray-600 text-white rounded hover:bg-gray-700 disabled:opacity-50"
>
{loading ? 'Generiere Vorschau...' : 'Script-Vorschau'}
</button>
</div>
{error && (
<div className="text-xs text-red-600 mb-2">{error}</div>
)}
{loading && (
<div className="flex items-center gap-2 text-sm text-gray-500">
<div className="animate-spin h-4 w-4 border-2 border-gray-500 border-t-transparent rounded-full" />
Folien-Script wird generiert...
</div>
)}
{script && (
<div className="space-y-3">
<h5 className="text-sm font-medium text-gray-900">{script.title}</h5>
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
{script.sections.map((section, i) => (
<div key={i} className="border rounded-lg p-3 bg-gray-50">
<div className="flex items-center gap-2 mb-2">
<span className="text-xs font-medium text-white bg-blue-900 rounded-full w-6 h-6 flex items-center justify-center">
{i + 1}
</span>
<h6 className="text-sm font-medium text-gray-800">{section.heading}</h6>
</div>
{section.text && (
<p className="text-xs text-gray-600 mb-2">{section.text}</p>
)}
{section.bullet_points && section.bullet_points.length > 0 && (
<ul className="space-y-1">
{section.bullet_points.map((bp, j) => (
<li key={j} className="text-xs text-gray-600 flex items-start gap-1">
<span className="text-blue-500 mt-0.5">{String.fromCharCode(8226)}</span>
{bp}
</li>
))}
</ul>
)}
</div>
))}
</div>
<p className="text-xs text-gray-400">{script.sections.length} Folien</p>
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,129 @@
'use client'
import { useState } from 'react'
import type { TrainingMedia } from '@/lib/sdk/training/types'
import { generateVideo, publishMedia } from '@/lib/sdk/training/api'
interface VideoPlayerProps {
moduleId: string
video: TrainingMedia | null
onMediaUpdate: () => void
}
export default function VideoPlayer({ moduleId, video, onMediaUpdate }: VideoPlayerProps) {
const [generating, setGenerating] = useState(false)
const [error, setError] = useState<string | null>(null)
async function handleGenerate() {
setGenerating(true)
setError(null)
try {
await generateVideo(moduleId)
onMediaUpdate()
} catch (e) {
setError(e instanceof Error ? e.message : 'Video-Generierung fehlgeschlagen')
} finally {
setGenerating(false)
}
}
async function handlePublishToggle() {
if (!video) return
try {
await publishMedia(video.id, !video.is_published)
onMediaUpdate()
} catch (e) {
setError(e instanceof Error ? e.message : 'Fehler beim Aendern des Status')
}
}
function formatDuration(seconds: number): string {
const mins = Math.floor(seconds / 60)
const secs = Math.floor(seconds % 60)
return `${mins}:${secs.toString().padStart(2, '0')}`
}
function formatSize(bytes: number): string {
if (bytes < 1024) return `${bytes} B`
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`
}
return (
<div className="border rounded-lg p-4 bg-white">
<div className="flex items-center justify-between mb-3">
<h4 className="text-sm font-medium text-gray-700">Praesentationsvideo</h4>
{!video && (
<button
onClick={handleGenerate}
disabled={generating}
className="px-3 py-1.5 text-xs bg-purple-600 text-white rounded hover:bg-purple-700 disabled:opacity-50"
>
{generating ? 'Generiere Video...' : 'Video generieren'}
</button>
)}
</div>
{error && (
<div className="text-xs text-red-600 mb-2">{error}</div>
)}
{generating && !video && (
<div className="flex items-center gap-2 text-sm text-gray-500">
<div className="animate-spin h-4 w-4 border-2 border-purple-500 border-t-transparent rounded-full" />
Video wird generiert (Folien + Audio, kann einige Minuten dauern)...
</div>
)}
{video && video.status === 'processing' && (
<div className="flex items-center gap-2 text-sm text-gray-500">
<div className="animate-spin h-4 w-4 border-2 border-purple-500 border-t-transparent rounded-full" />
Video wird verarbeitet...
</div>
)}
{video && video.status === 'failed' && (
<div className="space-y-2">
<div className="text-xs text-red-600">Generierung fehlgeschlagen: {video.error_message}</div>
<button
onClick={handleGenerate}
disabled={generating}
className="px-3 py-1.5 text-xs bg-purple-600 text-white rounded hover:bg-purple-700 disabled:opacity-50"
>
Erneut versuchen
</button>
</div>
)}
{video && video.status === 'completed' && (
<div className="space-y-3">
<video controls className="w-full rounded-lg bg-black" preload="none">
<source src={`/api/sdk/v1/training/media/${video.id}/stream`} type="video/mp4" />
Ihr Browser unterstuetzt kein Video.
</video>
<div className="flex items-center justify-between text-xs text-gray-500">
<div className="flex gap-4">
<span>Dauer: {formatDuration(video.duration_seconds)}</span>
<span>Groesse: {formatSize(video.file_size_bytes)}</span>
</div>
<div className="flex items-center gap-2">
<button
onClick={handlePublishToggle}
className={`px-2 py-1 rounded text-xs ${video.is_published ? 'bg-green-100 text-green-700' : 'bg-gray-100 text-gray-600'}`}
>
{video.is_published ? 'Veroeffentlicht' : 'Veroeffentlichen'}
</button>
<button
onClick={handleGenerate}
disabled={generating}
className="px-2 py-1 text-xs bg-gray-100 text-gray-600 rounded hover:bg-gray-200 disabled:opacity-50"
>
Neu generieren
</button>
</div>
</div>
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,311 @@
/**
* Training Engine API Client
* Communicates with the Go backend via Next.js API proxy at /api/sdk/v1/training/*
*/
import type {
ModuleListResponse,
AssignmentListResponse,
MatrixResponse,
AuditLogResponse,
EscalationResponse,
DeadlineListResponse,
TrainingModule,
TrainingAssignment,
ModuleContent,
QuizQuestion,
QuizAttempt,
QuizSubmitResponse,
TrainingStats,
TrainingMedia,
} from './types'
const BASE_URL = '/api/sdk/v1/training'
async function apiFetch<T>(path: string, options?: RequestInit): Promise<T> {
const res = await fetch(`${BASE_URL}${path}`, {
...options,
headers: {
'Content-Type': 'application/json',
'X-Tenant-ID': typeof window !== 'undefined'
? (localStorage.getItem('bp-tenant-id') || 'default')
: 'default',
...options?.headers,
},
})
if (!res.ok) {
const error = await res.json().catch(() => ({ error: res.statusText }))
throw new Error(error.error || `API Error: ${res.status}`)
}
return res.json()
}
// =============================================================================
// MODULES
// =============================================================================
export async function getModules(filters?: {
regulation_area?: string
frequency_type?: string
search?: string
}): Promise<ModuleListResponse> {
const params = new URLSearchParams()
if (filters?.regulation_area) params.set('regulation_area', filters.regulation_area)
if (filters?.frequency_type) params.set('frequency_type', filters.frequency_type)
if (filters?.search) params.set('search', filters.search)
const qs = params.toString()
return apiFetch<ModuleListResponse>(`/modules${qs ? `?${qs}` : ''}`)
}
export async function getModule(id: string): Promise<{
module: TrainingModule
content: ModuleContent | null
questions: QuizQuestion[]
}> {
return apiFetch(`/modules/${id}`)
}
export async function createModule(data: {
module_code: string
title: string
description?: string
regulation_area: string
frequency_type: string
duration_minutes?: number
pass_threshold?: number
}): Promise<TrainingModule> {
return apiFetch<TrainingModule>('/modules', {
method: 'POST',
body: JSON.stringify(data),
})
}
export async function updateModule(id: string, data: Record<string, unknown>): Promise<TrainingModule> {
return apiFetch<TrainingModule>(`/modules/${id}`, {
method: 'PUT',
body: JSON.stringify(data),
})
}
// =============================================================================
// MATRIX
// =============================================================================
export async function getMatrix(): Promise<MatrixResponse> {
return apiFetch<MatrixResponse>('/matrix')
}
export async function getMatrixForRole(role: string): Promise<{
role: string
label: string
entries: Array<{ module_id: string; module_code: string; module_title: string; is_mandatory: boolean; priority: number }>
total: number
}> {
return apiFetch(`/matrix/${role}`)
}
export async function setMatrixEntry(data: {
role_code: string
module_id: string
is_mandatory: boolean
priority: number
}): Promise<unknown> {
return apiFetch('/matrix', {
method: 'POST',
body: JSON.stringify(data),
})
}
export async function deleteMatrixEntry(role: string, moduleId: string): Promise<unknown> {
return apiFetch(`/matrix/${role}/${moduleId}`, { method: 'DELETE' })
}
// =============================================================================
// ASSIGNMENTS
// =============================================================================
export async function computeAssignments(data: {
user_id: string
user_name: string
user_email: string
roles: string[]
trigger?: string
}): Promise<{ assignments: TrainingAssignment[]; created: number }> {
return apiFetch('/assignments/compute', {
method: 'POST',
body: JSON.stringify(data),
})
}
export async function getAssignments(filters?: {
user_id?: string
module_id?: string
role?: string
status?: string
limit?: number
offset?: number
}): Promise<AssignmentListResponse> {
const params = new URLSearchParams()
if (filters?.user_id) params.set('user_id', filters.user_id)
if (filters?.module_id) params.set('module_id', filters.module_id)
if (filters?.role) params.set('role', filters.role)
if (filters?.status) params.set('status', filters.status)
if (filters?.limit) params.set('limit', String(filters.limit))
if (filters?.offset) params.set('offset', String(filters.offset))
const qs = params.toString()
return apiFetch<AssignmentListResponse>(`/assignments${qs ? `?${qs}` : ''}`)
}
export async function getAssignment(id: string): Promise<TrainingAssignment> {
return apiFetch<TrainingAssignment>(`/assignments/${id}`)
}
export async function startAssignment(id: string): Promise<{ status: string }> {
return apiFetch(`/assignments/${id}/start`, { method: 'POST' })
}
export async function updateAssignmentProgress(id: string, progress: number): Promise<{ status: string; progress: number }> {
return apiFetch(`/assignments/${id}/progress`, {
method: 'POST',
body: JSON.stringify({ progress }),
})
}
export async function completeAssignment(id: string): Promise<{ status: string }> {
return apiFetch(`/assignments/${id}/complete`, { method: 'POST' })
}
// =============================================================================
// QUIZ
// =============================================================================
export async function getQuiz(moduleId: string): Promise<{ questions: QuizQuestion[]; total: number }> {
return apiFetch(`/quiz/${moduleId}`)
}
export async function submitQuiz(moduleId: string, data: {
assignment_id: string
answers: Array<{ question_id: string; selected_index: number }>
duration_seconds?: number
}): Promise<QuizSubmitResponse> {
return apiFetch<QuizSubmitResponse>(`/quiz/${moduleId}/submit`, {
method: 'POST',
body: JSON.stringify(data),
})
}
export async function getQuizAttempts(assignmentId: string): Promise<{ attempts: QuizAttempt[]; total: number }> {
return apiFetch(`/quiz/attempts/${assignmentId}`)
}
// =============================================================================
// CONTENT GENERATION
// =============================================================================
export async function generateContent(moduleId: string, language?: string): Promise<ModuleContent> {
return apiFetch<ModuleContent>('/content/generate', {
method: 'POST',
body: JSON.stringify({ module_id: moduleId, language: language || 'de' }),
})
}
export async function generateQuiz(moduleId: string, count?: number): Promise<{ questions: QuizQuestion[]; total: number }> {
return apiFetch('/content/generate-quiz', {
method: 'POST',
body: JSON.stringify({ module_id: moduleId, count: count || 5 }),
})
}
export async function getContent(moduleId: string): Promise<ModuleContent> {
return apiFetch<ModuleContent>(`/content/${moduleId}`)
}
export async function publishContent(contentId: string): Promise<{ status: string }> {
return apiFetch(`/content/${contentId}/publish`, { method: 'POST' })
}
// =============================================================================
// DEADLINES & ESCALATION
// =============================================================================
export async function getDeadlines(limit?: number): Promise<DeadlineListResponse> {
const qs = limit ? `?limit=${limit}` : ''
return apiFetch<DeadlineListResponse>(`/deadlines${qs}`)
}
export async function getOverdueDeadlines(): Promise<DeadlineListResponse> {
return apiFetch<DeadlineListResponse>('/deadlines/overdue')
}
export async function checkEscalation(): Promise<EscalationResponse> {
return apiFetch<EscalationResponse>('/escalation/check', { method: 'POST' })
}
// =============================================================================
// AUDIT & STATS
// =============================================================================
export async function getAuditLog(filters?: {
action?: string
entity_type?: string
limit?: number
offset?: number
}): Promise<AuditLogResponse> {
const params = new URLSearchParams()
if (filters?.action) params.set('action', filters.action)
if (filters?.entity_type) params.set('entity_type', filters.entity_type)
if (filters?.limit) params.set('limit', String(filters.limit))
if (filters?.offset) params.set('offset', String(filters.offset))
const qs = params.toString()
return apiFetch<AuditLogResponse>(`/audit-log${qs ? `?${qs}` : ''}`)
}
export async function getStats(): Promise<TrainingStats> {
return apiFetch<TrainingStats>('/stats')
}
// =============================================================================
// BULK GENERATION
// =============================================================================
export async function generateAllContent(language?: string): Promise<{ generated: number; skipped: number; errors: string[] }> {
const qs = language ? `?language=${language}` : ''
return apiFetch(`/content/generate-all${qs}`, { method: 'POST' })
}
export async function generateAllQuizzes(): Promise<{ generated: number; skipped: number; errors: string[] }> {
return apiFetch('/content/generate-all-quiz', { method: 'POST' })
}
// =============================================================================
// MEDIA (Audio/Video)
// =============================================================================
export async function generateAudio(moduleId: string): Promise<TrainingMedia> {
return apiFetch<TrainingMedia>(`/content/${moduleId}/generate-audio`, { method: 'POST' })
}
export async function getModuleMedia(moduleId: string): Promise<{ media: TrainingMedia[]; total: number }> {
return apiFetch(`/media/${moduleId}`)
}
export async function getMediaURL(mediaId: string): Promise<{ bucket: string; object_key: string; mime_type: string }> {
return apiFetch(`/media/${mediaId}/url`)
}
export async function publishMedia(mediaId: string, publish?: boolean): Promise<{ status: string; is_published: boolean }> {
return apiFetch(`/media/${mediaId}/publish`, {
method: 'POST',
body: JSON.stringify({ publish: publish !== false }),
})
}
export async function generateVideo(moduleId: string): Promise<TrainingMedia> {
return apiFetch<TrainingMedia>(`/content/${moduleId}/generate-video`, { method: 'POST' })
}
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' })
}

View File

@@ -0,0 +1,309 @@
/**
* Compliance Training Engine Types
* TypeScript definitions for the Training Matrix, Assignments, Quiz, and Content
*/
// =============================================================================
// ENUMS / CONSTANTS
// =============================================================================
export type RegulationArea = 'dsgvo' | 'nis2' | 'iso27001' | 'ai_act' | 'geschgehg' | 'hinschg'
export type FrequencyType = 'onboarding' | 'annual' | 'event_trigger' | 'micro'
export type AssignmentStatus = 'pending' | 'in_progress' | 'completed' | 'overdue' | 'expired'
export type TriggerType = 'onboarding' | 'annual' | 'event' | 'manual'
export type Difficulty = 'easy' | 'medium' | 'hard'
export type AuditAction = 'assigned' | 'started' | 'completed' | 'quiz_submitted' | 'escalated' | 'certificate_issued' | 'content_generated'
export const REGULATION_LABELS: Record<RegulationArea, string> = {
dsgvo: 'DSGVO',
nis2: 'NIS-2',
iso27001: 'ISO 27001',
ai_act: 'AI Act',
geschgehg: 'GeschGehG',
hinschg: 'HinSchG',
}
export const REGULATION_COLORS: Record<RegulationArea, { bg: string; text: string; border: string }> = {
dsgvo: { bg: 'bg-blue-100', text: 'text-blue-700', border: 'border-blue-300' },
nis2: { bg: 'bg-purple-100', text: 'text-purple-700', border: 'border-purple-300' },
iso27001: { bg: 'bg-green-100', text: 'text-green-700', border: 'border-green-300' },
ai_act: { bg: 'bg-orange-100', text: 'text-orange-700', border: 'border-orange-300' },
geschgehg: { bg: 'bg-yellow-100', text: 'text-yellow-700', border: 'border-yellow-300' },
hinschg: { bg: 'bg-red-100', text: 'text-red-700', border: 'border-red-300' },
}
export const FREQUENCY_LABELS: Record<FrequencyType, string> = {
onboarding: 'Onboarding',
annual: 'Jaehrlich',
event_trigger: 'Ereignisbasiert',
micro: 'Micro-Training',
}
export const STATUS_LABELS: Record<AssignmentStatus, string> = {
pending: 'Ausstehend',
in_progress: 'In Bearbeitung',
completed: 'Abgeschlossen',
overdue: 'Ueberfaellig',
expired: 'Abgelaufen',
}
export const STATUS_COLORS: Record<AssignmentStatus, { bg: string; text: string }> = {
pending: { bg: 'bg-gray-100', text: 'text-gray-700' },
in_progress: { bg: 'bg-blue-100', text: 'text-blue-700' },
completed: { bg: 'bg-green-100', text: 'text-green-700' },
overdue: { bg: 'bg-red-100', text: 'text-red-700' },
expired: { bg: 'bg-gray-200', text: 'text-gray-500' },
}
export const ROLE_LABELS: Record<string, string> = {
R1: 'Geschaeftsfuehrung',
R2: 'IT-Leitung',
R3: 'Datenschutzbeauftragter',
R4: 'Informationssicherheitsbeauftragter',
R5: 'HR / Personal',
R6: 'Einkauf / Beschaffung',
R7: 'Fachabteilung',
R8: 'IT-Administration',
R9: 'Alle Mitarbeiter',
}
export const ALL_ROLES = ['R1', 'R2', 'R3', 'R4', 'R5', 'R6', 'R7', 'R8', 'R9'] as const
// =============================================================================
// MAIN ENTITIES
// =============================================================================
export interface TrainingModule {
id: string
tenant_id: string
academy_course_id?: string
module_code: string
title: string
description?: string
regulation_area: RegulationArea
nis2_relevant: boolean
iso_controls: string[]
frequency_type: FrequencyType
validity_days: number
risk_weight: number
content_type: string
duration_minutes: number
pass_threshold: number
is_active: boolean
sort_order: number
created_at: string
updated_at: string
}
export interface TrainingMatrixEntry {
id: string
tenant_id: string
role_code: string
module_id: string
is_mandatory: boolean
priority: number
created_at: string
module_code?: string
module_title?: string
}
export interface TrainingAssignment {
id: string
tenant_id: string
module_id: string
user_id: string
user_name: string
user_email: string
role_code?: string
trigger_type: TriggerType
trigger_event?: string
status: AssignmentStatus
progress_percent: number
quiz_score?: number
quiz_passed?: boolean
quiz_attempts: number
started_at?: string
completed_at?: string
deadline: string
certificate_id?: string
escalation_level: number
last_escalation_at?: string
enrollment_id?: string
created_at: string
updated_at: string
module_code?: string
module_title?: string
}
export interface QuizQuestion {
id: string
question: string
options: string[]
difficulty: Difficulty
}
export interface QuizAttempt {
id: string
assignment_id: string
user_id: string
answers: QuizAnswer[]
score: number
passed: boolean
correct_count: number
total_count: number
duration_seconds?: number
attempted_at: string
}
export interface QuizAnswer {
question_id: string
selected_index: number
correct: boolean
}
export interface ModuleContent {
id: string
module_id: string
version: number
content_format: string
content_body: string
summary?: string
generated_by?: string
llm_model?: string
is_published: boolean
reviewed_by?: string
reviewed_at?: string
created_at: string
updated_at: string
}
export interface AuditLogEntry {
id: string
tenant_id: string
user_id?: string
action: AuditAction
entity_type: string
entity_id?: string
details: Record<string, unknown>
ip_address?: string
created_at: string
}
export interface DeadlineInfo {
assignment_id: string
module_code: string
module_title: string
user_id: string
user_name: string
deadline: string
days_left: number
status: AssignmentStatus
}
export interface EscalationResult {
assignment_id: string
user_id: string
user_name: string
user_email: string
module_title: string
previous_level: number
new_level: number
days_overdue: number
escalation_label: string
}
export interface TrainingStats {
total_modules: number
total_assignments: number
completion_rate: number
overdue_count: number
pending_count: number
in_progress_count: number
completed_count: number
avg_quiz_score: number
avg_completion_days: number
upcoming_deadlines: number
}
// =============================================================================
// API RESPONSES
// =============================================================================
export interface ModuleListResponse {
modules: TrainingModule[]
total: number
}
export interface AssignmentListResponse {
assignments: TrainingAssignment[]
total: number
}
export interface MatrixResponse {
entries: Record<string, TrainingMatrixEntry[]>
roles: Record<string, string>
}
export interface AuditLogResponse {
entries: AuditLogEntry[]
total: number
}
export interface EscalationResponse {
results: EscalationResult[]
total_checked: number
escalated: number
}
export interface DeadlineListResponse {
deadlines: DeadlineInfo[]
total: number
}
export interface QuizSubmitResponse {
attempt_id: string
score: number
passed: boolean
correct_count: number
total_count: number
threshold: number
}
// =============================================================================
// MEDIA (Audio/Video)
// =============================================================================
export type MediaType = 'audio' | 'video'
export type MediaStatus = 'processing' | 'completed' | 'failed'
export interface TrainingMedia {
id: string
module_id: string
content_id?: string
media_type: MediaType
status: MediaStatus
bucket: string
object_key: string
file_size_bytes: number
duration_seconds: number
mime_type: string
voice_model: string
language: string
metadata: Record<string, unknown>
error_message?: string
generated_by: string
is_published: boolean
created_at: string
updated_at: string
}
export interface VideoScript {
title: string
sections: VideoScriptSection[]
}
export interface VideoScriptSection {
heading: string
text: string
bullet_points: string[]
}