Each page.tsx exceeded the 500-LOC hard cap. Extracted components and hooks into colocated _components/ and _hooks/ directories; page.tsx is now a thin orchestrator. - controls/page.tsx: 944 → 180 LOC; extracted ControlCard, AddControlForm, LoadingSkeleton, TransitionErrorBanner, StatsCards, FilterBar, RAGPanel into _components/ and useControlsData, useRAGSuggestions into _hooks/; types into _types.ts - training/page.tsx: 780 → 288 LOC; extracted ContentTab (inline content generator tab) into _components/ContentTab.tsx - control-provenance/page.tsx: 739 → 122 LOC; extracted MarkdownRenderer, UsageBadge, PermBadge, LicenseMatrix, SourceRegistry into _components/; PROVENANCE_SECTIONS static data into _data/provenance-sections.ts - iace/[projectId]/verification/page.tsx: 673 → 196 LOC; extracted StatusBadge, VerificationForm, CompleteModal, SuggestEvidenceModal, VerificationTable into _components/ Zero behavior changes; logic relocated verbatim. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
289 lines
14 KiB
TypeScript
289 lines
14 KiB
TypeScript
'use client'
|
|
|
|
import { useEffect, useState } from 'react'
|
|
import {
|
|
getModules, getMatrix, getAssignments, getStats, getDeadlines, getModuleMedia,
|
|
getAuditLog, generateContent, generateQuiz,
|
|
publishContent, checkEscalation, getContent,
|
|
generateAllContent, generateAllQuizzes,
|
|
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 { ContentTab } from './_components/ContentTab'
|
|
import OverviewTab from './_components/OverviewTab'
|
|
import ModulesTab from './_components/ModulesTab'
|
|
import MatrixTab from './_components/MatrixTab'
|
|
import AssignmentsTab from './_components/AssignmentsTab'
|
|
import AuditTab from './_components/AuditTab'
|
|
import ModuleCreateModal from './_components/ModuleCreateModal'
|
|
import ModuleEditDrawer from './_components/ModuleEditDrawer'
|
|
import MatrixAddModal from './_components/MatrixAddModal'
|
|
import AssignmentDetailDrawer from './_components/AssignmentDetailDrawer'
|
|
|
|
type Tab = 'overview' | 'modules' | 'matrix' | 'assignments' | 'content' | 'audit'
|
|
|
|
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' },
|
|
]
|
|
|
|
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 [interactiveGenerating, setInteractiveGenerating] = useState(false)
|
|
|
|
const [statusFilter, setStatusFilter] = useState<string>('')
|
|
const [regulationFilter, setRegulationFilter] = useState<string>('')
|
|
|
|
const [showModuleCreate, setShowModuleCreate] = useState(false)
|
|
const [selectedModule, setSelectedModule] = useState<TrainingModule | null>(null)
|
|
const [matrixAddRole, setMatrixAddRole] = useState<string | null>(null)
|
|
const [selectedAssignment, setSelectedAssignment] = useState<TrainingAssignment | null>(null)
|
|
const [escalationResult, setEscalationResult] = useState<{ total_checked: number; escalated: number } | null>(null)
|
|
|
|
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() }, [])
|
|
useEffect(() => { if (selectedModuleId) loadModuleMedia(selectedModuleId) }, [selectedModuleId])
|
|
|
|
async function loadData() {
|
|
setLoading(true); setError(null)
|
|
try {
|
|
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)
|
|
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)
|
|
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 { setLoading(false) }
|
|
}
|
|
|
|
async function loadModuleMedia(moduleId: string) {
|
|
try { const result = await getModuleMedia(moduleId); setModuleMedia(result.media) }
|
|
catch { setModuleMedia([]) }
|
|
}
|
|
|
|
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 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); setGeneratedContent(null); await loadData() }
|
|
catch (e) { setError(e instanceof Error ? e.message : 'Fehler beim Veroeffentlichen') }
|
|
}
|
|
|
|
async function handleCheckEscalation() {
|
|
try {
|
|
const result = await checkEscalation()
|
|
setEscalationResult({ total_checked: result.total_checked, escalated: result.escalated })
|
|
await loadData()
|
|
} catch (e) { setError(e instanceof Error ? e.message : 'Fehler bei der Eskalationspruefung') }
|
|
}
|
|
|
|
async function handleDeleteMatrixEntry(roleCode: string, moduleId: string) {
|
|
if (!window.confirm('Modulzuordnung entfernen?')) return
|
|
try { await deleteMatrixEntry(roleCode, moduleId); await loadData() }
|
|
catch (e) { setError(e instanceof Error ? e.message : 'Fehler beim Entfernen') }
|
|
}
|
|
|
|
async function handleBulkContent() {
|
|
setBulkGenerating(true); setBulkResult(null)
|
|
try {
|
|
const result = await generateAllContent('de')
|
|
setBulkResult({ generated: result.generated ?? 0, skipped: result.skipped ?? 0, errors: result.errors ?? [] })
|
|
await loadData()
|
|
} catch (e) { setError(e instanceof Error ? e.message : 'Fehler bei der Bulk-Generierung') }
|
|
finally { setBulkGenerating(false) }
|
|
}
|
|
|
|
async function handleBulkQuiz() {
|
|
setBulkGenerating(true); setBulkResult(null)
|
|
try {
|
|
const result = await generateAllQuizzes()
|
|
setBulkResult({ generated: result.generated ?? 0, skipped: result.skipped ?? 0, errors: result.errors ?? [] })
|
|
await loadData()
|
|
} catch (e) { setError(e instanceof Error ? e.message : 'Fehler bei der Bulk-Quiz-Generierung') }
|
|
finally { setBulkGenerating(false) }
|
|
}
|
|
|
|
async function handleCreateBlock(data: Parameters<typeof createBlockConfig>[0]) {
|
|
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 handleModuleSelect = (id: string) => {
|
|
setSelectedModuleId(id); setGeneratedContent(null); setModuleMedia([])
|
|
if (id) {
|
|
getContent(id).then(setGeneratedContent).catch(() => setGeneratedContent(null))
|
|
loadModuleMedia(id)
|
|
}
|
|
}
|
|
|
|
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 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>
|
|
)
|
|
}
|
|
|
|
return (
|
|
<div className="p-6 space-y-6">
|
|
<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>
|
|
)}
|
|
|
|
<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>
|
|
|
|
{activeTab === 'overview' && stats && (
|
|
<OverviewTab stats={stats} deadlines={deadlines} escalationResult={escalationResult} onDismissEscalation={() => setEscalationResult(null)} />
|
|
)}
|
|
{activeTab === 'modules' && (
|
|
<ModulesTab modules={modules.filter(m => !regulationFilter || m.regulation_area === regulationFilter)}
|
|
regulationFilter={regulationFilter} onRegulationFilterChange={setRegulationFilter}
|
|
onCreateClick={() => setShowModuleCreate(true)} onModuleClick={setSelectedModule} />
|
|
)}
|
|
{activeTab === 'matrix' && matrix && (
|
|
<MatrixTab matrix={matrix} onDeleteEntry={handleDeleteMatrixEntry} onAddEntry={setMatrixAddRole} />
|
|
)}
|
|
{activeTab === 'assignments' && (
|
|
<AssignmentsTab assignments={assignments.filter(a => !statusFilter || a.status === statusFilter)}
|
|
statusFilter={statusFilter} onStatusFilterChange={setStatusFilter} onAssignmentClick={setSelectedAssignment} />
|
|
)}
|
|
{activeTab === 'content' && (
|
|
<ContentTab
|
|
modules={modules} blocks={blocks} canonicalMeta={canonicalMeta}
|
|
selectedModuleId={selectedModuleId} onSelectedModuleIdChange={handleModuleSelect}
|
|
generatedContent={generatedContent} generating={generating}
|
|
bulkGenerating={bulkGenerating} bulkResult={bulkResult}
|
|
moduleMedia={moduleMedia} interactiveGenerating={interactiveGenerating}
|
|
blockPreview={blockPreview} blockPreviewId={blockPreviewId}
|
|
blockGenerating={blockGenerating} blockResult={blockResult}
|
|
showBlockCreate={showBlockCreate} onShowBlockCreate={setShowBlockCreate}
|
|
onGenerateContent={handleGenerateContent} onGenerateQuiz={handleGenerateQuiz}
|
|
onGenerateInteractiveVideo={handleGenerateInteractiveVideo}
|
|
onPublishContent={handlePublishContent}
|
|
onBulkContent={handleBulkContent} onBulkQuiz={handleBulkQuiz}
|
|
onPreviewBlock={handlePreviewBlock} onGenerateBlock={handleGenerateBlock}
|
|
onDeleteBlock={handleDeleteBlock} onCreateBlock={handleCreateBlock}
|
|
/>
|
|
)}
|
|
{activeTab === 'audit' && <AuditTab auditLog={auditLog} />}
|
|
|
|
{showModuleCreate && <ModuleCreateModal onClose={() => setShowModuleCreate(false)} onSaved={() => { setShowModuleCreate(false); loadData() }} />}
|
|
{selectedModule && <ModuleEditDrawer module={selectedModule} onClose={() => setSelectedModule(null)} onSaved={() => { setSelectedModule(null); loadData() }} />}
|
|
{matrixAddRole && <MatrixAddModal roleCode={matrixAddRole} modules={modules} onClose={() => setMatrixAddRole(null)} onSaved={() => { setMatrixAddRole(null); loadData() }} />}
|
|
{selectedAssignment && <AssignmentDetailDrawer assignment={selectedAssignment} onClose={() => setSelectedAssignment(null)} onSaved={() => { setSelectedAssignment(null); loadData() }} />}
|
|
</div>
|
|
)
|
|
}
|