refactor(admin): split training, control-provenance, iace/verification, training/learner, ControlDetail
All 5 files reduced below 500 LOC (hard cap) by extracting sub-components: - training/page.tsx: 780→278 LOC — imports existing _components/, adds BlocksSection - control-provenance/page.tsx: 739→145 LOC — extracts provenance-data.ts, ProvenanceHelpers, LicenseMatrix, SourceRegistry - iace/[projectId]/verification/page.tsx: 673→164 LOC — extracts VerificationForm, CompleteModal, SuggestEvidenceModal, VerificationTable - training/learner/page.tsx: 560→216 LOC — extracts AssignmentsList, ContentView, QuizView, CertificatesView - ControlDetail.tsx: 878→311 LOC — adds ControlSourceCitation, ControlTraceability, ControlRegulatorySection, ControlSimilarControls, ControlReviewActions siblings Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -2,81 +2,48 @@
|
|||||||
|
|
||||||
import { useState, useEffect, useCallback } from 'react'
|
import { useState, useEffect, useCallback } from 'react'
|
||||||
import {
|
import {
|
||||||
ArrowLeft, ExternalLink, BookOpen, Scale, FileText,
|
ArrowLeft, BookOpen, ExternalLink, FileText, Eye,
|
||||||
Eye, CheckCircle2, Trash2, Pencil, Clock,
|
Clock, ChevronLeft, SkipForward, Pencil, Trash2, Scale, GitMerge,
|
||||||
ChevronLeft, SkipForward, GitMerge, Search, Landmark,
|
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import {
|
import {
|
||||||
CanonicalControl, EFFORT_LABELS, BACKEND_URL,
|
CanonicalControl, EFFORT_LABELS, BACKEND_URL,
|
||||||
SeverityBadge, StateBadge, LicenseRuleBadge, VerificationMethodBadge, CategoryBadge, EvidenceTypeBadge, TargetAudienceBadge,
|
SeverityBadge, StateBadge, LicenseRuleBadge, VerificationMethodBadge, CategoryBadge,
|
||||||
ObligationTypeBadge, GenerationStrategyBadge, isEigenentwicklung,
|
EvidenceTypeBadge, TargetAudienceBadge, GenerationStrategyBadge, ObligationTypeBadge,
|
||||||
ExtractionMethodBadge, RegulationCountBadge,
|
isEigenentwicklung,
|
||||||
VERIFICATION_METHODS, CATEGORY_OPTIONS, EVIDENCE_TYPE_OPTIONS,
|
|
||||||
ObligationInfo, DocumentReference, MergedDuplicate, RegulationSummary,
|
ObligationInfo, DocumentReference, MergedDuplicate, RegulationSummary,
|
||||||
} from './helpers'
|
} from './helpers'
|
||||||
|
import { ControlSourceCitation } from './ControlSourceCitation'
|
||||||
|
import { ControlTraceability } from './ControlTraceability'
|
||||||
|
import { ControlRegulatorySection } from './ControlRegulatorySection'
|
||||||
|
import { ControlSimilarControls } from './ControlSimilarControls'
|
||||||
|
import { ControlReviewActions } from './ControlReviewActions'
|
||||||
|
|
||||||
interface SimilarControl {
|
interface SimilarControl {
|
||||||
control_id: string
|
control_id: string; title: string; severity: string; release_state: string;
|
||||||
title: string
|
tags: string[]; license_rule: number | null; verification_method: string | null;
|
||||||
severity: string
|
category: string | null; similarity: number;
|
||||||
release_state: string
|
|
||||||
tags: string[]
|
|
||||||
license_rule: number | null
|
|
||||||
verification_method: string | null
|
|
||||||
category: string | null
|
|
||||||
similarity: number
|
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ParentLink {
|
interface ParentLink {
|
||||||
parent_control_id: string
|
parent_control_id: string; parent_title: string; link_type: string; confidence: number;
|
||||||
parent_title: string
|
source_regulation: string | null; source_article: string | null;
|
||||||
link_type: string
|
parent_citation: Record<string, string> | null;
|
||||||
confidence: number
|
obligation: { text: string; action: string; object: string; normative_strength: string } | null;
|
||||||
source_regulation: string | null
|
|
||||||
source_article: string | null
|
|
||||||
parent_citation: Record<string, string> | null
|
|
||||||
obligation: {
|
|
||||||
text: string
|
|
||||||
action: string
|
|
||||||
object: string
|
|
||||||
normative_strength: string
|
|
||||||
} | null
|
|
||||||
}
|
}
|
||||||
|
|
||||||
interface TraceabilityData {
|
interface TraceabilityData {
|
||||||
control_id: string
|
control_id: string; title: string; is_atomic: boolean; parent_links: ParentLink[];
|
||||||
title: string
|
children: Array<{ control_id: string; title: string; category: string; severity: string; decomposition_method: string }>;
|
||||||
is_atomic: boolean
|
source_count: number; obligations?: ObligationInfo[]; obligation_count?: number;
|
||||||
parent_links: ParentLink[]
|
document_references?: DocumentReference[]; merged_duplicates?: MergedDuplicate[];
|
||||||
children: Array<{
|
merged_duplicates_count?: number; regulations_summary?: RegulationSummary[];
|
||||||
control_id: string
|
|
||||||
title: string
|
|
||||||
category: string
|
|
||||||
severity: string
|
|
||||||
decomposition_method: string
|
|
||||||
}>
|
|
||||||
source_count: number
|
|
||||||
// Extended provenance fields
|
|
||||||
obligations?: ObligationInfo[]
|
|
||||||
obligation_count?: number
|
|
||||||
document_references?: DocumentReference[]
|
|
||||||
merged_duplicates?: MergedDuplicate[]
|
|
||||||
merged_duplicates_count?: number
|
|
||||||
regulations_summary?: RegulationSummary[]
|
|
||||||
}
|
}
|
||||||
|
|
||||||
interface V1Match {
|
interface V1Match {
|
||||||
matched_control_id: string
|
matched_control_id: string; matched_title: string; matched_objective: string;
|
||||||
matched_title: string
|
matched_severity: string; matched_category: string; matched_source: string | null;
|
||||||
matched_objective: string
|
matched_article: string | null; matched_source_citation: Record<string, string> | null;
|
||||||
matched_severity: string
|
similarity_score: number; match_rank: number; match_method: string;
|
||||||
matched_category: string
|
|
||||||
matched_source: string | null
|
|
||||||
matched_article: string | null
|
|
||||||
matched_source_citation: Record<string, string> | null
|
|
||||||
similarity_score: number
|
|
||||||
match_rank: number
|
|
||||||
match_method: string
|
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ControlDetailProps {
|
interface ControlDetailProps {
|
||||||
@@ -88,7 +55,6 @@ interface ControlDetailProps {
|
|||||||
onRefresh?: () => void
|
onRefresh?: () => void
|
||||||
onNavigateToControl?: (controlId: string) => void
|
onNavigateToControl?: (controlId: string) => void
|
||||||
onCompare?: (ctrl: CanonicalControl, matches: V1Match[]) => void
|
onCompare?: (ctrl: CanonicalControl, matches: V1Match[]) => void
|
||||||
// Review mode navigation
|
|
||||||
reviewMode?: boolean
|
reviewMode?: boolean
|
||||||
reviewIndex?: number
|
reviewIndex?: number
|
||||||
reviewTotal?: number
|
reviewTotal?: number
|
||||||
@@ -97,19 +63,8 @@ interface ControlDetailProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function ControlDetail({
|
export function ControlDetail({
|
||||||
ctrl,
|
ctrl, onBack, onEdit, onDelete, onReview, onRefresh, onNavigateToControl, onCompare,
|
||||||
onBack,
|
reviewMode, reviewIndex = 0, reviewTotal = 0, onReviewPrev, onReviewNext,
|
||||||
onEdit,
|
|
||||||
onDelete,
|
|
||||||
onReview,
|
|
||||||
onRefresh,
|
|
||||||
onNavigateToControl,
|
|
||||||
onCompare,
|
|
||||||
reviewMode,
|
|
||||||
reviewIndex = 0,
|
|
||||||
reviewTotal = 0,
|
|
||||||
onReviewPrev,
|
|
||||||
onReviewNext,
|
|
||||||
}: ControlDetailProps) {
|
}: ControlDetailProps) {
|
||||||
const [similarControls, setSimilarControls] = useState<SimilarControl[]>([])
|
const [similarControls, setSimilarControls] = useState<SimilarControl[]>([])
|
||||||
const [loadingSimilar, setLoadingSimilar] = useState(false)
|
const [loadingSimilar, setLoadingSimilar] = useState(false)
|
||||||
@@ -124,14 +79,9 @@ export function ControlDetail({
|
|||||||
const loadTraceability = useCallback(async () => {
|
const loadTraceability = useCallback(async () => {
|
||||||
setLoadingTrace(true)
|
setLoadingTrace(true)
|
||||||
try {
|
try {
|
||||||
// Try provenance first (extended data), fall back to traceability
|
|
||||||
let res = await fetch(`${BACKEND_URL}?endpoint=provenance&id=${ctrl.control_id}`)
|
let res = await fetch(`${BACKEND_URL}?endpoint=provenance&id=${ctrl.control_id}`)
|
||||||
if (!res.ok) {
|
if (!res.ok) res = await fetch(`${BACKEND_URL}?endpoint=traceability&id=${ctrl.control_id}`)
|
||||||
res = await fetch(`${BACKEND_URL}?endpoint=traceability&id=${ctrl.control_id}`)
|
if (res.ok) setTraceability(await res.json())
|
||||||
}
|
|
||||||
if (res.ok) {
|
|
||||||
setTraceability(await res.json())
|
|
||||||
}
|
|
||||||
} catch { /* ignore */ }
|
} catch { /* ignore */ }
|
||||||
finally { setLoadingTrace(false) }
|
finally { setLoadingTrace(false) }
|
||||||
}, [ctrl.control_id])
|
}, [ctrl.control_id])
|
||||||
@@ -141,63 +91,45 @@ export function ControlDetail({
|
|||||||
setLoadingV1(true)
|
setLoadingV1(true)
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`${BACKEND_URL}?endpoint=v1-matches&id=${ctrl.control_id}`)
|
const res = await fetch(`${BACKEND_URL}?endpoint=v1-matches&id=${ctrl.control_id}`)
|
||||||
if (res.ok) setV1Matches(await res.json())
|
if (res.ok) setV1Matches(await res.json()); else setV1Matches([])
|
||||||
else setV1Matches([])
|
|
||||||
} catch { setV1Matches([]) }
|
} catch { setV1Matches([]) }
|
||||||
finally { setLoadingV1(false) }
|
finally { setLoadingV1(false) }
|
||||||
}, [ctrl.control_id, eigenentwicklung])
|
}, [ctrl.control_id, eigenentwicklung])
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
loadSimilarControls()
|
|
||||||
loadTraceability()
|
|
||||||
loadV1Matches()
|
|
||||||
setSelectedDuplicates(new Set())
|
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
||||||
}, [ctrl.control_id])
|
|
||||||
|
|
||||||
const loadSimilarControls = async () => {
|
const loadSimilarControls = async () => {
|
||||||
setLoadingSimilar(true)
|
setLoadingSimilar(true)
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`${BACKEND_URL}?endpoint=similar&id=${ctrl.control_id}`)
|
const res = await fetch(`${BACKEND_URL}?endpoint=similar&id=${ctrl.control_id}`)
|
||||||
if (res.ok) {
|
if (res.ok) setSimilarControls(await res.json())
|
||||||
setSimilarControls(await res.json())
|
|
||||||
}
|
|
||||||
} catch { /* ignore */ }
|
} catch { /* ignore */ }
|
||||||
finally { setLoadingSimilar(false) }
|
finally { setLoadingSimilar(false) }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadSimilarControls(); loadTraceability(); loadV1Matches()
|
||||||
|
setSelectedDuplicates(new Set())
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [ctrl.control_id])
|
||||||
|
|
||||||
const toggleDuplicate = (controlId: string) => {
|
const toggleDuplicate = (controlId: string) => {
|
||||||
setSelectedDuplicates(prev => {
|
setSelectedDuplicates(prev => { const n = new Set(prev); if (n.has(controlId)) n.delete(controlId); else n.add(controlId); return n })
|
||||||
const next = new Set(prev)
|
|
||||||
if (next.has(controlId)) next.delete(controlId)
|
|
||||||
else next.add(controlId)
|
|
||||||
return next
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleMergeDuplicates = async () => {
|
const handleMergeDuplicates = async () => {
|
||||||
if (selectedDuplicates.size === 0) return
|
if (selectedDuplicates.size === 0) return
|
||||||
if (!confirm(`${selectedDuplicates.size} Controls als Duplikate markieren und Tags/Anchors in ${ctrl.control_id} zusammenfuehren?`)) return
|
if (!confirm(`${selectedDuplicates.size} Controls als Duplikate markieren und Tags/Anchors in ${ctrl.control_id} zusammenfuehren?`)) return
|
||||||
|
|
||||||
setMerging(true)
|
setMerging(true)
|
||||||
try {
|
try {
|
||||||
// For each duplicate: mark as deprecated
|
|
||||||
for (const dupId of selectedDuplicates) {
|
for (const dupId of selectedDuplicates) {
|
||||||
await fetch(`${BACKEND_URL}?endpoint=update-control&id=${dupId}`, {
|
await fetch(`${BACKEND_URL}?endpoint=update-control&id=${dupId}`, {
|
||||||
method: 'PUT',
|
method: 'PUT', headers: { 'Content-Type': 'application/json' },
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({ release_state: 'deprecated' }),
|
body: JSON.stringify({ release_state: 'deprecated' }),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
// Refresh to show updated state
|
|
||||||
if (onRefresh) onRefresh()
|
if (onRefresh) onRefresh()
|
||||||
setSelectedDuplicates(new Set())
|
setSelectedDuplicates(new Set()); loadSimilarControls()
|
||||||
loadSimilarControls()
|
} catch { alert('Fehler beim Zusammenfuehren') }
|
||||||
} catch {
|
finally { setMerging(false) }
|
||||||
alert('Fehler beim Zusammenfuehren')
|
|
||||||
} finally {
|
|
||||||
setMerging(false)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -205,9 +137,7 @@ export function ControlDetail({
|
|||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="border-b border-gray-200 bg-white px-6 py-4 flex items-center justify-between">
|
<div className="border-b border-gray-200 bg-white px-6 py-4 flex items-center justify-between">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<button onClick={onBack} className="text-gray-400 hover:text-gray-600">
|
<button onClick={onBack} className="text-gray-400 hover:text-gray-600"><ArrowLeft className="w-5 h-5" /></button>
|
||||||
<ArrowLeft className="w-5 h-5" />
|
|
||||||
</button>
|
|
||||||
<div>
|
<div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<span className="text-sm font-mono text-purple-600 bg-purple-50 px-2 py-0.5 rounded">{ctrl.control_id}</span>
|
<span className="text-sm font-mono text-purple-600 bg-purple-50 px-2 py-0.5 rounded">{ctrl.control_id}</span>
|
||||||
@@ -227,13 +157,9 @@ export function ControlDetail({
|
|||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
{reviewMode && (
|
{reviewMode && (
|
||||||
<div className="flex items-center gap-1 mr-3">
|
<div className="flex items-center gap-1 mr-3">
|
||||||
<button onClick={onReviewPrev} disabled={reviewIndex === 0} className="p-1 text-gray-400 hover:text-gray-600 disabled:opacity-30">
|
<button onClick={onReviewPrev} disabled={reviewIndex === 0} className="p-1 text-gray-400 hover:text-gray-600 disabled:opacity-30"><ChevronLeft className="w-4 h-4" /></button>
|
||||||
<ChevronLeft className="w-4 h-4" />
|
|
||||||
</button>
|
|
||||||
<span className="text-xs text-gray-500 font-medium">{reviewIndex + 1} / {reviewTotal}</span>
|
<span className="text-xs text-gray-500 font-medium">{reviewIndex + 1} / {reviewTotal}</span>
|
||||||
<button onClick={onReviewNext} disabled={reviewIndex >= reviewTotal - 1} className="p-1 text-gray-400 hover:text-gray-600 disabled:opacity-30">
|
<button onClick={onReviewNext} disabled={reviewIndex >= reviewTotal - 1} className="p-1 text-gray-400 hover:text-gray-600 disabled:opacity-30"><SkipForward className="w-4 h-4" /></button>
|
||||||
<SkipForward className="w-4 h-4" />
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<button onClick={onEdit} className="px-3 py-1.5 text-sm text-gray-600 border border-gray-300 rounded-lg hover:bg-gray-50">
|
<button onClick={onEdit} className="px-3 py-1.5 text-sm text-gray-600 border border-gray-300 rounded-lg hover:bg-gray-50">
|
||||||
@@ -247,392 +173,19 @@ export function ControlDetail({
|
|||||||
|
|
||||||
{/* Content */}
|
{/* Content */}
|
||||||
<div className="flex-1 overflow-y-auto p-6 max-w-4xl mx-auto w-full space-y-6">
|
<div className="flex-1 overflow-y-auto p-6 max-w-4xl mx-auto w-full space-y-6">
|
||||||
{/* Objective */}
|
<section><h3 className="text-sm font-semibold text-gray-900 mb-2">Ziel</h3><p className="text-sm text-gray-700 leading-relaxed">{ctrl.objective}</p></section>
|
||||||
<section>
|
<section><h3 className="text-sm font-semibold text-gray-900 mb-2">Begruendung</h3><p className="text-sm text-gray-700 leading-relaxed">{ctrl.rationale}</p></section>
|
||||||
<h3 className="text-sm font-semibold text-gray-900 mb-2">Ziel</h3>
|
|
||||||
<p className="text-sm text-gray-700 leading-relaxed">{ctrl.objective}</p>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
{/* Rationale */}
|
<ControlSourceCitation ctrl={ctrl} />
|
||||||
<section>
|
|
||||||
<h3 className="text-sm font-semibold text-gray-900 mb-2">Begruendung</h3>
|
|
||||||
<p className="text-sm text-gray-700 leading-relaxed">{ctrl.rationale}</p>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
{/* Quellennachweis (Rule 1 + 2) — dynamic label based on source_type */}
|
|
||||||
{ctrl.source_citation && (
|
|
||||||
<section className={`border rounded-lg p-4 ${
|
|
||||||
ctrl.source_citation.source_type === 'law' ? 'bg-blue-50 border-blue-200' :
|
|
||||||
ctrl.source_citation.source_type === 'guideline' ? 'bg-indigo-50 border-indigo-200' :
|
|
||||||
'bg-teal-50 border-teal-200'
|
|
||||||
}`}>
|
|
||||||
<div className="flex items-center gap-2 mb-3">
|
|
||||||
<Scale className={`w-4 h-4 ${
|
|
||||||
ctrl.source_citation.source_type === 'law' ? 'text-blue-600' :
|
|
||||||
ctrl.source_citation.source_type === 'guideline' ? 'text-indigo-600' :
|
|
||||||
'text-teal-600'
|
|
||||||
}`} />
|
|
||||||
<h3 className={`text-sm font-semibold ${
|
|
||||||
ctrl.source_citation.source_type === 'law' ? 'text-blue-900' :
|
|
||||||
ctrl.source_citation.source_type === 'guideline' ? 'text-indigo-900' :
|
|
||||||
'text-teal-900'
|
|
||||||
}`}>{
|
|
||||||
ctrl.source_citation.source_type === 'law' ? 'Gesetzliche Grundlage' :
|
|
||||||
ctrl.source_citation.source_type === 'guideline' ? 'Behoerdliche Leitlinie' :
|
|
||||||
'Standard / Best Practice'
|
|
||||||
}</h3>
|
|
||||||
{ctrl.source_citation.source_type === 'law' && (
|
|
||||||
<span className="text-xs bg-blue-100 text-blue-700 px-2 py-0.5 rounded-full">Direkte gesetzliche Pflicht</span>
|
|
||||||
)}
|
|
||||||
{ctrl.source_citation.source_type === 'guideline' && (
|
|
||||||
<span className="text-xs bg-indigo-100 text-indigo-700 px-2 py-0.5 rounded-full">Aufsichtsbehoerdliche Empfehlung</span>
|
|
||||||
)}
|
|
||||||
{(ctrl.source_citation.source_type === 'standard' || (!ctrl.source_citation.source_type && ctrl.license_rule === 2)) && (
|
|
||||||
<span className="text-xs bg-teal-100 text-teal-700 px-2 py-0.5 rounded-full">Freiwilliger Standard</span>
|
|
||||||
)}
|
|
||||||
{(!ctrl.source_citation.source_type && ctrl.license_rule === 1) && (
|
|
||||||
<span className="text-xs bg-gray-100 text-gray-600 px-2 py-0.5 rounded-full">Noch nicht klassifiziert</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div className="flex items-start gap-3">
|
|
||||||
<div className="flex-1">
|
|
||||||
{ctrl.source_citation.source ? (
|
|
||||||
<p className="text-sm font-medium text-blue-900 mb-1">
|
|
||||||
{ctrl.source_citation.source}
|
|
||||||
{ctrl.source_citation.article && ` — ${ctrl.source_citation.article}`}
|
|
||||||
{ctrl.source_citation.paragraph && ` ${ctrl.source_citation.paragraph}`}
|
|
||||||
</p>
|
|
||||||
) : ctrl.generation_metadata?.source_regulation ? (
|
|
||||||
<p className="text-sm font-medium text-blue-900 mb-1">{String(ctrl.generation_metadata.source_regulation)}</p>
|
|
||||||
) : null}
|
|
||||||
{ctrl.source_citation.license && (
|
|
||||||
<p className="text-xs text-blue-600">Lizenz: {ctrl.source_citation.license}</p>
|
|
||||||
)}
|
|
||||||
{ctrl.source_citation.license_notice && (
|
|
||||||
<p className="text-xs text-blue-600 mt-0.5">{ctrl.source_citation.license_notice}</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
{ctrl.source_citation.url && (
|
|
||||||
<a
|
|
||||||
href={ctrl.source_citation.url}
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
className="flex items-center gap-1 text-xs text-blue-600 hover:text-blue-800 whitespace-nowrap"
|
|
||||||
>
|
|
||||||
<ExternalLink className="w-3.5 h-3.5" />Quelle
|
|
||||||
</a>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
{ctrl.source_original_text && (
|
|
||||||
<details className="mt-3">
|
|
||||||
<summary className="text-xs text-blue-600 cursor-pointer hover:text-blue-800">Originaltext anzeigen</summary>
|
|
||||||
<p className="text-xs text-gray-600 mt-2 p-2 bg-white rounded border border-blue-100 leading-relaxed max-h-40 overflow-y-auto whitespace-pre-wrap">
|
|
||||||
{ctrl.source_original_text}
|
|
||||||
</p>
|
|
||||||
</details>
|
|
||||||
)}
|
|
||||||
</section>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Regulatorische Abdeckung (Eigenentwicklung) */}
|
|
||||||
{eigenentwicklung && (
|
{eigenentwicklung && (
|
||||||
<section className="bg-orange-50 border border-orange-200 rounded-lg p-4">
|
<ControlRegulatorySection ctrl={ctrl} v1Matches={v1Matches} loadingV1={loadingV1}
|
||||||
<div className="flex items-center gap-2 mb-3">
|
onNavigateToControl={onNavigateToControl} onCompare={onCompare} />
|
||||||
<Scale className="w-4 h-4 text-orange-600" />
|
|
||||||
<h3 className="text-sm font-semibold text-orange-900">
|
|
||||||
Regulatorische Abdeckung
|
|
||||||
</h3>
|
|
||||||
{loadingV1 && <span className="text-xs text-orange-400">Laden...</span>}
|
|
||||||
</div>
|
|
||||||
{v1Matches.length > 0 ? (
|
|
||||||
<div className="space-y-2">
|
|
||||||
{v1Matches.map((match, i) => (
|
|
||||||
<div key={i} className="bg-white/60 border border-orange-100 rounded-lg p-3">
|
|
||||||
<div className="flex items-start justify-between gap-2">
|
|
||||||
<div className="flex-1 min-w-0">
|
|
||||||
<div className="flex items-center gap-2 flex-wrap mb-1">
|
|
||||||
{match.matched_source && (
|
|
||||||
<span className="text-xs font-semibold text-blue-800 bg-blue-100 px-1.5 py-0.5 rounded">
|
|
||||||
{match.matched_source}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
{match.matched_article && (
|
|
||||||
<span className="text-xs text-blue-700 bg-blue-50 px-1.5 py-0.5 rounded">
|
|
||||||
{match.matched_article}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
<span className={`text-xs font-medium px-1.5 py-0.5 rounded ${
|
|
||||||
match.similarity_score >= 0.85 ? 'bg-green-100 text-green-700' :
|
|
||||||
match.similarity_score >= 0.80 ? 'bg-yellow-100 text-yellow-700' :
|
|
||||||
'bg-gray-100 text-gray-600'
|
|
||||||
}`}>
|
|
||||||
{(match.similarity_score * 100).toFixed(0)}%
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<p className="text-sm text-gray-800">
|
|
||||||
{onNavigateToControl ? (
|
|
||||||
<button
|
|
||||||
onClick={() => onNavigateToControl(match.matched_control_id)}
|
|
||||||
className="font-mono text-xs text-purple-600 bg-purple-50 px-1.5 py-0.5 rounded hover:bg-purple-100 hover:underline mr-1.5"
|
|
||||||
>
|
|
||||||
{match.matched_control_id}
|
|
||||||
</button>
|
|
||||||
) : (
|
|
||||||
<span className="font-mono text-xs text-purple-600 bg-purple-50 px-1.5 py-0.5 rounded mr-1.5">
|
|
||||||
{match.matched_control_id}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
{match.matched_title}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
{onCompare && (
|
|
||||||
<button
|
|
||||||
onClick={() => onCompare(ctrl, v1Matches)}
|
|
||||||
className="text-xs text-orange-600 border border-orange-300 rounded px-2 py-1 hover:bg-orange-100 whitespace-nowrap flex-shrink-0"
|
|
||||||
>
|
|
||||||
Vergleichen
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
) : !loadingV1 ? (
|
|
||||||
<p className="text-sm text-orange-600">Keine regulatorische Abdeckung gefunden. Dieses Control ist eine reine Eigenentwicklung.</p>
|
|
||||||
) : null}
|
|
||||||
</section>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Rechtsgrundlagen / Traceability (atomic controls) */}
|
<ControlTraceability ctrl={ctrl} traceability={traceability} loadingTrace={loadingTrace}
|
||||||
{traceability && traceability.parent_links.length > 0 && (
|
onNavigateToControl={onNavigateToControl} />
|
||||||
<section className="bg-violet-50 border border-violet-200 rounded-lg p-4">
|
|
||||||
<div className="flex items-center gap-2 mb-3">
|
|
||||||
<Landmark className="w-4 h-4 text-violet-600" />
|
|
||||||
<h3 className="text-sm font-semibold text-violet-900">
|
|
||||||
Rechtsgrundlagen ({traceability.source_count} {traceability.source_count === 1 ? 'Quelle' : 'Quellen'})
|
|
||||||
</h3>
|
|
||||||
<ObligationTypeBadge type={ctrl.generation_metadata?.obligation_type as string} />
|
|
||||||
{traceability.regulations_summary && traceability.regulations_summary.map(rs => (
|
|
||||||
<span key={rs.regulation_code} className="inline-flex items-center px-1.5 py-0.5 rounded text-xs font-medium bg-violet-200 text-violet-800">
|
|
||||||
{rs.regulation_code}
|
|
||||||
</span>
|
|
||||||
))}
|
|
||||||
{loadingTrace && <span className="text-xs text-violet-400">Laden...</span>}
|
|
||||||
</div>
|
|
||||||
<div className="space-y-3">
|
|
||||||
{traceability.parent_links.map((link, i) => (
|
|
||||||
<div key={i} className="bg-white/60 border border-violet-100 rounded-lg p-3">
|
|
||||||
<div className="flex items-start gap-2">
|
|
||||||
<Scale className="w-4 h-4 text-violet-500 mt-0.5 flex-shrink-0" />
|
|
||||||
<div className="flex-1 min-w-0">
|
|
||||||
<div className="flex items-center gap-2 flex-wrap">
|
|
||||||
{link.source_regulation && (
|
|
||||||
<span className="text-sm font-semibold text-violet-900">{link.source_regulation}</span>
|
|
||||||
)}
|
|
||||||
{link.source_article && (
|
|
||||||
<span className="text-sm text-violet-700">{link.source_article}</span>
|
|
||||||
)}
|
|
||||||
{!link.source_regulation && link.parent_citation?.source && (
|
|
||||||
<span className="text-sm font-semibold text-violet-900">
|
|
||||||
{link.parent_citation.source}
|
|
||||||
{link.parent_citation.article && ` — ${link.parent_citation.article}`}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
<span className={`text-xs px-1.5 py-0.5 rounded ${
|
|
||||||
link.link_type === 'decomposition' ? 'bg-violet-100 text-violet-600' :
|
|
||||||
link.link_type === 'dedup_merge' ? 'bg-blue-100 text-blue-600' :
|
|
||||||
'bg-gray-100 text-gray-600'
|
|
||||||
}`}>
|
|
||||||
{link.link_type === 'decomposition' ? 'Ableitung' :
|
|
||||||
link.link_type === 'dedup_merge' ? 'Dedup' :
|
|
||||||
link.link_type}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<p className="text-xs text-violet-600 mt-1">
|
|
||||||
via{' '}
|
|
||||||
{onNavigateToControl ? (
|
|
||||||
<button
|
|
||||||
onClick={() => onNavigateToControl(link.parent_control_id)}
|
|
||||||
className="font-mono font-medium text-purple-700 bg-purple-50 px-1 py-0.5 rounded hover:bg-purple-100 hover:underline"
|
|
||||||
>
|
|
||||||
{link.parent_control_id}
|
|
||||||
</button>
|
|
||||||
) : (
|
|
||||||
<span className="font-mono font-medium text-purple-700 bg-purple-50 px-1 py-0.5 rounded">
|
|
||||||
{link.parent_control_id}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
{link.parent_title && (
|
|
||||||
<span className="text-violet-500 ml-1">— {link.parent_title}</span>
|
|
||||||
)}
|
|
||||||
</p>
|
|
||||||
{link.obligation && (
|
|
||||||
<p className="text-xs text-violet-500 mt-1.5 bg-violet-50 rounded p-2">
|
|
||||||
<span className={`inline-block mr-1.5 px-1.5 py-0.5 rounded text-xs font-medium ${
|
|
||||||
link.obligation.normative_strength === 'must' ? 'bg-red-100 text-red-700' :
|
|
||||||
link.obligation.normative_strength === 'should' ? 'bg-amber-100 text-amber-700' :
|
|
||||||
'bg-green-100 text-green-700'
|
|
||||||
}`}>
|
|
||||||
{link.obligation.normative_strength === 'must' ? 'MUSS' :
|
|
||||||
link.obligation.normative_strength === 'should' ? 'SOLL' : 'KANN'}
|
|
||||||
</span>
|
|
||||||
{link.obligation.text.slice(0, 200)}
|
|
||||||
{link.obligation.text.length > 200 ? '...' : ''}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Fallback: simple parent display when traceability not loaded yet */}
|
|
||||||
{ctrl.parent_control_uuid && (!traceability || traceability.parent_links.length === 0) && !loadingTrace && (
|
|
||||||
<section className="bg-violet-50 border border-violet-200 rounded-lg p-4">
|
|
||||||
<div className="flex items-center gap-2 mb-1">
|
|
||||||
<GitMerge className="w-4 h-4 text-violet-600" />
|
|
||||||
<h3 className="text-sm font-semibold text-violet-900">Atomares Control</h3>
|
|
||||||
<ObligationTypeBadge type={ctrl.generation_metadata?.obligation_type as string} />
|
|
||||||
</div>
|
|
||||||
<p className="text-sm text-violet-800">
|
|
||||||
Abgeleitet aus Eltern-Control{' '}
|
|
||||||
<span className="font-mono font-semibold text-purple-700 bg-purple-100 px-1.5 py-0.5 rounded">
|
|
||||||
{ctrl.parent_control_id || ctrl.parent_control_uuid}
|
|
||||||
</span>
|
|
||||||
{ctrl.parent_control_title && (
|
|
||||||
<span className="text-violet-700 ml-1">— {ctrl.parent_control_title}</span>
|
|
||||||
)}
|
|
||||||
</p>
|
|
||||||
</section>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Document References (atomic controls) */}
|
|
||||||
{traceability && traceability.is_atomic && traceability.document_references && traceability.document_references.length > 0 && (
|
|
||||||
<section className="bg-indigo-50 border border-indigo-200 rounded-lg p-4">
|
|
||||||
<div className="flex items-center gap-2 mb-3">
|
|
||||||
<FileText className="w-4 h-4 text-indigo-600" />
|
|
||||||
<h3 className="text-sm font-semibold text-indigo-900">
|
|
||||||
Original-Dokumente ({traceability.document_references.length})
|
|
||||||
</h3>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-2">
|
|
||||||
{traceability.document_references.map((dr, i) => (
|
|
||||||
<div key={i} className="flex items-center gap-2 text-sm bg-white/60 border border-indigo-100 rounded-lg p-2">
|
|
||||||
<span className="font-semibold text-indigo-900">{dr.regulation_code}</span>
|
|
||||||
{dr.article && <span className="text-indigo-700">{dr.article}</span>}
|
|
||||||
{dr.paragraph && <span className="text-indigo-600 text-xs">{dr.paragraph}</span>}
|
|
||||||
<span className="ml-auto flex items-center gap-1.5">
|
|
||||||
<ExtractionMethodBadge method={dr.extraction_method} />
|
|
||||||
{dr.confidence !== null && (
|
|
||||||
<span className="text-xs text-gray-500">{(dr.confidence * 100).toFixed(0)}%</span>
|
|
||||||
)}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Obligations (rich controls) */}
|
|
||||||
{traceability && !traceability.is_atomic && traceability.obligations && traceability.obligations.length > 0 && (
|
|
||||||
<section className="bg-amber-50 border border-amber-200 rounded-lg p-4">
|
|
||||||
<div className="flex items-center gap-2 mb-3">
|
|
||||||
<Scale className="w-4 h-4 text-amber-600" />
|
|
||||||
<h3 className="text-sm font-semibold text-amber-900">
|
|
||||||
Abgeleitete Pflichten ({traceability.obligation_count ?? traceability.obligations.length})
|
|
||||||
</h3>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-2">
|
|
||||||
{traceability.obligations.map((ob) => (
|
|
||||||
<div key={ob.candidate_id} className="bg-white/60 border border-amber-100 rounded-lg p-3">
|
|
||||||
<div className="flex items-center gap-2 mb-1 flex-wrap">
|
|
||||||
<span className="font-mono text-xs text-amber-700 bg-amber-100 px-1.5 py-0.5 rounded">{ob.candidate_id}</span>
|
|
||||||
<span className={`inline-block px-1.5 py-0.5 rounded text-xs font-medium ${
|
|
||||||
ob.normative_strength === 'must' ? 'bg-red-100 text-red-700' :
|
|
||||||
ob.normative_strength === 'should' ? 'bg-amber-100 text-amber-700' :
|
|
||||||
'bg-green-100 text-green-700'
|
|
||||||
}`}>
|
|
||||||
{ob.normative_strength === 'must' ? 'MUSS' :
|
|
||||||
ob.normative_strength === 'should' ? 'SOLL' : 'KANN'}
|
|
||||||
</span>
|
|
||||||
{ob.action && <span className="text-xs text-amber-600">{ob.action}</span>}
|
|
||||||
{ob.object && <span className="text-xs text-amber-500">→ {ob.object}</span>}
|
|
||||||
</div>
|
|
||||||
<p className="text-xs text-gray-700 leading-relaxed">
|
|
||||||
{ob.obligation_text.slice(0, 300)}
|
|
||||||
{ob.obligation_text.length > 300 ? '...' : ''}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Merged Duplicates */}
|
|
||||||
{traceability && traceability.merged_duplicates && traceability.merged_duplicates.length > 0 && (
|
|
||||||
<section className="bg-slate-50 border border-slate-200 rounded-lg p-4">
|
|
||||||
<div className="flex items-center gap-2 mb-3">
|
|
||||||
<GitMerge className="w-4 h-4 text-slate-600" />
|
|
||||||
<h3 className="text-sm font-semibold text-slate-900">
|
|
||||||
Zusammengefuehrte Duplikate ({traceability.merged_duplicates_count ?? traceability.merged_duplicates.length})
|
|
||||||
</h3>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-1.5">
|
|
||||||
{traceability.merged_duplicates.map((dup) => (
|
|
||||||
<div key={dup.control_id} className="flex items-center gap-2 text-sm">
|
|
||||||
{onNavigateToControl ? (
|
|
||||||
<button
|
|
||||||
onClick={() => onNavigateToControl(dup.control_id)}
|
|
||||||
className="font-mono text-xs text-purple-600 bg-purple-50 px-1.5 py-0.5 rounded hover:bg-purple-100 hover:underline"
|
|
||||||
>
|
|
||||||
{dup.control_id}
|
|
||||||
</button>
|
|
||||||
) : (
|
|
||||||
<span className="font-mono text-xs text-purple-600 bg-purple-50 px-1.5 py-0.5 rounded">{dup.control_id}</span>
|
|
||||||
)}
|
|
||||||
<span className="text-gray-700 flex-1 truncate">{dup.title}</span>
|
|
||||||
{dup.source_regulation && (
|
|
||||||
<span className="text-xs text-slate-500 bg-slate-100 px-1.5 py-0.5 rounded">{dup.source_regulation}</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Child controls (rich controls that have atomic children) */}
|
|
||||||
{traceability && traceability.children.length > 0 && (
|
|
||||||
<section className="bg-emerald-50 border border-emerald-200 rounded-lg p-4">
|
|
||||||
<div className="flex items-center gap-2 mb-3">
|
|
||||||
<GitMerge className="w-4 h-4 text-emerald-600" />
|
|
||||||
<h3 className="text-sm font-semibold text-emerald-900">
|
|
||||||
Abgeleitete Controls ({traceability.children.length})
|
|
||||||
</h3>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-1.5">
|
|
||||||
{traceability.children.map((child) => (
|
|
||||||
<div key={child.control_id} className="flex items-center gap-2 text-sm">
|
|
||||||
{onNavigateToControl ? (
|
|
||||||
<button
|
|
||||||
onClick={() => onNavigateToControl(child.control_id)}
|
|
||||||
className="font-mono text-xs text-purple-600 bg-purple-50 px-1.5 py-0.5 rounded hover:bg-purple-100 hover:underline"
|
|
||||||
>
|
|
||||||
{child.control_id}
|
|
||||||
</button>
|
|
||||||
) : (
|
|
||||||
<span className="font-mono text-xs text-purple-600 bg-purple-50 px-1.5 py-0.5 rounded">{child.control_id}</span>
|
|
||||||
)}
|
|
||||||
<span className="text-gray-700 flex-1 truncate">{child.title}</span>
|
|
||||||
<SeverityBadge severity={child.severity} />
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Impliziter Gesetzesbezug (Rule 3 — reformuliert, kein Originaltext) */}
|
|
||||||
{!ctrl.source_citation && ctrl.open_anchors.length > 0 && (
|
{!ctrl.source_citation && ctrl.open_anchors.length > 0 && (
|
||||||
<section className="bg-amber-50 border border-amber-200 rounded-lg p-3">
|
<section className="bg-amber-50 border border-amber-200 rounded-lg p-3">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
@@ -648,49 +201,31 @@ export function ControlDetail({
|
|||||||
</section>
|
</section>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Scope */}
|
|
||||||
{(ctrl.scope.platforms?.length || ctrl.scope.components?.length || ctrl.scope.data_classes?.length) ? (
|
{(ctrl.scope.platforms?.length || ctrl.scope.components?.length || ctrl.scope.data_classes?.length) ? (
|
||||||
<section>
|
<section>
|
||||||
<h3 className="text-sm font-semibold text-gray-900 mb-2">Geltungsbereich</h3>
|
<h3 className="text-sm font-semibold text-gray-900 mb-2">Geltungsbereich</h3>
|
||||||
<div className="grid grid-cols-3 gap-4 text-xs">
|
<div className="grid grid-cols-3 gap-4 text-xs">
|
||||||
{ctrl.scope.platforms?.length ? (
|
{ctrl.scope.platforms?.length ? <div><span className="text-gray-500">Plattformen:</span> <span className="text-gray-700">{ctrl.scope.platforms.join(', ')}</span></div> : null}
|
||||||
<div><span className="text-gray-500">Plattformen:</span> <span className="text-gray-700">{ctrl.scope.platforms.join(', ')}</span></div>
|
{ctrl.scope.components?.length ? <div><span className="text-gray-500">Komponenten:</span> <span className="text-gray-700">{ctrl.scope.components.join(', ')}</span></div> : null}
|
||||||
) : null}
|
{ctrl.scope.data_classes?.length ? <div><span className="text-gray-500">Datenklassen:</span> <span className="text-gray-700">{ctrl.scope.data_classes.join(', ')}</span></div> : null}
|
||||||
{ctrl.scope.components?.length ? (
|
|
||||||
<div><span className="text-gray-500">Komponenten:</span> <span className="text-gray-700">{ctrl.scope.components.join(', ')}</span></div>
|
|
||||||
) : null}
|
|
||||||
{ctrl.scope.data_classes?.length ? (
|
|
||||||
<div><span className="text-gray-500">Datenklassen:</span> <span className="text-gray-700">{ctrl.scope.data_classes.join(', ')}</span></div>
|
|
||||||
) : null}
|
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{/* Requirements */}
|
|
||||||
{ctrl.requirements.length > 0 && (
|
{ctrl.requirements.length > 0 && (
|
||||||
<section>
|
<section>
|
||||||
<h3 className="text-sm font-semibold text-gray-900 mb-2">Anforderungen</h3>
|
<h3 className="text-sm font-semibold text-gray-900 mb-2">Anforderungen</h3>
|
||||||
<ol className="list-decimal list-inside space-y-1">
|
<ol className="list-decimal list-inside space-y-1">{ctrl.requirements.map((r, i) => <li key={i} className="text-sm text-gray-700">{r}</li>)}</ol>
|
||||||
{ctrl.requirements.map((r, i) => (
|
|
||||||
<li key={i} className="text-sm text-gray-700">{r}</li>
|
|
||||||
))}
|
|
||||||
</ol>
|
|
||||||
</section>
|
</section>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Test Procedure */}
|
|
||||||
{ctrl.test_procedure.length > 0 && (
|
{ctrl.test_procedure.length > 0 && (
|
||||||
<section>
|
<section>
|
||||||
<h3 className="text-sm font-semibold text-gray-900 mb-2">Pruefverfahren</h3>
|
<h3 className="text-sm font-semibold text-gray-900 mb-2">Pruefverfahren</h3>
|
||||||
<ol className="list-decimal list-inside space-y-1">
|
<ol className="list-decimal list-inside space-y-1">{ctrl.test_procedure.map((s, i) => <li key={i} className="text-sm text-gray-700">{s}</li>)}</ol>
|
||||||
{ctrl.test_procedure.map((s, i) => (
|
|
||||||
<li key={i} className="text-sm text-gray-700">{s}</li>
|
|
||||||
))}
|
|
||||||
</ol>
|
|
||||||
</section>
|
</section>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Evidence — handles both {type, description} objects and plain strings */}
|
|
||||||
{ctrl.evidence.length > 0 && (
|
{ctrl.evidence.length > 0 && (
|
||||||
<section>
|
<section>
|
||||||
<h3 className="text-sm font-semibold text-gray-900 mb-2">Nachweise</h3>
|
<h3 className="text-sm font-semibold text-gray-900 mb-2">Nachweise</h3>
|
||||||
@@ -698,31 +233,23 @@ export function ControlDetail({
|
|||||||
{ctrl.evidence.map((ev, i) => (
|
{ctrl.evidence.map((ev, i) => (
|
||||||
<div key={i} className="flex items-start gap-2 text-sm text-gray-700">
|
<div key={i} className="flex items-start gap-2 text-sm text-gray-700">
|
||||||
<FileText className="w-4 h-4 text-gray-400 flex-shrink-0 mt-0.5" />
|
<FileText className="w-4 h-4 text-gray-400 flex-shrink-0 mt-0.5" />
|
||||||
{typeof ev === 'string' ? (
|
{typeof ev === 'string' ? <div>{ev}</div> : <div><span className="font-medium">{ev.type}:</span> {ev.description}</div>}
|
||||||
<div>{ev}</div>
|
|
||||||
) : (
|
|
||||||
<div><span className="font-medium">{ev.type}:</span> {ev.description}</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Meta */}
|
|
||||||
<section className="grid grid-cols-3 gap-4 text-xs text-gray-500">
|
<section className="grid grid-cols-3 gap-4 text-xs text-gray-500">
|
||||||
{ctrl.risk_score !== null && <div>Risiko-Score: <span className="text-gray-700 font-medium">{ctrl.risk_score}</span></div>}
|
{ctrl.risk_score !== null && <div>Risiko-Score: <span className="text-gray-700 font-medium">{ctrl.risk_score}</span></div>}
|
||||||
{ctrl.implementation_effort && <div>Aufwand: <span className="text-gray-700 font-medium">{EFFORT_LABELS[ctrl.implementation_effort] || ctrl.implementation_effort}</span></div>}
|
{ctrl.implementation_effort && <div>Aufwand: <span className="text-gray-700 font-medium">{EFFORT_LABELS[ctrl.implementation_effort] || ctrl.implementation_effort}</span></div>}
|
||||||
{ctrl.tags.length > 0 && (
|
{ctrl.tags.length > 0 && (
|
||||||
<div className="col-span-3 flex items-center gap-1 flex-wrap">
|
<div className="col-span-3 flex items-center gap-1 flex-wrap">
|
||||||
{ctrl.tags.map(t => (
|
{ctrl.tags.map(t => <span key={t} className="px-2 py-0.5 bg-gray-100 text-gray-600 rounded text-xs">{t}</span>)}
|
||||||
<span key={t} className="px-2 py-0.5 bg-gray-100 text-gray-600 rounded text-xs">{t}</span>
|
|
||||||
))}
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{/* Open Anchors */}
|
|
||||||
<section className="bg-green-50 border border-green-200 rounded-lg p-4">
|
<section className="bg-green-50 border border-green-200 rounded-lg p-4">
|
||||||
<div className="flex items-center gap-2 mb-3">
|
<div className="flex items-center gap-2 mb-3">
|
||||||
<BookOpen className="w-4 h-4 text-green-700" />
|
<BookOpen className="w-4 h-4 text-green-700" />
|
||||||
@@ -735,11 +262,7 @@ export function ControlDetail({
|
|||||||
<ExternalLink className="w-3.5 h-3.5 text-green-600 flex-shrink-0" />
|
<ExternalLink className="w-3.5 h-3.5 text-green-600 flex-shrink-0" />
|
||||||
<span className="font-medium text-green-800">{anchor.framework}</span>
|
<span className="font-medium text-green-800">{anchor.framework}</span>
|
||||||
<span className="text-green-700">{anchor.ref}</span>
|
<span className="text-green-700">{anchor.ref}</span>
|
||||||
{anchor.url && (
|
{anchor.url && <a href={anchor.url} target="_blank" rel="noopener noreferrer" className="text-green-600 hover:text-green-800 underline text-xs ml-auto">Link</a>}
|
||||||
<a href={anchor.url} target="_blank" rel="noopener noreferrer" className="text-green-600 hover:text-green-800 underline text-xs ml-auto">
|
|
||||||
Link
|
|
||||||
</a>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -748,7 +271,6 @@ export function ControlDetail({
|
|||||||
)}
|
)}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{/* Generation Metadata (internal) */}
|
|
||||||
{ctrl.generation_metadata && (
|
{ctrl.generation_metadata && (
|
||||||
<section className="bg-gray-50 border border-gray-200 rounded-lg p-4">
|
<section className="bg-gray-50 border border-gray-200 rounded-lg p-4">
|
||||||
<div className="flex items-center gap-2 mb-2">
|
<div className="flex items-center gap-2 mb-2">
|
||||||
@@ -756,21 +278,11 @@ export function ControlDetail({
|
|||||||
<h3 className="text-sm font-semibold text-gray-700">Generierungsdetails (intern)</h3>
|
<h3 className="text-sm font-semibold text-gray-700">Generierungsdetails (intern)</h3>
|
||||||
</div>
|
</div>
|
||||||
<div className="text-xs text-gray-600 space-y-1">
|
<div className="text-xs text-gray-600 space-y-1">
|
||||||
{ctrl.generation_metadata.processing_path && (
|
{ctrl.generation_metadata.processing_path && <p>Pfad: {String(ctrl.generation_metadata.processing_path)}</p>}
|
||||||
<p>Pfad: {String(ctrl.generation_metadata.processing_path)}</p>
|
{ctrl.generation_metadata.decomposition_method && <p>Methode: {String(ctrl.generation_metadata.decomposition_method)}</p>}
|
||||||
)}
|
{ctrl.generation_metadata.pass0b_model && <p>LLM: {String(ctrl.generation_metadata.pass0b_model)}</p>}
|
||||||
{ctrl.generation_metadata.decomposition_method && (
|
{ctrl.generation_metadata.obligation_type && <p>Obligation-Typ: {String(ctrl.generation_metadata.obligation_type)}</p>}
|
||||||
<p>Methode: {String(ctrl.generation_metadata.decomposition_method)}</p>
|
{ctrl.generation_metadata.similarity_status && <p className="text-red-600">Similarity: {String(ctrl.generation_metadata.similarity_status)}</p>}
|
||||||
)}
|
|
||||||
{ctrl.generation_metadata.pass0b_model && (
|
|
||||||
<p>LLM: {String(ctrl.generation_metadata.pass0b_model)}</p>
|
|
||||||
)}
|
|
||||||
{ctrl.generation_metadata.obligation_type && (
|
|
||||||
<p>Obligation-Typ: {String(ctrl.generation_metadata.obligation_type)}</p>
|
|
||||||
)}
|
|
||||||
{ctrl.generation_metadata.similarity_status && (
|
|
||||||
<p className="text-red-600">Similarity: {String(ctrl.generation_metadata.similarity_status)}</p>
|
|
||||||
)}
|
|
||||||
{Array.isArray(ctrl.generation_metadata.similar_controls) && (
|
{Array.isArray(ctrl.generation_metadata.similar_controls) && (
|
||||||
<div>
|
<div>
|
||||||
<p className="font-medium">Aehnliche Controls:</p>
|
<p className="font-medium">Aehnliche Controls:</p>
|
||||||
@@ -783,95 +295,16 @@ export function ControlDetail({
|
|||||||
</section>
|
</section>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Similar Controls (Dedup) */}
|
<ControlSimilarControls
|
||||||
<section className="bg-gray-50 border border-gray-200 rounded-lg p-4">
|
ctrl={ctrl} similarControls={similarControls} loadingSimilar={loadingSimilar}
|
||||||
<div className="flex items-center gap-2 mb-3">
|
selectedDuplicates={selectedDuplicates} merging={merging}
|
||||||
<Search className="w-4 h-4 text-gray-600" />
|
onToggleDuplicate={toggleDuplicate} onMergeDuplicates={handleMergeDuplicates}
|
||||||
<h3 className="text-sm font-semibold text-gray-800">Aehnliche Controls</h3>
|
|
||||||
{loadingSimilar && <span className="text-xs text-gray-400">Laden...</span>}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{similarControls.length > 0 ? (
|
|
||||||
<>
|
|
||||||
<div className="mb-3 p-2 bg-white border border-gray-100 rounded flex items-center gap-2">
|
|
||||||
<input type="radio" checked readOnly className="text-purple-600" />
|
|
||||||
<span className="text-sm font-medium text-purple-700">{ctrl.control_id} — {ctrl.title}</span>
|
|
||||||
<span className="text-xs text-gray-400 ml-auto">Behalten (Haupt-Control)</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-2">
|
|
||||||
{similarControls.map(sim => (
|
|
||||||
<div key={sim.control_id} className="p-2 bg-white border border-gray-100 rounded flex items-center gap-2">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
checked={selectedDuplicates.has(sim.control_id)}
|
|
||||||
onChange={() => toggleDuplicate(sim.control_id)}
|
|
||||||
className="text-red-600"
|
|
||||||
/>
|
/>
|
||||||
<span className="text-xs font-mono text-purple-600 bg-purple-50 px-1.5 py-0.5 rounded">{sim.control_id}</span>
|
|
||||||
<span className="text-sm text-gray-700 flex-1">{sim.title}</span>
|
|
||||||
<span className="text-xs font-medium text-amber-600 bg-amber-50 px-1.5 py-0.5 rounded">
|
|
||||||
{(sim.similarity * 100).toFixed(1)}%
|
|
||||||
</span>
|
|
||||||
<LicenseRuleBadge rule={sim.license_rule} />
|
|
||||||
<VerificationMethodBadge method={sim.verification_method} />
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{selectedDuplicates.size > 0 && (
|
<ControlReviewActions
|
||||||
<button
|
controlId={ctrl.control_id} releaseState={ctrl.release_state}
|
||||||
onClick={handleMergeDuplicates}
|
reviewMode={reviewMode} onReview={onReview} onEdit={onEdit}
|
||||||
disabled={merging}
|
/>
|
||||||
className="mt-3 flex items-center gap-1.5 px-3 py-1.5 text-sm text-white bg-red-600 rounded-lg hover:bg-red-700 disabled:opacity-50"
|
|
||||||
>
|
|
||||||
<GitMerge className="w-3.5 h-3.5" />
|
|
||||||
{merging ? 'Zusammenfuehren...' : `${selectedDuplicates.size} Duplikat(e) zusammenfuehren`}
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<p className="text-sm text-gray-500">
|
|
||||||
{loadingSimilar ? 'Suche aehnliche Controls...' : 'Keine aehnlichen Controls gefunden.'}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</section>
|
|
||||||
|
|
||||||
{/* Review Actions */}
|
|
||||||
{['needs_review', 'too_close', 'duplicate'].includes(ctrl.release_state) && (
|
|
||||||
<section className="bg-yellow-50 border border-yellow-200 rounded-lg p-4">
|
|
||||||
<div className="flex items-center gap-2 mb-3">
|
|
||||||
<Eye className="w-4 h-4 text-yellow-700" />
|
|
||||||
<h3 className="text-sm font-semibold text-yellow-900">Review erforderlich</h3>
|
|
||||||
{reviewMode && (
|
|
||||||
<span className="text-xs text-yellow-600 ml-auto">Review-Modus aktiv</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<button
|
|
||||||
onClick={() => onReview(ctrl.control_id, 'approve')}
|
|
||||||
className="px-3 py-1.5 text-sm text-white bg-green-600 rounded-lg hover:bg-green-700"
|
|
||||||
>
|
|
||||||
<CheckCircle2 className="w-3.5 h-3.5 inline mr-1" />
|
|
||||||
Akzeptieren
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={() => onReview(ctrl.control_id, 'reject')}
|
|
||||||
className="px-3 py-1.5 text-sm text-white bg-red-600 rounded-lg hover:bg-red-700"
|
|
||||||
>
|
|
||||||
<Trash2 className="w-3.5 h-3.5 inline mr-1" />
|
|
||||||
Ablehnen
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={onEdit}
|
|
||||||
className="px-3 py-1.5 text-sm text-gray-600 border border-gray-300 rounded-lg hover:bg-gray-50"
|
|
||||||
>
|
|
||||||
<Pencil className="w-3.5 h-3.5 inline mr-1" />
|
|
||||||
Ueberarbeiten
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -0,0 +1,85 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { Scale } from 'lucide-react'
|
||||||
|
import type { CanonicalControl } from './helpers'
|
||||||
|
|
||||||
|
interface V1Match {
|
||||||
|
matched_control_id: string
|
||||||
|
matched_title: string
|
||||||
|
matched_objective: string
|
||||||
|
matched_severity: string
|
||||||
|
matched_category: string
|
||||||
|
matched_source: string | null
|
||||||
|
matched_article: string | null
|
||||||
|
matched_source_citation: Record<string, string> | null
|
||||||
|
similarity_score: number
|
||||||
|
match_rank: number
|
||||||
|
match_method: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ControlRegulatorySectionProps {
|
||||||
|
ctrl: CanonicalControl
|
||||||
|
v1Matches: V1Match[]
|
||||||
|
loadingV1: boolean
|
||||||
|
onNavigateToControl?: (controlId: string) => void
|
||||||
|
onCompare?: (ctrl: CanonicalControl, matches: V1Match[]) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ControlRegulatorySection({
|
||||||
|
ctrl, v1Matches, loadingV1, onNavigateToControl, onCompare,
|
||||||
|
}: ControlRegulatorySectionProps) {
|
||||||
|
return (
|
||||||
|
<section className="bg-orange-50 border border-orange-200 rounded-lg p-4">
|
||||||
|
<div className="flex items-center gap-2 mb-3">
|
||||||
|
<Scale className="w-4 h-4 text-orange-600" />
|
||||||
|
<h3 className="text-sm font-semibold text-orange-900">Regulatorische Abdeckung</h3>
|
||||||
|
{loadingV1 && <span className="text-xs text-orange-400">Laden...</span>}
|
||||||
|
</div>
|
||||||
|
{v1Matches.length > 0 ? (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{v1Matches.map((match, i) => (
|
||||||
|
<div key={i} className="bg-white/60 border border-orange-100 rounded-lg p-3">
|
||||||
|
<div className="flex items-start justify-between gap-2">
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="flex items-center gap-2 flex-wrap mb-1">
|
||||||
|
{match.matched_source && (
|
||||||
|
<span className="text-xs font-semibold text-blue-800 bg-blue-100 px-1.5 py-0.5 rounded">{match.matched_source}</span>
|
||||||
|
)}
|
||||||
|
{match.matched_article && (
|
||||||
|
<span className="text-xs text-blue-700 bg-blue-50 px-1.5 py-0.5 rounded">{match.matched_article}</span>
|
||||||
|
)}
|
||||||
|
<span className={`text-xs font-medium px-1.5 py-0.5 rounded ${
|
||||||
|
match.similarity_score >= 0.85 ? 'bg-green-100 text-green-700' :
|
||||||
|
match.similarity_score >= 0.80 ? 'bg-yellow-100 text-yellow-700' : 'bg-gray-100 text-gray-600'
|
||||||
|
}`}>
|
||||||
|
{(match.similarity_score * 100).toFixed(0)}%
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-gray-800">
|
||||||
|
{onNavigateToControl ? (
|
||||||
|
<button onClick={() => onNavigateToControl(match.matched_control_id)}
|
||||||
|
className="font-mono text-xs text-purple-600 bg-purple-50 px-1.5 py-0.5 rounded hover:bg-purple-100 hover:underline mr-1.5">
|
||||||
|
{match.matched_control_id}
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<span className="font-mono text-xs text-purple-600 bg-purple-50 px-1.5 py-0.5 rounded mr-1.5">{match.matched_control_id}</span>
|
||||||
|
)}
|
||||||
|
{match.matched_title}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{onCompare && (
|
||||||
|
<button onClick={() => onCompare(ctrl, v1Matches)}
|
||||||
|
className="text-xs text-orange-600 border border-orange-300 rounded px-2 py-1 hover:bg-orange-100 whitespace-nowrap flex-shrink-0">
|
||||||
|
Vergleichen
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : !loadingV1 ? (
|
||||||
|
<p className="text-sm text-orange-600">Keine regulatorische Abdeckung gefunden. Dieses Control ist eine reine Eigenentwicklung.</p>
|
||||||
|
) : null}
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { Eye, CheckCircle2, Trash2, Pencil } from 'lucide-react'
|
||||||
|
|
||||||
|
interface ControlReviewActionsProps {
|
||||||
|
controlId: string
|
||||||
|
releaseState: string
|
||||||
|
reviewMode?: boolean
|
||||||
|
onReview: (controlId: string, action: string) => void
|
||||||
|
onEdit: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ControlReviewActions({
|
||||||
|
controlId, releaseState, reviewMode, onReview, onEdit,
|
||||||
|
}: ControlReviewActionsProps) {
|
||||||
|
if (!['needs_review', 'too_close', 'duplicate'].includes(releaseState)) return null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="bg-yellow-50 border border-yellow-200 rounded-lg p-4">
|
||||||
|
<div className="flex items-center gap-2 mb-3">
|
||||||
|
<Eye className="w-4 h-4 text-yellow-700" />
|
||||||
|
<h3 className="text-sm font-semibold text-yellow-900">Review erforderlich</h3>
|
||||||
|
{reviewMode && <span className="text-xs text-yellow-600 ml-auto">Review-Modus aktiv</span>}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<button onClick={() => onReview(controlId, 'approve')} className="px-3 py-1.5 text-sm text-white bg-green-600 rounded-lg hover:bg-green-700">
|
||||||
|
<CheckCircle2 className="w-3.5 h-3.5 inline mr-1" />Akzeptieren
|
||||||
|
</button>
|
||||||
|
<button onClick={() => onReview(controlId, 'reject')} className="px-3 py-1.5 text-sm text-white bg-red-600 rounded-lg hover:bg-red-700">
|
||||||
|
<Trash2 className="w-3.5 h-3.5 inline mr-1" />Ablehnen
|
||||||
|
</button>
|
||||||
|
<button onClick={onEdit} className="px-3 py-1.5 text-sm text-gray-600 border border-gray-300 rounded-lg hover:bg-gray-50">
|
||||||
|
<Pencil className="w-3.5 h-3.5 inline mr-1" />Ueberarbeiten
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { UsageBadge } from './ProvenanceHelpers'
|
||||||
|
|
||||||
|
interface LicenseInfo {
|
||||||
|
license_id: string
|
||||||
|
name: string
|
||||||
|
terms_url: string | null
|
||||||
|
commercial_use: string
|
||||||
|
ai_training_restriction: string | null
|
||||||
|
tdm_allowed_under_44b: string | null
|
||||||
|
deletion_required: boolean
|
||||||
|
notes: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export function LicenseMatrix({ licenses, loading }: { licenses: LicenseInfo[]; loading: boolean }) {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<h2 className="text-xl font-bold text-gray-900 mb-4">Lizenz-Matrix</h2>
|
||||||
|
<p className="text-sm text-gray-600 mb-4">Uebersicht aller Lizenzen mit ihren erlaubten Nutzungsarten.</p>
|
||||||
|
{loading ? (
|
||||||
|
<div className="animate-pulse h-32 bg-gray-100 rounded" />
|
||||||
|
) : (
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full text-sm border-collapse">
|
||||||
|
<thead>
|
||||||
|
<tr className="bg-gray-50">
|
||||||
|
<th className="text-left px-3 py-2 border-b font-medium text-gray-600">Lizenz</th>
|
||||||
|
<th className="text-left px-3 py-2 border-b font-medium text-gray-600">Kommerziell</th>
|
||||||
|
<th className="text-left px-3 py-2 border-b font-medium text-gray-600">AI-Training</th>
|
||||||
|
<th className="text-left px-3 py-2 border-b font-medium text-gray-600">TDM (§44b)</th>
|
||||||
|
<th className="text-left px-3 py-2 border-b font-medium text-gray-600">Loeschpflicht</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{licenses.map(lic => (
|
||||||
|
<tr key={lic.license_id} className="hover:bg-gray-50">
|
||||||
|
<td className="px-3 py-2 border-b">
|
||||||
|
<div className="font-medium text-gray-900">{lic.license_id}</div>
|
||||||
|
<div className="text-xs text-gray-500">{lic.name}</div>
|
||||||
|
</td>
|
||||||
|
<td className="px-3 py-2 border-b"><UsageBadge value={lic.commercial_use} /></td>
|
||||||
|
<td className="px-3 py-2 border-b"><UsageBadge value={lic.ai_training_restriction || 'n/a'} /></td>
|
||||||
|
<td className="px-3 py-2 border-b"><UsageBadge value={lic.tdm_allowed_under_44b || 'unclear'} /></td>
|
||||||
|
<td className="px-3 py-2 border-b">
|
||||||
|
{lic.deletion_required
|
||||||
|
? <span className="text-red-600 text-xs font-medium">Ja</span>
|
||||||
|
: <span className="text-green-600 text-xs font-medium">Nein</span>}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { CheckCircle2, Lock } from 'lucide-react'
|
||||||
|
|
||||||
|
export function UsageBadge({ value }: { value: string }) {
|
||||||
|
const config: Record<string, { bg: string; label: string }> = {
|
||||||
|
allowed: { bg: 'bg-green-100 text-green-800', label: 'Erlaubt' },
|
||||||
|
restricted: { bg: 'bg-yellow-100 text-yellow-800', label: 'Eingeschraenkt' },
|
||||||
|
prohibited: { bg: 'bg-red-100 text-red-800', label: 'Verboten' },
|
||||||
|
unclear: { bg: 'bg-gray-100 text-gray-600', label: 'Unklar' },
|
||||||
|
yes: { bg: 'bg-green-100 text-green-800', label: 'Ja' },
|
||||||
|
no: { bg: 'bg-red-100 text-red-800', label: 'Nein' },
|
||||||
|
'n/a': { bg: 'bg-gray-100 text-gray-400', label: 'k.A.' },
|
||||||
|
}
|
||||||
|
const c = config[value] || config.unclear
|
||||||
|
return <span className={`inline-flex px-1.5 py-0.5 rounded text-xs font-medium ${c.bg}`}>{c.label}</span>
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PermBadge({ label, allowed }: { label: string; allowed: boolean }) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
{allowed ? <CheckCircle2 className="w-3 h-3 text-green-500" /> : <Lock className="w-3 h-3 text-red-400" />}
|
||||||
|
<span className={`text-xs ${allowed ? 'text-green-700' : 'text-red-500'}`}>{label}</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function MarkdownRenderer({ content }: { content: string }) {
|
||||||
|
let html = content
|
||||||
|
.replace(/&/g, '&')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>')
|
||||||
|
|
||||||
|
html = html.replace(
|
||||||
|
/^```[\w]*\n([\s\S]*?)^```$/gm,
|
||||||
|
(_m, code: string) => `<pre class="bg-gray-50 border rounded p-3 my-3 text-xs font-mono overflow-x-auto whitespace-pre">${code.trimEnd()}</pre>`
|
||||||
|
)
|
||||||
|
|
||||||
|
html = html.replace(
|
||||||
|
/^(\|.+\|)\n(\|[\s:|-]+\|)\n((?:\|.+\|\n?)*)/gm,
|
||||||
|
(_m, header: string, _sep: string, body: string) => {
|
||||||
|
const ths = header.split('|').filter((c: string) => c.trim()).map((c: string) =>
|
||||||
|
`<th class="px-3 py-2 text-left text-xs font-medium text-gray-500 uppercase border-b">${c.trim()}</th>`
|
||||||
|
).join('')
|
||||||
|
const rows = body.trim().split('\n').map((row: string) => {
|
||||||
|
const tds = row.split('|').filter((c: string) => c.trim()).map((c: string) =>
|
||||||
|
`<td class="px-3 py-2 text-sm text-gray-700 border-b border-gray-100">${c.trim()}</td>`
|
||||||
|
).join('')
|
||||||
|
return `<tr>${tds}</tr>`
|
||||||
|
}).join('')
|
||||||
|
return `<table class="w-full border-collapse my-3 text-sm"><thead><tr>${ths}</tr></thead><tbody>${rows}</tbody></table>`
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
html = html.replace(/^### (.+)$/gm, '<h4 class="text-sm font-semibold text-gray-800 mt-4 mb-2">$1</h4>')
|
||||||
|
html = html.replace(/^## (.+)$/gm, '<h3 class="text-base font-semibold text-gray-900 mt-5 mb-2">$1</h3>')
|
||||||
|
html = html.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>')
|
||||||
|
html = html.replace(/`([^`]+)`/g, '<code class="bg-gray-100 px-1 py-0.5 rounded text-xs font-mono">$1</code>')
|
||||||
|
html = html.replace(/^- (.+)$/gm, '<li class="ml-4 text-sm text-gray-700 list-disc">$1</li>')
|
||||||
|
html = html.replace(/((?:<li[^>]*>.*<\/li>\n?)+)/g, '<ul class="my-2 space-y-1">$1</ul>')
|
||||||
|
html = html.replace(/^(\d+)\. (.+)$/gm, '<li class="ml-4 text-sm text-gray-700 list-decimal">$2</li>')
|
||||||
|
html = html.replace(/^(?!<[hultdp]|$)(.+)$/gm, '<p class="text-sm text-gray-700 my-2">$1</p>')
|
||||||
|
|
||||||
|
return <div dangerouslySetInnerHTML={{ __html: html }} />
|
||||||
|
}
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { ExternalLink } from 'lucide-react'
|
||||||
|
import { PermBadge } from './ProvenanceHelpers'
|
||||||
|
|
||||||
|
interface SourceInfo {
|
||||||
|
source_id: string
|
||||||
|
title: string
|
||||||
|
publisher: string
|
||||||
|
url: string | null
|
||||||
|
version_label: string | null
|
||||||
|
language: string
|
||||||
|
license_id: string
|
||||||
|
license_name: string
|
||||||
|
commercial_use: string
|
||||||
|
allowed_analysis: boolean
|
||||||
|
allowed_store_excerpt: boolean
|
||||||
|
allowed_ship_embeddings: boolean
|
||||||
|
allowed_ship_in_product: boolean
|
||||||
|
vault_retention_days: number
|
||||||
|
vault_access_tier: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SourceRegistry({ sources, loading }: { sources: SourceInfo[]; loading: boolean }) {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<h2 className="text-xl font-bold text-gray-900 mb-4">Quellenregister</h2>
|
||||||
|
<p className="text-sm text-gray-600 mb-4">Alle registrierten Quellen mit ihren Berechtigungen.</p>
|
||||||
|
{loading ? (
|
||||||
|
<div className="animate-pulse h-32 bg-gray-100 rounded" />
|
||||||
|
) : (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{sources.map(src => (
|
||||||
|
<div key={src.source_id} className="bg-white border border-gray-200 rounded-lg p-4">
|
||||||
|
<div className="flex items-start justify-between mb-2">
|
||||||
|
<div>
|
||||||
|
<h3 className="text-sm font-medium text-gray-900">{src.title}</h3>
|
||||||
|
<p className="text-xs text-gray-500">{src.publisher} — {src.license_name}</p>
|
||||||
|
</div>
|
||||||
|
{src.url && (
|
||||||
|
<a href={src.url} target="_blank" rel="noopener noreferrer"
|
||||||
|
className="flex items-center gap-1 text-xs text-blue-600 hover:text-blue-800">
|
||||||
|
<ExternalLink className="w-3 h-3" />Quelle
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-3 mt-2">
|
||||||
|
<PermBadge label="Analyse" allowed={src.allowed_analysis} />
|
||||||
|
<PermBadge label="Excerpt" allowed={src.allowed_store_excerpt} />
|
||||||
|
<PermBadge label="Embeddings" allowed={src.allowed_ship_embeddings} />
|
||||||
|
<PermBadge label="Produkt" allowed={src.allowed_ship_in_product} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,398 @@
|
|||||||
|
export const PROVENANCE_SECTIONS = [
|
||||||
|
{
|
||||||
|
id: 'methodology',
|
||||||
|
title: 'Methodik der Control-Erstellung',
|
||||||
|
content: `## Unabhaengige Formulierung
|
||||||
|
|
||||||
|
Alle Controls in der Canonical Control Library wurden **eigenstaendig formuliert** und folgen einer
|
||||||
|
**unabhaengigen Taxonomie**. Es werden keine proprietaeren Bezeichner, Nummern oder Strukturen
|
||||||
|
aus geschuetzten Quellen uebernommen.
|
||||||
|
|
||||||
|
### Dreistufiger Prozess
|
||||||
|
|
||||||
|
1. **Offene Recherche** — Identifikation von Security-Anforderungen aus oeffentlichen, frei zugaenglichen
|
||||||
|
Frameworks (OWASP, NIST, ENISA). Jede Anforderung wird aus mindestens 2 unabhaengigen offenen Quellen belegt.
|
||||||
|
|
||||||
|
2. **Eigenstaendige Formulierung** — Jedes Control wird mit eigener Sprache, eigener Struktur und eigener
|
||||||
|
Taxonomie (z.B. AUTH-001, NET-001) verfasst. Kein Copy-Paste, keine Paraphrase geschuetzter Texte.
|
||||||
|
|
||||||
|
3. **Too-Close-Pruefung** — Automatisierte Aehnlichkeitspruefung gegen Quelltexte mit 5 Metriken
|
||||||
|
(Token Overlap, N-Gram Jaccard, Embedding Cosine, LCS Ratio, Exact-Phrase). Nur Controls mit
|
||||||
|
Status PASS oder WARN (+ Human Review) werden freigegeben.
|
||||||
|
|
||||||
|
### Rechtliche Grundlage
|
||||||
|
|
||||||
|
- **UrhG §44b** — Text & Data Mining erlaubt fuer Analyse; Kopien werden danach geloescht
|
||||||
|
- **UrhG §23** — Hinreichender Abstand zum Originalwerk durch eigene Formulierung
|
||||||
|
- **BSI Nutzungsbedingungen** — Kommerzielle Nutzung nur mit Zustimmung; wir nutzen BSI-Dokumente
|
||||||
|
ausschliesslich als Analysegrundlage, nicht im Produkt`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'filters',
|
||||||
|
title: 'Filter in der Control Library',
|
||||||
|
content: `## Dropdown-Filter
|
||||||
|
|
||||||
|
Die Control Library bietet 7 Filter-Dropdowns, um die ueber 3.000 Controls effizient zu durchsuchen:
|
||||||
|
|
||||||
|
### Schweregrad (Severity)
|
||||||
|
|
||||||
|
| Stufe | Farbe | Bedeutung |
|
||||||
|
|-------|-------|-----------|
|
||||||
|
| **Kritisch** | Rot | Sicherheitskritische Controls — Verstoesse fuehren zu schwerwiegenden Risiken |
|
||||||
|
| **Hoch** | Orange | Wichtige Controls — sollten zeitnah umgesetzt werden |
|
||||||
|
| **Mittel** | Gelb | Standardmaessige Controls — empfohlene Umsetzung |
|
||||||
|
| **Niedrig** | Gruen | Nice-to-have Controls — zusaetzliche Haertung |
|
||||||
|
|
||||||
|
### Domain
|
||||||
|
|
||||||
|
Das Praefix der Control-ID (z.B. \`AUTH-001\`, \`SEC-042\`). Kennzeichnet den thematischen Bereich.
|
||||||
|
Die haeufigsten Domains:
|
||||||
|
|
||||||
|
| Domain | Anzahl | Thema |
|
||||||
|
|--------|--------|-------|
|
||||||
|
| SEC | ~700 | Allgemeine Sicherheit, Systemhaertung |
|
||||||
|
| COMP | ~470 | Compliance, Regulierung, Nachweispflichten |
|
||||||
|
| DATA | ~400 | Datenschutz, Datenklassifizierung, DSGVO |
|
||||||
|
| AI | ~290 | KI-Regulierung (AI Act, Transparenz, Erklaerbarkeit) |
|
||||||
|
| LOG | ~230 | Logging, Monitoring, SIEM |
|
||||||
|
| AUTH | ~200 | Authentifizierung, Zugriffskontrolle |
|
||||||
|
| NET | ~150 | Netzwerksicherheit, Transport, Firewall |
|
||||||
|
| CRYP | ~90 | Kryptographie, Schluesselmanagement |
|
||||||
|
| ACC | ~25 | Zugriffskontrolle (Access Control) |
|
||||||
|
| INC | ~25 | Incident Response, Vorfallmanagement |
|
||||||
|
|
||||||
|
Zusaetzlich existieren spezialisierte Domains wie CRA, ARC (Architektur), API, PKI, SUP (Supply Chain) u.v.m.
|
||||||
|
|
||||||
|
### Status (Release State)
|
||||||
|
|
||||||
|
| Status | Bedeutung |
|
||||||
|
|--------|-----------|
|
||||||
|
| **Draft** | Entwurf — noch nicht freigegeben |
|
||||||
|
| **Approved** | Freigegeben fuer Kunden |
|
||||||
|
| **Review noetig** | Muss manuell geprueft werden |
|
||||||
|
| **Zu aehnlich** | Too-Close-Check hat Warnung ausgeloest |
|
||||||
|
| **Duplikat** | Wurde als Duplikat eines anderen Controls erkannt |
|
||||||
|
|
||||||
|
### Nachweis (Verification Method)
|
||||||
|
|
||||||
|
| Methode | Farbe | Beschreibung |
|
||||||
|
|---------|-------|-------------|
|
||||||
|
| **Code Review** | Blau | Nachweis durch Quellcode-Inspektion |
|
||||||
|
| **Dokument** | Amber | Nachweis durch Richtlinien, Prozesse, Schulungen |
|
||||||
|
| **Tool** | Teal | Nachweis durch automatisierte Scans/Monitoring |
|
||||||
|
| **Hybrid** | Lila | Kombination aus mehreren Methoden |
|
||||||
|
|
||||||
|
### Kategorie
|
||||||
|
|
||||||
|
Thematische Einordnung (17 Kategorien). Kategorien sind **thematisch**, Domains **strukturell**.
|
||||||
|
Ein AUTH-Control kann z.B. die Kategorie "Netzwerksicherheit" haben.
|
||||||
|
|
||||||
|
### Zielgruppe (Target Audience)
|
||||||
|
|
||||||
|
| Zielgruppe | Bedeutung |
|
||||||
|
|------------|-----------|
|
||||||
|
| **Unternehmen** | Fuer Endkunden/Firmen relevant |
|
||||||
|
| **Behoerden** | Spezifisch fuer oeffentliche Verwaltung |
|
||||||
|
| **Anbieter** | Fuer SaaS/Plattform-Anbieter |
|
||||||
|
| **Alle** | Allgemein anwendbar |
|
||||||
|
|
||||||
|
### Dokumentenursprung (Source)
|
||||||
|
|
||||||
|
Filtert nach der Quelldokument-Herkunft des Controls. Zeigt alle Quellen sortiert nach
|
||||||
|
Haeufigkeit. Die wichtigsten Quellen:
|
||||||
|
|
||||||
|
| Quelle | Typ |
|
||||||
|
|--------|-----|
|
||||||
|
| KI-Verordnung (EU) 2024/1689 | EU-Recht |
|
||||||
|
| Cyber Resilience Act (EU) 2024/2847 | EU-Recht |
|
||||||
|
| DSGVO (EU) 2016/679 | EU-Recht |
|
||||||
|
| NIS2-Richtlinie (EU) 2022/2555 | EU-Recht |
|
||||||
|
| NIST SP 800-53, CSF 2.0, SSDF | US-Standards |
|
||||||
|
| OWASP Top 10, ASVS, SAMM | Open Source |
|
||||||
|
| ENISA Guidelines | EU-Agentur |
|
||||||
|
| CISA Secure by Design | US-Behoerde |
|
||||||
|
| BDSG, TKG, GewO, HGB | Deutsche Gesetze |
|
||||||
|
| EDPB Leitlinien | EU Datenschutz |`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'badges',
|
||||||
|
title: 'Badges & Lizenzregeln',
|
||||||
|
content: `## Badges in der Control Library
|
||||||
|
|
||||||
|
Jedes Control zeigt mehrere farbige Badges:
|
||||||
|
|
||||||
|
### Lizenzregel-Badge (Rule 1 / 2 / 3)
|
||||||
|
|
||||||
|
Die Lizenzregel bestimmt, wie ein Control erstellt und genutzt werden darf:
|
||||||
|
|
||||||
|
| Badge | Farbe | Regel | Bedeutung |
|
||||||
|
|-------|-------|-------|-----------|
|
||||||
|
| **Free Use** | Gruen | Rule 1 | Quelle ist Public Domain oder EU-Recht — Originaltext darf gezeigt werden |
|
||||||
|
| **Zitation** | Blau | Rule 2 | Quelle ist CC-BY oder aehnlich — Zitation + Quellenangabe erforderlich |
|
||||||
|
| **Reformuliert** | Amber | Rule 3 | Quelle hat eingeschraenkte Lizenz — Control wurde eigenstaendig reformuliert, kein Originaltext |
|
||||||
|
|
||||||
|
### Processing-Path
|
||||||
|
|
||||||
|
| Pfad | Bedeutung |
|
||||||
|
|------|-----------|
|
||||||
|
| **structured** | Control wurde direkt aus strukturierten Daten (Tabellen, Listen) extrahiert |
|
||||||
|
| **llm_reform** | Control wurde mit LLM eigenstaendig formuliert (bei Rule 3 zwingend) |
|
||||||
|
|
||||||
|
### Referenzen (Open Anchors)
|
||||||
|
|
||||||
|
Zeigt die Anzahl der verlinkten Open-Source-Referenzen (OWASP, NIST, ENISA etc.).
|
||||||
|
Jedes freigegebene Control muss mindestens 1 Open Anchor haben.
|
||||||
|
|
||||||
|
### Weitere Badges
|
||||||
|
|
||||||
|
| Badge | Bedeutung |
|
||||||
|
|-------|-----------|
|
||||||
|
| Score | Risiko-Score (0-10) |
|
||||||
|
| Severity-Badge | Schweregrad (Kritisch/Hoch/Mittel/Niedrig) |
|
||||||
|
| State-Badge | Freigabestatus (Draft/Approved/etc.) |
|
||||||
|
| Kategorie-Badge | Thematische Kategorie |
|
||||||
|
| Zielgruppe-Badge | Enterprise/Behoerden/Anbieter/Alle |`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'taxonomy',
|
||||||
|
title: 'Unabhaengige Taxonomie',
|
||||||
|
content: `## Eigenes Klassifikationssystem
|
||||||
|
|
||||||
|
Die Canonical Control Library verwendet ein **eigenes Domain-Schema**, das sich bewusst von
|
||||||
|
proprietaeren Frameworks unterscheidet. Die Domains werden **automatisch** durch den
|
||||||
|
Control Generator vergeben, basierend auf dem Inhalt der Quelldokumente.
|
||||||
|
|
||||||
|
### Top-10 Domains
|
||||||
|
|
||||||
|
| Domain | Anzahl | Thema | Hauptquellen |
|
||||||
|
|--------|--------|-------|-------------|
|
||||||
|
| SEC | ~700 | Allgemeine Sicherheit | CRA, NIS2, BSI, ENISA |
|
||||||
|
| COMP | ~470 | Compliance & Regulierung | DSGVO, AI Act, Richtlinien |
|
||||||
|
| DATA | ~400 | Datenschutz & Datenklassifizierung | DSGVO, BDSG, EDPB |
|
||||||
|
| AI | ~290 | KI-Regulierung & Ethik | AI Act, HLEG, OECD |
|
||||||
|
| LOG | ~230 | Logging & Monitoring | NIST, OWASP |
|
||||||
|
| AUTH | ~200 | Authentifizierung & Session | NIST SP 800-63, OWASP |
|
||||||
|
| NET | ~150 | Netzwerksicherheit | NIST, ENISA |
|
||||||
|
| CRYP | ~90 | Kryptographie & Schluessel | NIST SP 800-57 |
|
||||||
|
| ACC | ~25 | Zugriffskontrolle | OWASP ASVS |
|
||||||
|
| INC | ~25 | Incident Response | NIS2, CRA |
|
||||||
|
|
||||||
|
### Spezialisierte Domains
|
||||||
|
|
||||||
|
Neben den Top-10 gibt es ueber 90 weitere Domains fuer spezifische Themen:
|
||||||
|
|
||||||
|
- **CRA** — Cyber Resilience Act spezifisch
|
||||||
|
- **ARC** — Sichere Architektur
|
||||||
|
- **API** — API-Security
|
||||||
|
- **PKI** — Public Key Infrastructure
|
||||||
|
- **SUP** — Supply Chain Security
|
||||||
|
- **VUL** — Vulnerability Management
|
||||||
|
- **BCP** — Business Continuity
|
||||||
|
- **PHY** — Physische Sicherheit
|
||||||
|
- u.v.m.
|
||||||
|
|
||||||
|
### ID-Format
|
||||||
|
|
||||||
|
Control-IDs folgen dem Muster \`DOMAIN-NNN\` (z.B. AUTH-001, SEC-042). Dieses Format ist
|
||||||
|
**nicht von BSI oder anderen proprietaeren Standards abgeleitet**, sondern folgt einem
|
||||||
|
allgemein ueblichen Nummerierungsschema.`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'open-sources',
|
||||||
|
title: 'Offene Referenzquellen',
|
||||||
|
content: `## Primaere offene Quellen
|
||||||
|
|
||||||
|
Alle Controls sind in mindestens einer der folgenden **frei zugaenglichen** Quellen verankert:
|
||||||
|
|
||||||
|
### OWASP (CC BY-SA 4.0 — kommerziell erlaubt)
|
||||||
|
- **ASVS** — Application Security Verification Standard v4.0.3
|
||||||
|
- **MASVS** — Mobile Application Security Verification Standard v2.1
|
||||||
|
- **Top 10** — OWASP Top 10 (2021)
|
||||||
|
- **Cheat Sheets** — OWASP Cheat Sheet Series
|
||||||
|
- **SAMM** — Software Assurance Maturity Model
|
||||||
|
|
||||||
|
### NIST (Public Domain — keine Einschraenkungen)
|
||||||
|
- **SP 800-53 Rev.5** — Security and Privacy Controls
|
||||||
|
- **SP 800-63B** — Digital Identity Guidelines (Authentication)
|
||||||
|
- **SP 800-57** — Key Management Recommendations
|
||||||
|
- **SP 800-52 Rev.2** — TLS Implementation Guidelines
|
||||||
|
- **SP 800-92** — Log Management Guide
|
||||||
|
- **SP 800-218 (SSDF)** — Secure Software Development Framework
|
||||||
|
- **SP 800-60** — Information Types to Security Categories
|
||||||
|
|
||||||
|
### ENISA (CC BY 4.0 — kommerziell erlaubt)
|
||||||
|
- Good Practices for IoT/Mobile Security
|
||||||
|
- Data Protection Engineering
|
||||||
|
- Algorithms, Key Sizes and Parameters Report
|
||||||
|
|
||||||
|
### Weitere offene Quellen
|
||||||
|
- **SLSA** (Supply-chain Levels for Software Artifacts) — Google Open Source
|
||||||
|
- **CIS Controls v8** (CC BY-NC-ND — nur fuer interne Analyse)`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'restricted-sources',
|
||||||
|
title: 'Geschuetzte Quellen — Nur interne Analyse',
|
||||||
|
content: `## Quellen mit eingeschraenkter Nutzung
|
||||||
|
|
||||||
|
Die folgenden Quellen werden **ausschliesslich intern zur Analyse** verwendet.
|
||||||
|
Kein Text, keine Struktur, keine Bezeichner aus diesen Quellen erscheinen im Produkt.
|
||||||
|
|
||||||
|
### BSI (Nutzungsbedingungen — kommerziell eingeschraenkt)
|
||||||
|
- TR-03161 Teil 1-3 (Mobile, Web, Hintergrunddienste)
|
||||||
|
- Nutzung: TDM unter UrhG §44b, Kopien werden geloescht
|
||||||
|
- Kein Shipping von Zitaten, Embeddings oder Strukturen
|
||||||
|
|
||||||
|
### ISO/IEC (Kostenpflichtig — kein Shipping)
|
||||||
|
- ISO 27001, ISO 27002
|
||||||
|
- Nutzung: Nur als Referenz fuer Mapping, kein Text im Produkt
|
||||||
|
|
||||||
|
### ETSI (Restriktiv — kein kommerzieller Gebrauch)
|
||||||
|
- Nutzung: Nur als Hintergrundwissen, kein direkter Einfluss
|
||||||
|
|
||||||
|
### Trennungsprinzip
|
||||||
|
|
||||||
|
| Ebene | Geschuetzte Quelle | Offene Quelle |
|
||||||
|
|-------|--------------------|---------------|
|
||||||
|
| Analyse | ✅ Darf gelesen werden | ✅ Darf gelesen werden |
|
||||||
|
| Inspiration | ✅ Darf Ideen liefern | ✅ Darf Ideen liefern |
|
||||||
|
| Formulierung | ❌ Keine Uebernahme | ✅ Darf zitiert werden |
|
||||||
|
| Struktur | ❌ Keine Uebernahme | ✅ Darf verwendet werden |
|
||||||
|
| Produkttext | ❌ Nicht erlaubt | ✅ Erlaubt |`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'verification-methods',
|
||||||
|
title: 'Verifikationsmethoden',
|
||||||
|
content: `## Nachweis-Klassifizierung
|
||||||
|
|
||||||
|
Jedes Control wird einer von vier Verifikationsmethoden zugeordnet. Dies bestimmt,
|
||||||
|
**wie** ein Kunde den Nachweis fuer die Einhaltung erbringen kann:
|
||||||
|
|
||||||
|
| Methode | Beschreibung | Beispiele |
|
||||||
|
|---------|-------------|-----------|
|
||||||
|
| **Code Review** | Nachweis durch Quellcode-Inspektion | Input-Validierung, Encryption-Konfiguration, Auth-Logic |
|
||||||
|
| **Dokument** | Nachweis durch Richtlinien, Prozesse, Schulungen | Notfallplaene, Schulungsnachweise, Datenschutzkonzepte |
|
||||||
|
| **Tool** | Nachweis durch automatisierte Tools/Scans | SIEM-Logs, Vulnerability-Scans, Monitoring-Dashboards |
|
||||||
|
| **Hybrid** | Kombination aus mehreren Methoden | Zugriffskontrollen (Code + Policy + Tool) |
|
||||||
|
|
||||||
|
### Bedeutung fuer Kunden
|
||||||
|
|
||||||
|
- **Code Review Controls** koennen direkt im SDK-Scan geprueft werden
|
||||||
|
- **Dokument Controls** erfordern manuelle Uploads (PDFs, Links)
|
||||||
|
- **Tool Controls** koennen per API-Integration automatisch nachgewiesen werden
|
||||||
|
- **Hybrid Controls** benoetigen mehrere Nachweisarten`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'categories',
|
||||||
|
title: 'Thematische Kategorien',
|
||||||
|
content: `## 17 Sicherheitskategorien
|
||||||
|
|
||||||
|
Controls sind in thematische Kategorien gruppiert, um Kunden eine
|
||||||
|
uebersichtliche Navigation zu ermoeglichen:
|
||||||
|
|
||||||
|
| Kategorie | Beschreibung |
|
||||||
|
|-----------|-------------|
|
||||||
|
| Verschluesselung & Kryptographie | TLS, Key Management, Algorithmen |
|
||||||
|
| Authentisierung & Zugriffskontrolle | Login, MFA, RBAC, Session-Management |
|
||||||
|
| Netzwerksicherheit | Firewall, Segmentierung, VPN, DNS |
|
||||||
|
| Datenschutz & Datensicherheit | DSGVO, Datenklassifizierung, Anonymisierung |
|
||||||
|
| Logging & Monitoring | SIEM, Audit-Logs, Alerting |
|
||||||
|
| Vorfallmanagement | Incident Response, Meldepflichten |
|
||||||
|
| Notfall & Wiederherstellung | BCM, Disaster Recovery, Backups |
|
||||||
|
| Compliance & Audit | Zertifizierungen, Audits, Berichtspflichten |
|
||||||
|
| Lieferkettenmanagement | Vendor Risk, SBOM, Third-Party |
|
||||||
|
| Physische Sicherheit | Zutritt, Gebaeudesicherheit |
|
||||||
|
| Personal & Schulung | Security Awareness, Rollenkonzepte |
|
||||||
|
| Anwendungssicherheit | SAST, DAST, Secure Coding |
|
||||||
|
| Systemhaertung & -betrieb | Patching, Konfiguration, Hardening |
|
||||||
|
| Risikomanagement | Risikoanalyse, Bewertung, Massnahmen |
|
||||||
|
| Sicherheitsorganisation | ISMS, Richtlinien, Governance |
|
||||||
|
| Hardware & Plattformsicherheit | TPM, Secure Boot, Firmware |
|
||||||
|
| Identitaetsmanagement | SSO, Federation, Directory |
|
||||||
|
|
||||||
|
### Abgrenzung zu Domains
|
||||||
|
|
||||||
|
Kategorien sind **thematisch**, Domains (AUTH, NET, etc.) sind **strukturell**.
|
||||||
|
Ein Control AUTH-005 (Domain AUTH) hat die Kategorie "authentication",
|
||||||
|
aber ein Control NET-012 (Domain NET) koennte ebenfalls die Kategorie
|
||||||
|
"authentication" haben, wenn es um Netzwerk-Authentifizierung geht.`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'master-library',
|
||||||
|
title: 'Master Library Strategie',
|
||||||
|
content: `## RAG-First Ansatz
|
||||||
|
|
||||||
|
Die Canonical Control Library folgt einer **RAG-First-Strategie**:
|
||||||
|
|
||||||
|
### Schritt 1: Rule 1+2 Controls aus RAG generieren
|
||||||
|
|
||||||
|
Prioritaet haben Controls aus Quellen mit **Originaltext-Erlaubnis**:
|
||||||
|
|
||||||
|
| Welle | Quellen | Lizenzregel | Vorteil |
|
||||||
|
|-------|---------|------------|---------|
|
||||||
|
| 1 | OWASP (ASVS, MASVS, Top10) | Rule 2 (CC-BY-SA, Zitation) | Originaltext + Zitation |
|
||||||
|
| 2 | NIST (SP 800-53, CSF, SSDF) | Rule 1 (Public Domain) | Voller Text, keine Einschraenkungen |
|
||||||
|
| 3 | EU-Verordnungen (DSGVO, AI Act, NIS2, CRA) | Rule 1 (EU Law) | Gesetzestext + Erklaerung |
|
||||||
|
| 4 | Deutsche Gesetze (BDSG, TTDSG, TKG) | Rule 1 (DE Law) | Gesetzestext + Erklaerung |
|
||||||
|
|
||||||
|
### Schritt 2: Dedup gegen BSI Rule-3 Controls
|
||||||
|
|
||||||
|
Die ~880 BSI Rule-3 Controls werden **gegen** die neuen Rule 1+2 Controls abgeglichen:
|
||||||
|
|
||||||
|
- Wenn ein BSI-Control ein Duplikat eines OWASP/NIST-Controls ist → **OWASP/NIST bevorzugt**
|
||||||
|
(weil Originaltext + Zitation erlaubt)
|
||||||
|
- BSI-Duplikate werden als \`deprecated\` markiert
|
||||||
|
- Tags und Anchors werden in den behaltenen Control zusammengefuehrt
|
||||||
|
|
||||||
|
### Schritt 3: Aktueller Stand
|
||||||
|
|
||||||
|
Aktuell: **~3.100+ Controls** (Stand Maerz 2026), davon:
|
||||||
|
- Viele mit \`source_original_text\` (Originaltext fuer Kunden sichtbar)
|
||||||
|
- Viele mit \`source_citation\` (Quellenangabe mit Lizenz)
|
||||||
|
- Klare Nachweismethode (\`verification_method\`)
|
||||||
|
- Thematische Kategorie (\`category\`)
|
||||||
|
|
||||||
|
### Verstaendliche Texte
|
||||||
|
|
||||||
|
Zusaetzlich zum Originaltext (der oft juristisch/technisch formuliert ist)
|
||||||
|
enthaelt jedes Control ein eigenstaendig formuliertes **Ziel** (objective)
|
||||||
|
und eine **Begruendung** (rationale) in verstaendlicher Sprache.`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'validation',
|
||||||
|
title: 'Automatisierte Validierung',
|
||||||
|
content: `## CI/CD-Pruefungen
|
||||||
|
|
||||||
|
Jedes Control wird bei jedem Commit automatisch geprueft:
|
||||||
|
|
||||||
|
### 1. Schema-Validierung
|
||||||
|
- Alle Pflichtfelder vorhanden
|
||||||
|
- Control-ID Format: \`^[A-Z]{2,6}-[0-9]{3}$\`
|
||||||
|
- Severity: low, medium, high, critical
|
||||||
|
- Risk Score: 0-10
|
||||||
|
|
||||||
|
### 2. No-Leak Scanner
|
||||||
|
Regex-Pruefung gegen verbotene Muster in produktfaehigen Feldern:
|
||||||
|
- \`O.[A-Za-z]+_[0-9]+\` — BSI Objective-IDs
|
||||||
|
- \`TR-03161\` — Direkte BSI-TR-Referenzen
|
||||||
|
- \`BSI-TR-\` — BSI-spezifische Locators
|
||||||
|
- \`Anforderung [A-Z].[0-9]+\` — BSI-Anforderungsformat
|
||||||
|
|
||||||
|
### 3. Open Anchor Check
|
||||||
|
Jedes freigegebene Control muss mindestens 1 Open-Source-Referenz haben.
|
||||||
|
|
||||||
|
### 4. Too-Close Detektor (5 Metriken)
|
||||||
|
|
||||||
|
| Metrik | Warn | Fail | Beschreibung |
|
||||||
|
|--------|------|------|-------------|
|
||||||
|
| Exact Phrase | ≥8 Tokens | ≥12 Tokens | Laengste identische Token-Sequenz |
|
||||||
|
| Token Overlap | ≥0.20 | ≥0.30 | Jaccard-Aehnlichkeit der Token-Mengen |
|
||||||
|
| 3-Gram Jaccard | ≥0.10 | ≥0.18 | Zeichenketten-Aehnlichkeit |
|
||||||
|
| Embedding Cosine | ≥0.86 | ≥0.92 | Semantische Aehnlichkeit (bge-m3) |
|
||||||
|
| LCS Ratio | ≥0.35 | ≥0.50 | Longest Common Subsequence |
|
||||||
|
|
||||||
|
**Entscheidungslogik:**
|
||||||
|
- **PASS** — Kein Fail + max 1 Warn
|
||||||
|
- **WARN** — Max 2 Warn, kein Fail → Human Review erforderlich
|
||||||
|
- **FAIL** — Irgendein Fail → Blockiert, Umformulierung noetig`,
|
||||||
|
},
|
||||||
|
]
|
||||||
@@ -1,15 +1,12 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { useState, useEffect } from 'react'
|
import { useState, useEffect } from 'react'
|
||||||
import {
|
import { FileText, Shield } from 'lucide-react'
|
||||||
Shield, BookOpen, ExternalLink, CheckCircle2, AlertTriangle,
|
|
||||||
Lock, Scale, FileText, Eye, ArrowLeft,
|
|
||||||
} from 'lucide-react'
|
|
||||||
import Link from 'next/link'
|
import Link from 'next/link'
|
||||||
|
import { PROVENANCE_SECTIONS } from './_components/provenance-data'
|
||||||
// =============================================================================
|
import { MarkdownRenderer } from './_components/ProvenanceHelpers'
|
||||||
// TYPES
|
import { LicenseMatrix } from './_components/LicenseMatrix'
|
||||||
// =============================================================================
|
import { SourceRegistry } from './_components/SourceRegistry'
|
||||||
|
|
||||||
interface LicenseInfo {
|
interface LicenseInfo {
|
||||||
license_id: string
|
license_id: string
|
||||||
@@ -40,413 +37,6 @@ interface SourceInfo {
|
|||||||
vault_access_tier: string
|
vault_access_tier: string
|
||||||
}
|
}
|
||||||
|
|
||||||
// =============================================================================
|
|
||||||
// STATIC PROVENANCE DOCUMENTATION
|
|
||||||
// =============================================================================
|
|
||||||
|
|
||||||
const PROVENANCE_SECTIONS = [
|
|
||||||
{
|
|
||||||
id: 'methodology',
|
|
||||||
title: 'Methodik der Control-Erstellung',
|
|
||||||
content: `## Unabhaengige Formulierung
|
|
||||||
|
|
||||||
Alle Controls in der Canonical Control Library wurden **eigenstaendig formuliert** und folgen einer
|
|
||||||
**unabhaengigen Taxonomie**. Es werden keine proprietaeren Bezeichner, Nummern oder Strukturen
|
|
||||||
aus geschuetzten Quellen uebernommen.
|
|
||||||
|
|
||||||
### Dreistufiger Prozess
|
|
||||||
|
|
||||||
1. **Offene Recherche** — Identifikation von Security-Anforderungen aus oeffentlichen, frei zugaenglichen
|
|
||||||
Frameworks (OWASP, NIST, ENISA). Jede Anforderung wird aus mindestens 2 unabhaengigen offenen Quellen belegt.
|
|
||||||
|
|
||||||
2. **Eigenstaendige Formulierung** — Jedes Control wird mit eigener Sprache, eigener Struktur und eigener
|
|
||||||
Taxonomie (z.B. AUTH-001, NET-001) verfasst. Kein Copy-Paste, keine Paraphrase geschuetzter Texte.
|
|
||||||
|
|
||||||
3. **Too-Close-Pruefung** — Automatisierte Aehnlichkeitspruefung gegen Quelltexte mit 5 Metriken
|
|
||||||
(Token Overlap, N-Gram Jaccard, Embedding Cosine, LCS Ratio, Exact-Phrase). Nur Controls mit
|
|
||||||
Status PASS oder WARN (+ Human Review) werden freigegeben.
|
|
||||||
|
|
||||||
### Rechtliche Grundlage
|
|
||||||
|
|
||||||
- **UrhG §44b** — Text & Data Mining erlaubt fuer Analyse; Kopien werden danach geloescht
|
|
||||||
- **UrhG §23** — Hinreichender Abstand zum Originalwerk durch eigene Formulierung
|
|
||||||
- **BSI Nutzungsbedingungen** — Kommerzielle Nutzung nur mit Zustimmung; wir nutzen BSI-Dokumente
|
|
||||||
ausschliesslich als Analysegrundlage, nicht im Produkt`,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'filters',
|
|
||||||
title: 'Filter in der Control Library',
|
|
||||||
content: `## Dropdown-Filter
|
|
||||||
|
|
||||||
Die Control Library bietet 7 Filter-Dropdowns, um die ueber 3.000 Controls effizient zu durchsuchen:
|
|
||||||
|
|
||||||
### Schweregrad (Severity)
|
|
||||||
|
|
||||||
| Stufe | Farbe | Bedeutung |
|
|
||||||
|-------|-------|-----------|
|
|
||||||
| **Kritisch** | Rot | Sicherheitskritische Controls — Verstoesse fuehren zu schwerwiegenden Risiken |
|
|
||||||
| **Hoch** | Orange | Wichtige Controls — sollten zeitnah umgesetzt werden |
|
|
||||||
| **Mittel** | Gelb | Standardmaessige Controls — empfohlene Umsetzung |
|
|
||||||
| **Niedrig** | Gruen | Nice-to-have Controls — zusaetzliche Haertung |
|
|
||||||
|
|
||||||
### Domain
|
|
||||||
|
|
||||||
Das Praefix der Control-ID (z.B. \`AUTH-001\`, \`SEC-042\`). Kennzeichnet den thematischen Bereich.
|
|
||||||
Die haeufigsten Domains:
|
|
||||||
|
|
||||||
| Domain | Anzahl | Thema |
|
|
||||||
|--------|--------|-------|
|
|
||||||
| SEC | ~700 | Allgemeine Sicherheit, Systemhaertung |
|
|
||||||
| COMP | ~470 | Compliance, Regulierung, Nachweispflichten |
|
|
||||||
| DATA | ~400 | Datenschutz, Datenklassifizierung, DSGVO |
|
|
||||||
| AI | ~290 | KI-Regulierung (AI Act, Transparenz, Erklaerbarkeit) |
|
|
||||||
| LOG | ~230 | Logging, Monitoring, SIEM |
|
|
||||||
| AUTH | ~200 | Authentifizierung, Zugriffskontrolle |
|
|
||||||
| NET | ~150 | Netzwerksicherheit, Transport, Firewall |
|
|
||||||
| CRYP | ~90 | Kryptographie, Schluesselmanagement |
|
|
||||||
| ACC | ~25 | Zugriffskontrolle (Access Control) |
|
|
||||||
| INC | ~25 | Incident Response, Vorfallmanagement |
|
|
||||||
|
|
||||||
Zusaetzlich existieren spezialisierte Domains wie CRA, ARC (Architektur), API, PKI, SUP (Supply Chain) u.v.m.
|
|
||||||
|
|
||||||
### Status (Release State)
|
|
||||||
|
|
||||||
| Status | Bedeutung |
|
|
||||||
|--------|-----------|
|
|
||||||
| **Draft** | Entwurf — noch nicht freigegeben |
|
|
||||||
| **Approved** | Freigegeben fuer Kunden |
|
|
||||||
| **Review noetig** | Muss manuell geprueft werden |
|
|
||||||
| **Zu aehnlich** | Too-Close-Check hat Warnung ausgeloest |
|
|
||||||
| **Duplikat** | Wurde als Duplikat eines anderen Controls erkannt |
|
|
||||||
|
|
||||||
### Nachweis (Verification Method)
|
|
||||||
|
|
||||||
| Methode | Farbe | Beschreibung |
|
|
||||||
|---------|-------|-------------|
|
|
||||||
| **Code Review** | Blau | Nachweis durch Quellcode-Inspektion |
|
|
||||||
| **Dokument** | Amber | Nachweis durch Richtlinien, Prozesse, Schulungen |
|
|
||||||
| **Tool** | Teal | Nachweis durch automatisierte Scans/Monitoring |
|
|
||||||
| **Hybrid** | Lila | Kombination aus mehreren Methoden |
|
|
||||||
|
|
||||||
### Kategorie
|
|
||||||
|
|
||||||
Thematische Einordnung (17 Kategorien). Kategorien sind **thematisch**, Domains **strukturell**.
|
|
||||||
Ein AUTH-Control kann z.B. die Kategorie "Netzwerksicherheit" haben.
|
|
||||||
|
|
||||||
### Zielgruppe (Target Audience)
|
|
||||||
|
|
||||||
| Zielgruppe | Bedeutung |
|
|
||||||
|------------|-----------|
|
|
||||||
| **Unternehmen** | Fuer Endkunden/Firmen relevant |
|
|
||||||
| **Behoerden** | Spezifisch fuer oeffentliche Verwaltung |
|
|
||||||
| **Anbieter** | Fuer SaaS/Plattform-Anbieter |
|
|
||||||
| **Alle** | Allgemein anwendbar |
|
|
||||||
|
|
||||||
### Dokumentenursprung (Source)
|
|
||||||
|
|
||||||
Filtert nach der Quelldokument-Herkunft des Controls. Zeigt alle Quellen sortiert nach
|
|
||||||
Haeufigkeit. Die wichtigsten Quellen:
|
|
||||||
|
|
||||||
| Quelle | Typ |
|
|
||||||
|--------|-----|
|
|
||||||
| KI-Verordnung (EU) 2024/1689 | EU-Recht |
|
|
||||||
| Cyber Resilience Act (EU) 2024/2847 | EU-Recht |
|
|
||||||
| DSGVO (EU) 2016/679 | EU-Recht |
|
|
||||||
| NIS2-Richtlinie (EU) 2022/2555 | EU-Recht |
|
|
||||||
| NIST SP 800-53, CSF 2.0, SSDF | US-Standards |
|
|
||||||
| OWASP Top 10, ASVS, SAMM | Open Source |
|
|
||||||
| ENISA Guidelines | EU-Agentur |
|
|
||||||
| CISA Secure by Design | US-Behoerde |
|
|
||||||
| BDSG, TKG, GewO, HGB | Deutsche Gesetze |
|
|
||||||
| EDPB Leitlinien | EU Datenschutz |`,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'badges',
|
|
||||||
title: 'Badges & Lizenzregeln',
|
|
||||||
content: `## Badges in der Control Library
|
|
||||||
|
|
||||||
Jedes Control zeigt mehrere farbige Badges:
|
|
||||||
|
|
||||||
### Lizenzregel-Badge (Rule 1 / 2 / 3)
|
|
||||||
|
|
||||||
Die Lizenzregel bestimmt, wie ein Control erstellt und genutzt werden darf:
|
|
||||||
|
|
||||||
| Badge | Farbe | Regel | Bedeutung |
|
|
||||||
|-------|-------|-------|-----------|
|
|
||||||
| **Free Use** | Gruen | Rule 1 | Quelle ist Public Domain oder EU-Recht — Originaltext darf gezeigt werden |
|
|
||||||
| **Zitation** | Blau | Rule 2 | Quelle ist CC-BY oder aehnlich — Zitation + Quellenangabe erforderlich |
|
|
||||||
| **Reformuliert** | Amber | Rule 3 | Quelle hat eingeschraenkte Lizenz — Control wurde eigenstaendig reformuliert, kein Originaltext |
|
|
||||||
|
|
||||||
### Processing-Path
|
|
||||||
|
|
||||||
| Pfad | Bedeutung |
|
|
||||||
|------|-----------|
|
|
||||||
| **structured** | Control wurde direkt aus strukturierten Daten (Tabellen, Listen) extrahiert |
|
|
||||||
| **llm_reform** | Control wurde mit LLM eigenstaendig formuliert (bei Rule 3 zwingend) |
|
|
||||||
|
|
||||||
### Referenzen (Open Anchors)
|
|
||||||
|
|
||||||
Zeigt die Anzahl der verlinkten Open-Source-Referenzen (OWASP, NIST, ENISA etc.).
|
|
||||||
Jedes freigegebene Control muss mindestens 1 Open Anchor haben.
|
|
||||||
|
|
||||||
### Weitere Badges
|
|
||||||
|
|
||||||
| Badge | Bedeutung |
|
|
||||||
|-------|-----------|
|
|
||||||
| Score | Risiko-Score (0-10) |
|
|
||||||
| Severity-Badge | Schweregrad (Kritisch/Hoch/Mittel/Niedrig) |
|
|
||||||
| State-Badge | Freigabestatus (Draft/Approved/etc.) |
|
|
||||||
| Kategorie-Badge | Thematische Kategorie |
|
|
||||||
| Zielgruppe-Badge | Enterprise/Behoerden/Anbieter/Alle |`,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'taxonomy',
|
|
||||||
title: 'Unabhaengige Taxonomie',
|
|
||||||
content: `## Eigenes Klassifikationssystem
|
|
||||||
|
|
||||||
Die Canonical Control Library verwendet ein **eigenes Domain-Schema**, das sich bewusst von
|
|
||||||
proprietaeren Frameworks unterscheidet. Die Domains werden **automatisch** durch den
|
|
||||||
Control Generator vergeben, basierend auf dem Inhalt der Quelldokumente.
|
|
||||||
|
|
||||||
### Top-10 Domains
|
|
||||||
|
|
||||||
| Domain | Anzahl | Thema | Hauptquellen |
|
|
||||||
|--------|--------|-------|-------------|
|
|
||||||
| SEC | ~700 | Allgemeine Sicherheit | CRA, NIS2, BSI, ENISA |
|
|
||||||
| COMP | ~470 | Compliance & Regulierung | DSGVO, AI Act, Richtlinien |
|
|
||||||
| DATA | ~400 | Datenschutz & Datenklassifizierung | DSGVO, BDSG, EDPB |
|
|
||||||
| AI | ~290 | KI-Regulierung & Ethik | AI Act, HLEG, OECD |
|
|
||||||
| LOG | ~230 | Logging & Monitoring | NIST, OWASP |
|
|
||||||
| AUTH | ~200 | Authentifizierung & Session | NIST SP 800-63, OWASP |
|
|
||||||
| NET | ~150 | Netzwerksicherheit | NIST, ENISA |
|
|
||||||
| CRYP | ~90 | Kryptographie & Schluessel | NIST SP 800-57 |
|
|
||||||
| ACC | ~25 | Zugriffskontrolle | OWASP ASVS |
|
|
||||||
| INC | ~25 | Incident Response | NIS2, CRA |
|
|
||||||
|
|
||||||
### Spezialisierte Domains
|
|
||||||
|
|
||||||
Neben den Top-10 gibt es ueber 90 weitere Domains fuer spezifische Themen:
|
|
||||||
|
|
||||||
- **CRA** — Cyber Resilience Act spezifisch
|
|
||||||
- **ARC** — Sichere Architektur
|
|
||||||
- **API** — API-Security
|
|
||||||
- **PKI** — Public Key Infrastructure
|
|
||||||
- **SUP** — Supply Chain Security
|
|
||||||
- **VUL** — Vulnerability Management
|
|
||||||
- **BCP** — Business Continuity
|
|
||||||
- **PHY** — Physische Sicherheit
|
|
||||||
- u.v.m.
|
|
||||||
|
|
||||||
### ID-Format
|
|
||||||
|
|
||||||
Control-IDs folgen dem Muster \`DOMAIN-NNN\` (z.B. AUTH-001, SEC-042). Dieses Format ist
|
|
||||||
**nicht von BSI oder anderen proprietaeren Standards abgeleitet**, sondern folgt einem
|
|
||||||
allgemein ueblichen Nummerierungsschema.`,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'open-sources',
|
|
||||||
title: 'Offene Referenzquellen',
|
|
||||||
content: `## Primaere offene Quellen
|
|
||||||
|
|
||||||
Alle Controls sind in mindestens einer der folgenden **frei zugaenglichen** Quellen verankert:
|
|
||||||
|
|
||||||
### OWASP (CC BY-SA 4.0 — kommerziell erlaubt)
|
|
||||||
- **ASVS** — Application Security Verification Standard v4.0.3
|
|
||||||
- **MASVS** — Mobile Application Security Verification Standard v2.1
|
|
||||||
- **Top 10** — OWASP Top 10 (2021)
|
|
||||||
- **Cheat Sheets** — OWASP Cheat Sheet Series
|
|
||||||
- **SAMM** — Software Assurance Maturity Model
|
|
||||||
|
|
||||||
### NIST (Public Domain — keine Einschraenkungen)
|
|
||||||
- **SP 800-53 Rev.5** — Security and Privacy Controls
|
|
||||||
- **SP 800-63B** — Digital Identity Guidelines (Authentication)
|
|
||||||
- **SP 800-57** — Key Management Recommendations
|
|
||||||
- **SP 800-52 Rev.2** — TLS Implementation Guidelines
|
|
||||||
- **SP 800-92** — Log Management Guide
|
|
||||||
- **SP 800-218 (SSDF)** — Secure Software Development Framework
|
|
||||||
- **SP 800-60** — Information Types to Security Categories
|
|
||||||
|
|
||||||
### ENISA (CC BY 4.0 — kommerziell erlaubt)
|
|
||||||
- Good Practices for IoT/Mobile Security
|
|
||||||
- Data Protection Engineering
|
|
||||||
- Algorithms, Key Sizes and Parameters Report
|
|
||||||
|
|
||||||
### Weitere offene Quellen
|
|
||||||
- **SLSA** (Supply-chain Levels for Software Artifacts) — Google Open Source
|
|
||||||
- **CIS Controls v8** (CC BY-NC-ND — nur fuer interne Analyse)`,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'restricted-sources',
|
|
||||||
title: 'Geschuetzte Quellen — Nur interne Analyse',
|
|
||||||
content: `## Quellen mit eingeschraenkter Nutzung
|
|
||||||
|
|
||||||
Die folgenden Quellen werden **ausschliesslich intern zur Analyse** verwendet.
|
|
||||||
Kein Text, keine Struktur, keine Bezeichner aus diesen Quellen erscheinen im Produkt.
|
|
||||||
|
|
||||||
### BSI (Nutzungsbedingungen — kommerziell eingeschraenkt)
|
|
||||||
- TR-03161 Teil 1-3 (Mobile, Web, Hintergrunddienste)
|
|
||||||
- Nutzung: TDM unter UrhG §44b, Kopien werden geloescht
|
|
||||||
- Kein Shipping von Zitaten, Embeddings oder Strukturen
|
|
||||||
|
|
||||||
### ISO/IEC (Kostenpflichtig — kein Shipping)
|
|
||||||
- ISO 27001, ISO 27002
|
|
||||||
- Nutzung: Nur als Referenz fuer Mapping, kein Text im Produkt
|
|
||||||
|
|
||||||
### ETSI (Restriktiv — kein kommerzieller Gebrauch)
|
|
||||||
- Nutzung: Nur als Hintergrundwissen, kein direkter Einfluss
|
|
||||||
|
|
||||||
### Trennungsprinzip
|
|
||||||
|
|
||||||
| Ebene | Geschuetzte Quelle | Offene Quelle |
|
|
||||||
|-------|--------------------|---------------|
|
|
||||||
| Analyse | ✅ Darf gelesen werden | ✅ Darf gelesen werden |
|
|
||||||
| Inspiration | ✅ Darf Ideen liefern | ✅ Darf Ideen liefern |
|
|
||||||
| Formulierung | ❌ Keine Uebernahme | ✅ Darf zitiert werden |
|
|
||||||
| Struktur | ❌ Keine Uebernahme | ✅ Darf verwendet werden |
|
|
||||||
| Produkttext | ❌ Nicht erlaubt | ✅ Erlaubt |`,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'verification-methods',
|
|
||||||
title: 'Verifikationsmethoden',
|
|
||||||
content: `## Nachweis-Klassifizierung
|
|
||||||
|
|
||||||
Jedes Control wird einer von vier Verifikationsmethoden zugeordnet. Dies bestimmt,
|
|
||||||
**wie** ein Kunde den Nachweis fuer die Einhaltung erbringen kann:
|
|
||||||
|
|
||||||
| Methode | Beschreibung | Beispiele |
|
|
||||||
|---------|-------------|-----------|
|
|
||||||
| **Code Review** | Nachweis durch Quellcode-Inspektion | Input-Validierung, Encryption-Konfiguration, Auth-Logic |
|
|
||||||
| **Dokument** | Nachweis durch Richtlinien, Prozesse, Schulungen | Notfallplaene, Schulungsnachweise, Datenschutzkonzepte |
|
|
||||||
| **Tool** | Nachweis durch automatisierte Tools/Scans | SIEM-Logs, Vulnerability-Scans, Monitoring-Dashboards |
|
|
||||||
| **Hybrid** | Kombination aus mehreren Methoden | Zugriffskontrollen (Code + Policy + Tool) |
|
|
||||||
|
|
||||||
### Bedeutung fuer Kunden
|
|
||||||
|
|
||||||
- **Code Review Controls** koennen direkt im SDK-Scan geprueft werden
|
|
||||||
- **Dokument Controls** erfordern manuelle Uploads (PDFs, Links)
|
|
||||||
- **Tool Controls** koennen per API-Integration automatisch nachgewiesen werden
|
|
||||||
- **Hybrid Controls** benoetigen mehrere Nachweisarten`,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'categories',
|
|
||||||
title: 'Thematische Kategorien',
|
|
||||||
content: `## 17 Sicherheitskategorien
|
|
||||||
|
|
||||||
Controls sind in thematische Kategorien gruppiert, um Kunden eine
|
|
||||||
uebersichtliche Navigation zu ermoeglichen:
|
|
||||||
|
|
||||||
| Kategorie | Beschreibung |
|
|
||||||
|-----------|-------------|
|
|
||||||
| Verschluesselung & Kryptographie | TLS, Key Management, Algorithmen |
|
|
||||||
| Authentisierung & Zugriffskontrolle | Login, MFA, RBAC, Session-Management |
|
|
||||||
| Netzwerksicherheit | Firewall, Segmentierung, VPN, DNS |
|
|
||||||
| Datenschutz & Datensicherheit | DSGVO, Datenklassifizierung, Anonymisierung |
|
|
||||||
| Logging & Monitoring | SIEM, Audit-Logs, Alerting |
|
|
||||||
| Vorfallmanagement | Incident Response, Meldepflichten |
|
|
||||||
| Notfall & Wiederherstellung | BCM, Disaster Recovery, Backups |
|
|
||||||
| Compliance & Audit | Zertifizierungen, Audits, Berichtspflichten |
|
|
||||||
| Lieferkettenmanagement | Vendor Risk, SBOM, Third-Party |
|
|
||||||
| Physische Sicherheit | Zutritt, Gebaeudesicherheit |
|
|
||||||
| Personal & Schulung | Security Awareness, Rollenkonzepte |
|
|
||||||
| Anwendungssicherheit | SAST, DAST, Secure Coding |
|
|
||||||
| Systemhaertung & -betrieb | Patching, Konfiguration, Hardening |
|
|
||||||
| Risikomanagement | Risikoanalyse, Bewertung, Massnahmen |
|
|
||||||
| Sicherheitsorganisation | ISMS, Richtlinien, Governance |
|
|
||||||
| Hardware & Plattformsicherheit | TPM, Secure Boot, Firmware |
|
|
||||||
| Identitaetsmanagement | SSO, Federation, Directory |
|
|
||||||
|
|
||||||
### Abgrenzung zu Domains
|
|
||||||
|
|
||||||
Kategorien sind **thematisch**, Domains (AUTH, NET, etc.) sind **strukturell**.
|
|
||||||
Ein Control AUTH-005 (Domain AUTH) hat die Kategorie "authentication",
|
|
||||||
aber ein Control NET-012 (Domain NET) koennte ebenfalls die Kategorie
|
|
||||||
"authentication" haben, wenn es um Netzwerk-Authentifizierung geht.`,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'master-library',
|
|
||||||
title: 'Master Library Strategie',
|
|
||||||
content: `## RAG-First Ansatz
|
|
||||||
|
|
||||||
Die Canonical Control Library folgt einer **RAG-First-Strategie**:
|
|
||||||
|
|
||||||
### Schritt 1: Rule 1+2 Controls aus RAG generieren
|
|
||||||
|
|
||||||
Prioritaet haben Controls aus Quellen mit **Originaltext-Erlaubnis**:
|
|
||||||
|
|
||||||
| Welle | Quellen | Lizenzregel | Vorteil |
|
|
||||||
|-------|---------|------------|---------|
|
|
||||||
| 1 | OWASP (ASVS, MASVS, Top10) | Rule 2 (CC-BY-SA, Zitation) | Originaltext + Zitation |
|
|
||||||
| 2 | NIST (SP 800-53, CSF, SSDF) | Rule 1 (Public Domain) | Voller Text, keine Einschraenkungen |
|
|
||||||
| 3 | EU-Verordnungen (DSGVO, AI Act, NIS2, CRA) | Rule 1 (EU Law) | Gesetzestext + Erklaerung |
|
|
||||||
| 4 | Deutsche Gesetze (BDSG, TTDSG, TKG) | Rule 1 (DE Law) | Gesetzestext + Erklaerung |
|
|
||||||
|
|
||||||
### Schritt 2: Dedup gegen BSI Rule-3 Controls
|
|
||||||
|
|
||||||
Die ~880 BSI Rule-3 Controls werden **gegen** die neuen Rule 1+2 Controls abgeglichen:
|
|
||||||
|
|
||||||
- Wenn ein BSI-Control ein Duplikat eines OWASP/NIST-Controls ist → **OWASP/NIST bevorzugt**
|
|
||||||
(weil Originaltext + Zitation erlaubt)
|
|
||||||
- BSI-Duplikate werden als \`deprecated\` markiert
|
|
||||||
- Tags und Anchors werden in den behaltenen Control zusammengefuehrt
|
|
||||||
|
|
||||||
### Schritt 3: Aktueller Stand
|
|
||||||
|
|
||||||
Aktuell: **~3.100+ Controls** (Stand Maerz 2026), davon:
|
|
||||||
- Viele mit \`source_original_text\` (Originaltext fuer Kunden sichtbar)
|
|
||||||
- Viele mit \`source_citation\` (Quellenangabe mit Lizenz)
|
|
||||||
- Klare Nachweismethode (\`verification_method\`)
|
|
||||||
- Thematische Kategorie (\`category\`)
|
|
||||||
|
|
||||||
### Verstaendliche Texte
|
|
||||||
|
|
||||||
Zusaetzlich zum Originaltext (der oft juristisch/technisch formuliert ist)
|
|
||||||
enthaelt jedes Control ein eigenstaendig formuliertes **Ziel** (objective)
|
|
||||||
und eine **Begruendung** (rationale) in verstaendlicher Sprache.`,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'validation',
|
|
||||||
title: 'Automatisierte Validierung',
|
|
||||||
content: `## CI/CD-Pruefungen
|
|
||||||
|
|
||||||
Jedes Control wird bei jedem Commit automatisch geprueft:
|
|
||||||
|
|
||||||
### 1. Schema-Validierung
|
|
||||||
- Alle Pflichtfelder vorhanden
|
|
||||||
- Control-ID Format: \`^[A-Z]{2,6}-[0-9]{3}$\`
|
|
||||||
- Severity: low, medium, high, critical
|
|
||||||
- Risk Score: 0-10
|
|
||||||
|
|
||||||
### 2. No-Leak Scanner
|
|
||||||
Regex-Pruefung gegen verbotene Muster in produktfaehigen Feldern:
|
|
||||||
- \`O.[A-Za-z]+_[0-9]+\` — BSI Objective-IDs
|
|
||||||
- \`TR-03161\` — Direkte BSI-TR-Referenzen
|
|
||||||
- \`BSI-TR-\` — BSI-spezifische Locators
|
|
||||||
- \`Anforderung [A-Z].[0-9]+\` — BSI-Anforderungsformat
|
|
||||||
|
|
||||||
### 3. Open Anchor Check
|
|
||||||
Jedes freigegebene Control muss mindestens 1 Open-Source-Referenz haben.
|
|
||||||
|
|
||||||
### 4. Too-Close Detektor (5 Metriken)
|
|
||||||
|
|
||||||
| Metrik | Warn | Fail | Beschreibung |
|
|
||||||
|--------|------|------|-------------|
|
|
||||||
| Exact Phrase | ≥8 Tokens | ≥12 Tokens | Laengste identische Token-Sequenz |
|
|
||||||
| Token Overlap | ≥0.20 | ≥0.30 | Jaccard-Aehnlichkeit der Token-Mengen |
|
|
||||||
| 3-Gram Jaccard | ≥0.10 | ≥0.18 | Zeichenketten-Aehnlichkeit |
|
|
||||||
| Embedding Cosine | ≥0.86 | ≥0.92 | Semantische Aehnlichkeit (bge-m3) |
|
|
||||||
| LCS Ratio | ≥0.35 | ≥0.50 | Longest Common Subsequence |
|
|
||||||
|
|
||||||
**Entscheidungslogik:**
|
|
||||||
- **PASS** — Kein Fail + max 1 Warn
|
|
||||||
- **WARN** — Max 2 Warn, kein Fail → Human Review erforderlich
|
|
||||||
- **FAIL** — Irgendein Fail → Blockiert, Umformulierung noetig`,
|
|
||||||
},
|
|
||||||
]
|
|
||||||
|
|
||||||
// =============================================================================
|
|
||||||
// PAGE
|
|
||||||
// =============================================================================
|
|
||||||
|
|
||||||
export default function ControlProvenancePage() {
|
export default function ControlProvenancePage() {
|
||||||
const [licenses, setLicenses] = useState<LicenseInfo[]>([])
|
const [licenses, setLicenses] = useState<LicenseInfo[]>([])
|
||||||
const [sources, setSources] = useState<SourceInfo[]>([])
|
const [sources, setSources] = useState<SourceInfo[]>([])
|
||||||
@@ -485,10 +75,7 @@ export default function ControlProvenancePage() {
|
|||||||
Dokumentation der unabhaengigen Herkunft aller Security Controls — rechtssicherer Nachweis
|
Dokumentation der unabhaengigen Herkunft aller Security Controls — rechtssicherer Nachweis
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<Link
|
<Link href="/sdk/control-library" className="ml-auto flex items-center gap-1 text-sm text-purple-600 hover:text-purple-800">
|
||||||
href="/sdk/control-library"
|
|
||||||
className="ml-auto flex items-center gap-1 text-sm text-purple-600 hover:text-purple-800"
|
|
||||||
>
|
|
||||||
<Shield className="w-4 h-4" />
|
<Shield className="w-4 h-4" />
|
||||||
Zur Control Library
|
Zur Control Library
|
||||||
</Link>
|
</Link>
|
||||||
@@ -516,26 +103,19 @@ export default function ControlProvenancePage() {
|
|||||||
|
|
||||||
<div className="border-t border-gray-200 mt-3 pt-3">
|
<div className="border-t border-gray-200 mt-3 pt-3">
|
||||||
<p className="text-xs font-semibold text-gray-400 uppercase px-3 mb-2">Live-Daten</p>
|
<p className="text-xs font-semibold text-gray-400 uppercase px-3 mb-2">Live-Daten</p>
|
||||||
|
{['license-matrix', 'source-registry'].map(id => (
|
||||||
<button
|
<button
|
||||||
onClick={() => setActiveSection('license-matrix')}
|
key={id}
|
||||||
|
onClick={() => setActiveSection(id)}
|
||||||
className={`w-full text-left px-3 py-2 rounded-lg text-sm transition-colors ${
|
className={`w-full text-left px-3 py-2 rounded-lg text-sm transition-colors ${
|
||||||
activeSection === 'license-matrix'
|
activeSection === id
|
||||||
? 'bg-green-100 text-green-900 font-medium'
|
? 'bg-green-100 text-green-900 font-medium'
|
||||||
: 'text-gray-700 hover:bg-gray-100'
|
: 'text-gray-700 hover:bg-gray-100'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
Lizenz-Matrix
|
{id === 'license-matrix' ? 'Lizenz-Matrix' : 'Quellenregister'}
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={() => setActiveSection('source-registry')}
|
|
||||||
className={`w-full text-left px-3 py-2 rounded-lg text-sm transition-colors ${
|
|
||||||
activeSection === 'source-registry'
|
|
||||||
? 'bg-green-100 text-green-900 font-medium'
|
|
||||||
: 'text-gray-700 hover:bg-gray-100'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
Quellenregister
|
|
||||||
</button>
|
</button>
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -543,7 +123,6 @@ export default function ControlProvenancePage() {
|
|||||||
{/* Right: Content */}
|
{/* Right: Content */}
|
||||||
<div className="flex-1 overflow-y-auto p-6">
|
<div className="flex-1 overflow-y-auto p-6">
|
||||||
<div className="max-w-3xl mx-auto">
|
<div className="max-w-3xl mx-auto">
|
||||||
{/* Static documentation sections */}
|
|
||||||
{currentSection && (
|
{currentSection && (
|
||||||
<div>
|
<div>
|
||||||
<h2 className="text-xl font-bold text-gray-900 mb-4">{currentSection.title}</h2>
|
<h2 className="text-xl font-bold text-gray-900 mb-4">{currentSection.title}</h2>
|
||||||
@@ -552,101 +131,11 @@ export default function ControlProvenancePage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* License Matrix (live data) */}
|
|
||||||
{activeSection === 'license-matrix' && (
|
{activeSection === 'license-matrix' && (
|
||||||
<div>
|
<LicenseMatrix licenses={licenses} loading={loading} />
|
||||||
<h2 className="text-xl font-bold text-gray-900 mb-4">Lizenz-Matrix</h2>
|
|
||||||
<p className="text-sm text-gray-600 mb-4">
|
|
||||||
Uebersicht aller Lizenzen mit ihren erlaubten Nutzungsarten.
|
|
||||||
</p>
|
|
||||||
{loading ? (
|
|
||||||
<div className="animate-pulse h-32 bg-gray-100 rounded" />
|
|
||||||
) : (
|
|
||||||
<div className="overflow-x-auto">
|
|
||||||
<table className="w-full text-sm border-collapse">
|
|
||||||
<thead>
|
|
||||||
<tr className="bg-gray-50">
|
|
||||||
<th className="text-left px-3 py-2 border-b font-medium text-gray-600">Lizenz</th>
|
|
||||||
<th className="text-left px-3 py-2 border-b font-medium text-gray-600">Kommerziell</th>
|
|
||||||
<th className="text-left px-3 py-2 border-b font-medium text-gray-600">AI-Training</th>
|
|
||||||
<th className="text-left px-3 py-2 border-b font-medium text-gray-600">TDM (§44b)</th>
|
|
||||||
<th className="text-left px-3 py-2 border-b font-medium text-gray-600">Loeschpflicht</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{licenses.map(lic => (
|
|
||||||
<tr key={lic.license_id} className="hover:bg-gray-50">
|
|
||||||
<td className="px-3 py-2 border-b">
|
|
||||||
<div className="font-medium text-gray-900">{lic.license_id}</div>
|
|
||||||
<div className="text-xs text-gray-500">{lic.name}</div>
|
|
||||||
</td>
|
|
||||||
<td className="px-3 py-2 border-b">
|
|
||||||
<UsageBadge value={lic.commercial_use} />
|
|
||||||
</td>
|
|
||||||
<td className="px-3 py-2 border-b">
|
|
||||||
<UsageBadge value={lic.ai_training_restriction || 'n/a'} />
|
|
||||||
</td>
|
|
||||||
<td className="px-3 py-2 border-b">
|
|
||||||
<UsageBadge value={lic.tdm_allowed_under_44b || 'unclear'} />
|
|
||||||
</td>
|
|
||||||
<td className="px-3 py-2 border-b">
|
|
||||||
{lic.deletion_required ? (
|
|
||||||
<span className="text-red-600 text-xs font-medium">Ja</span>
|
|
||||||
) : (
|
|
||||||
<span className="text-green-600 text-xs font-medium">Nein</span>
|
|
||||||
)}
|
)}
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Source Registry (live data) */}
|
|
||||||
{activeSection === 'source-registry' && (
|
{activeSection === 'source-registry' && (
|
||||||
<div>
|
<SourceRegistry sources={sources} loading={loading} />
|
||||||
<h2 className="text-xl font-bold text-gray-900 mb-4">Quellenregister</h2>
|
|
||||||
<p className="text-sm text-gray-600 mb-4">
|
|
||||||
Alle registrierten Quellen mit ihren Berechtigungen.
|
|
||||||
</p>
|
|
||||||
{loading ? (
|
|
||||||
<div className="animate-pulse h-32 bg-gray-100 rounded" />
|
|
||||||
) : (
|
|
||||||
<div className="space-y-3">
|
|
||||||
{sources.map(src => (
|
|
||||||
<div key={src.source_id} className="bg-white border border-gray-200 rounded-lg p-4">
|
|
||||||
<div className="flex items-start justify-between mb-2">
|
|
||||||
<div>
|
|
||||||
<h3 className="text-sm font-medium text-gray-900">{src.title}</h3>
|
|
||||||
<p className="text-xs text-gray-500">{src.publisher} — {src.license_name}</p>
|
|
||||||
</div>
|
|
||||||
{src.url && (
|
|
||||||
<a
|
|
||||||
href={src.url}
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
className="flex items-center gap-1 text-xs text-blue-600 hover:text-blue-800"
|
|
||||||
>
|
|
||||||
<ExternalLink className="w-3 h-3" />
|
|
||||||
Quelle
|
|
||||||
</a>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-3 mt-2">
|
|
||||||
<PermBadge label="Analyse" allowed={src.allowed_analysis} />
|
|
||||||
<PermBadge label="Excerpt" allowed={src.allowed_store_excerpt} />
|
|
||||||
<PermBadge label="Embeddings" allowed={src.allowed_ship_embeddings} />
|
|
||||||
<PermBadge label="Produkt" allowed={src.allowed_ship_in_product} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -654,86 +143,3 @@ export default function ControlProvenancePage() {
|
|||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// =============================================================================
|
|
||||||
// HELPER COMPONENTS
|
|
||||||
// =============================================================================
|
|
||||||
|
|
||||||
function UsageBadge({ value }: { value: string }) {
|
|
||||||
const config: Record<string, { bg: string; label: string }> = {
|
|
||||||
allowed: { bg: 'bg-green-100 text-green-800', label: 'Erlaubt' },
|
|
||||||
restricted: { bg: 'bg-yellow-100 text-yellow-800', label: 'Eingeschraenkt' },
|
|
||||||
prohibited: { bg: 'bg-red-100 text-red-800', label: 'Verboten' },
|
|
||||||
unclear: { bg: 'bg-gray-100 text-gray-600', label: 'Unklar' },
|
|
||||||
yes: { bg: 'bg-green-100 text-green-800', label: 'Ja' },
|
|
||||||
no: { bg: 'bg-red-100 text-red-800', label: 'Nein' },
|
|
||||||
'n/a': { bg: 'bg-gray-100 text-gray-400', label: 'k.A.' },
|
|
||||||
}
|
|
||||||
const c = config[value] || config.unclear
|
|
||||||
return <span className={`inline-flex px-1.5 py-0.5 rounded text-xs font-medium ${c.bg}`}>{c.label}</span>
|
|
||||||
}
|
|
||||||
|
|
||||||
function PermBadge({ label, allowed }: { label: string; allowed: boolean }) {
|
|
||||||
return (
|
|
||||||
<div className="flex items-center gap-1">
|
|
||||||
{allowed ? (
|
|
||||||
<CheckCircle2 className="w-3 h-3 text-green-500" />
|
|
||||||
) : (
|
|
||||||
<Lock className="w-3 h-3 text-red-400" />
|
|
||||||
)}
|
|
||||||
<span className={`text-xs ${allowed ? 'text-green-700' : 'text-red-500'}`}>{label}</span>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function MarkdownRenderer({ content }: { content: string }) {
|
|
||||||
let html = content
|
|
||||||
.replace(/&/g, '&')
|
|
||||||
.replace(/</g, '<')
|
|
||||||
.replace(/>/g, '>')
|
|
||||||
|
|
||||||
// Code blocks
|
|
||||||
html = html.replace(
|
|
||||||
/^```[\w]*\n([\s\S]*?)^```$/gm,
|
|
||||||
(_m, code: string) => `<pre class="bg-gray-50 border rounded p-3 my-3 text-xs font-mono overflow-x-auto whitespace-pre">${code.trimEnd()}</pre>`
|
|
||||||
)
|
|
||||||
|
|
||||||
// Tables
|
|
||||||
html = html.replace(
|
|
||||||
/^(\|.+\|)\n(\|[\s:|-]+\|)\n((?:\|.+\|\n?)*)/gm,
|
|
||||||
(_m, header: string, _sep: string, body: string) => {
|
|
||||||
const ths = header.split('|').filter((c: string) => c.trim()).map((c: string) =>
|
|
||||||
`<th class="px-3 py-2 text-left text-xs font-medium text-gray-500 uppercase border-b">${c.trim()}</th>`
|
|
||||||
).join('')
|
|
||||||
const rows = body.trim().split('\n').map((row: string) => {
|
|
||||||
const tds = row.split('|').filter((c: string) => c.trim()).map((c: string) =>
|
|
||||||
`<td class="px-3 py-2 text-sm text-gray-700 border-b border-gray-100">${c.trim()}</td>`
|
|
||||||
).join('')
|
|
||||||
return `<tr>${tds}</tr>`
|
|
||||||
}).join('')
|
|
||||||
return `<table class="w-full border-collapse my-3 text-sm"><thead><tr>${ths}</tr></thead><tbody>${rows}</tbody></table>`
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
// Headers
|
|
||||||
html = html.replace(/^### (.+)$/gm, '<h4 class="text-sm font-semibold text-gray-800 mt-4 mb-2">$1</h4>')
|
|
||||||
html = html.replace(/^## (.+)$/gm, '<h3 class="text-base font-semibold text-gray-900 mt-5 mb-2">$1</h3>')
|
|
||||||
|
|
||||||
// Bold
|
|
||||||
html = html.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>')
|
|
||||||
|
|
||||||
// Inline code
|
|
||||||
html = html.replace(/`([^`]+)`/g, '<code class="bg-gray-100 px-1 py-0.5 rounded text-xs font-mono">$1</code>')
|
|
||||||
|
|
||||||
// Lists
|
|
||||||
html = html.replace(/^- (.+)$/gm, '<li class="ml-4 text-sm text-gray-700 list-disc">$1</li>')
|
|
||||||
html = html.replace(/((?:<li[^>]*>.*<\/li>\n?)+)/g, '<ul class="my-2 space-y-1">$1</ul>')
|
|
||||||
|
|
||||||
// Numbered lists
|
|
||||||
html = html.replace(/^(\d+)\. (.+)$/gm, '<li class="ml-4 text-sm text-gray-700 list-decimal">$2</li>')
|
|
||||||
|
|
||||||
// Paragraphs
|
|
||||||
html = html.replace(/^(?!<[hultdp]|$)(.+)$/gm, '<p class="text-sm text-gray-700 my-2">$1</p>')
|
|
||||||
|
|
||||||
return <div dangerouslySetInnerHTML={{ __html: html }} />
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -0,0 +1,61 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useState } from 'react'
|
||||||
|
import type { VerificationItem } from './verification-types'
|
||||||
|
|
||||||
|
export function CompleteModal({
|
||||||
|
item, onSubmit, onClose,
|
||||||
|
}: {
|
||||||
|
item: VerificationItem
|
||||||
|
onSubmit: (id: string, result: string, passed: boolean) => void
|
||||||
|
onClose: () => void
|
||||||
|
}) {
|
||||||
|
const [result, setResult] = useState('')
|
||||||
|
const [passed, setPassed] = useState(true)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
|
||||||
|
<div className="bg-white dark:bg-gray-800 rounded-xl w-full max-w-lg p-6">
|
||||||
|
<h3 className="text-lg font-semibold text-gray-900 dark:text-white mb-4">
|
||||||
|
Verifikation abschliessen: {item.title}
|
||||||
|
</h3>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Ergebnis</label>
|
||||||
|
<textarea
|
||||||
|
value={result} onChange={(e) => setResult(e.target.value)}
|
||||||
|
rows={3} placeholder="Beschreiben Sie das Ergebnis der Verifikation..."
|
||||||
|
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent dark:bg-gray-700 dark:border-gray-600 dark:text-white"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Bewertung</label>
|
||||||
|
<div className="flex gap-3">
|
||||||
|
<button
|
||||||
|
onClick={() => setPassed(true)}
|
||||||
|
className={`flex-1 py-2 rounded-lg border text-sm font-medium transition-colors ${passed ? 'border-green-400 bg-green-50 text-green-700' : 'border-gray-200 text-gray-500 hover:bg-gray-50'}`}
|
||||||
|
>
|
||||||
|
Bestanden
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setPassed(false)}
|
||||||
|
className={`flex-1 py-2 rounded-lg border text-sm font-medium transition-colors ${!passed ? 'border-red-400 bg-red-50 text-red-700' : 'border-gray-200 text-gray-500 hover:bg-gray-50'}`}
|
||||||
|
>
|
||||||
|
Nicht bestanden
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="mt-6 flex items-center gap-3">
|
||||||
|
<button
|
||||||
|
onClick={() => onSubmit(item.id, result, passed)} disabled={!result}
|
||||||
|
className={`px-6 py-2 rounded-lg font-medium transition-colors ${result ? 'bg-purple-600 text-white hover:bg-purple-700' : 'bg-gray-200 text-gray-400 cursor-not-allowed'}`}
|
||||||
|
>
|
||||||
|
Abschliessen
|
||||||
|
</button>
|
||||||
|
<button onClick={onClose} className="px-4 py-2 text-gray-600 hover:bg-gray-100 rounded-lg transition-colors">Abbrechen</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useState } from 'react'
|
||||||
|
import { VERIFICATION_METHODS, type SuggestedEvidence } from './verification-types'
|
||||||
|
|
||||||
|
export function SuggestEvidenceModal({
|
||||||
|
mitigations, projectId, onAddEvidence, onClose,
|
||||||
|
}: {
|
||||||
|
mitigations: { id: string; title: string }[]
|
||||||
|
projectId: string
|
||||||
|
onAddEvidence: (title: string, description: string, method: string, mitigationId: string) => void
|
||||||
|
onClose: () => void
|
||||||
|
}) {
|
||||||
|
const [selectedMitigation, setSelectedMitigation] = useState<string>('')
|
||||||
|
const [suggested, setSuggested] = useState<SuggestedEvidence[]>([])
|
||||||
|
const [loadingSuggestions, setLoadingSuggestions] = useState(false)
|
||||||
|
|
||||||
|
async function handleSelectMitigation(mitigationId: string) {
|
||||||
|
setSelectedMitigation(mitigationId)
|
||||||
|
setSuggested([])
|
||||||
|
if (!mitigationId) return
|
||||||
|
setLoadingSuggestions(true)
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/sdk/v1/iace/projects/${projectId}/mitigations/${mitigationId}/suggest-evidence`, {
|
||||||
|
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||||
|
})
|
||||||
|
if (res.ok) { const json = await res.json(); setSuggested(json.suggested_evidence || []) }
|
||||||
|
} catch (err) { console.error('Failed to suggest evidence:', err) }
|
||||||
|
finally { setLoadingSuggestions(false) }
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
|
||||||
|
<div className="bg-white dark:bg-gray-800 rounded-xl w-full max-w-3xl max-h-[85vh] flex flex-col">
|
||||||
|
<div className="p-6 border-b border-gray-200 dark:border-gray-700">
|
||||||
|
<div className="flex items-center justify-between mb-4">
|
||||||
|
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">Nachweise vorschlagen</h3>
|
||||||
|
<button onClick={onClose} className="p-1 text-gray-400 hover:text-gray-600 rounded">
|
||||||
|
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-gray-500 mb-3">
|
||||||
|
Waehlen Sie eine Massnahme, um passende Nachweismethoden vorgeschlagen zu bekommen.
|
||||||
|
</p>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{mitigations.map(m => (
|
||||||
|
<button
|
||||||
|
key={m.id} onClick={() => handleSelectMitigation(m.id)}
|
||||||
|
className={`px-3 py-1.5 text-xs rounded-lg border transition-colors ${
|
||||||
|
selectedMitigation === m.id
|
||||||
|
? 'border-purple-400 bg-purple-50 text-purple-700 font-medium'
|
||||||
|
: 'border-gray-200 bg-white text-gray-700 hover:border-purple-300'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{m.title}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 overflow-auto p-6">
|
||||||
|
{loadingSuggestions ? (
|
||||||
|
<div className="flex items-center justify-center py-12">
|
||||||
|
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-purple-600" />
|
||||||
|
</div>
|
||||||
|
) : suggested.length > 0 ? (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{suggested.map(ev => (
|
||||||
|
<div key={ev.id} className="border border-gray-200 rounded-lg p-4 hover:bg-gray-50 transition-colors">
|
||||||
|
<div className="flex items-start justify-between">
|
||||||
|
<div className="flex-1">
|
||||||
|
<div className="flex items-center gap-2 mb-1">
|
||||||
|
<span className="text-xs font-mono text-gray-400">{ev.id}</span>
|
||||||
|
{ev.method && (
|
||||||
|
<span className="text-xs px-1.5 py-0.5 rounded bg-blue-50 text-blue-600">
|
||||||
|
{VERIFICATION_METHODS.find(m => m.value === ev.method)?.label || ev.method}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="text-sm font-medium text-gray-900 dark:text-white">{ev.name}</div>
|
||||||
|
<div className="text-xs text-gray-500 mt-0.5">{ev.description}</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => onAddEvidence(ev.name, ev.description, ev.method || 'test_report', selectedMitigation)}
|
||||||
|
className="ml-3 px-3 py-1.5 text-xs bg-purple-600 text-white rounded-lg hover:bg-purple-700 transition-colors flex-shrink-0"
|
||||||
|
>
|
||||||
|
Uebernehmen
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : selectedMitigation ? (
|
||||||
|
<div className="text-center py-12 text-gray-500">Keine Vorschlaege fuer diese Massnahme gefunden.</div>
|
||||||
|
) : (
|
||||||
|
<div className="text-center py-12 text-gray-500">Waehlen Sie eine Massnahme aus, um Nachweise vorgeschlagen zu bekommen.</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useState } from 'react'
|
||||||
|
import { VERIFICATION_METHODS, type VerificationFormData } from './verification-types'
|
||||||
|
|
||||||
|
export function VerificationForm({
|
||||||
|
onSubmit, onCancel, hazards, mitigations,
|
||||||
|
}: {
|
||||||
|
onSubmit: (data: VerificationFormData) => void
|
||||||
|
onCancel: () => void
|
||||||
|
hazards: { id: string; name: string }[]
|
||||||
|
mitigations: { id: string; title: string }[]
|
||||||
|
}) {
|
||||||
|
const [formData, setFormData] = useState<VerificationFormData>({
|
||||||
|
title: '', description: '', method: 'test', linked_hazard_id: '', linked_mitigation_id: '',
|
||||||
|
})
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 p-6">
|
||||||
|
<h3 className="text-lg font-semibold text-gray-900 dark:text-white mb-4">Neues Verifikationselement</h3>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Titel *</label>
|
||||||
|
<input
|
||||||
|
type="text" value={formData.title}
|
||||||
|
onChange={(e) => setFormData({ ...formData, title: e.target.value })}
|
||||||
|
placeholder="z.B. Funktionstest Lichtvorhang"
|
||||||
|
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent dark:bg-gray-700 dark:border-gray-600 dark:text-white"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Methode</label>
|
||||||
|
<select
|
||||||
|
value={formData.method}
|
||||||
|
onChange={(e) => setFormData({ ...formData, method: e.target.value })}
|
||||||
|
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent dark:bg-gray-700 dark:border-gray-600 dark:text-white"
|
||||||
|
>
|
||||||
|
{VERIFICATION_METHODS.map((m) => <option key={m.value} value={m.value}>{m.label}</option>)}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Beschreibung</label>
|
||||||
|
<textarea
|
||||||
|
value={formData.description}
|
||||||
|
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
|
||||||
|
rows={2}
|
||||||
|
placeholder="Beschreiben Sie den Verifikationsschritt..."
|
||||||
|
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent dark:bg-gray-700 dark:border-gray-600 dark:text-white"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Verknuepfte Gefaehrdung</label>
|
||||||
|
<select
|
||||||
|
value={formData.linked_hazard_id}
|
||||||
|
onChange={(e) => setFormData({ ...formData, linked_hazard_id: e.target.value })}
|
||||||
|
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent dark:bg-gray-700 dark:border-gray-600 dark:text-white"
|
||||||
|
>
|
||||||
|
<option value="">-- Keine --</option>
|
||||||
|
{hazards.map((h) => <option key={h.id} value={h.id}>{h.name}</option>)}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Verknuepfte Massnahme</label>
|
||||||
|
<select
|
||||||
|
value={formData.linked_mitigation_id}
|
||||||
|
onChange={(e) => setFormData({ ...formData, linked_mitigation_id: e.target.value })}
|
||||||
|
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent dark:bg-gray-700 dark:border-gray-600 dark:text-white"
|
||||||
|
>
|
||||||
|
<option value="">-- Keine --</option>
|
||||||
|
{mitigations.map((m) => <option key={m.id} value={m.id}>{m.title}</option>)}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="mt-4 flex items-center gap-3">
|
||||||
|
<button
|
||||||
|
onClick={() => onSubmit(formData)} disabled={!formData.title}
|
||||||
|
className={`px-6 py-2 rounded-lg font-medium transition-colors ${formData.title ? 'bg-purple-600 text-white hover:bg-purple-700' : 'bg-gray-200 text-gray-400 cursor-not-allowed'}`}
|
||||||
|
>
|
||||||
|
Hinzufuegen
|
||||||
|
</button>
|
||||||
|
<button onClick={onCancel} className="px-4 py-2 text-gray-600 hover:bg-gray-100 rounded-lg transition-colors">Abbrechen</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { VERIFICATION_METHODS, STATUS_CONFIG, type VerificationItem } from './verification-types'
|
||||||
|
|
||||||
|
function StatusBadge({ status }: { status: string }) {
|
||||||
|
const config = STATUS_CONFIG[status] || STATUS_CONFIG.pending
|
||||||
|
return (
|
||||||
|
<span className={`inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium ${config.color}`}>
|
||||||
|
{config.label}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function VerificationTable({
|
||||||
|
items, onComplete, onDelete,
|
||||||
|
}: {
|
||||||
|
items: VerificationItem[]
|
||||||
|
onComplete: (item: VerificationItem) => void
|
||||||
|
onDelete: (id: string) => void
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 overflow-hidden">
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full">
|
||||||
|
<thead>
|
||||||
|
<tr className="bg-gray-50 dark:bg-gray-750 border-b border-gray-200 dark:border-gray-700">
|
||||||
|
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Titel</th>
|
||||||
|
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Methode</th>
|
||||||
|
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Gefaehrdung</th>
|
||||||
|
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Massnahme</th>
|
||||||
|
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Status</th>
|
||||||
|
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Ergebnis</th>
|
||||||
|
<th className="px-4 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">Aktionen</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-gray-200 dark:divide-gray-700">
|
||||||
|
{items.map((item) => (
|
||||||
|
<tr key={item.id} className="hover:bg-gray-50 dark:hover:bg-gray-750 transition-colors">
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
<div className="text-sm font-medium text-gray-900 dark:text-white">{item.title}</div>
|
||||||
|
{item.description && <div className="text-xs text-gray-500 truncate max-w-[200px]">{item.description}</div>}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
<span className="text-xs px-2 py-0.5 rounded bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-300">
|
||||||
|
{VERIFICATION_METHODS.find((m) => m.value === item.method)?.label || item.method}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 text-sm text-gray-600">{item.linked_hazard_name || '--'}</td>
|
||||||
|
<td className="px-4 py-3 text-sm text-gray-600">{item.linked_mitigation_name || '--'}</td>
|
||||||
|
<td className="px-4 py-3"><StatusBadge status={item.status} /></td>
|
||||||
|
<td className="px-4 py-3 text-sm text-gray-600 max-w-[150px] truncate">{item.result || '--'}</td>
|
||||||
|
<td className="px-4 py-3 text-right">
|
||||||
|
<div className="flex items-center justify-end gap-1">
|
||||||
|
{item.status !== 'completed' && item.status !== 'failed' && (
|
||||||
|
<button
|
||||||
|
onClick={() => onComplete(item)}
|
||||||
|
className="text-xs px-2.5 py-1 bg-green-50 text-green-700 border border-green-200 rounded-lg hover:bg-green-100 transition-colors"
|
||||||
|
>
|
||||||
|
Abschliessen
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
onClick={() => onDelete(item.id)}
|
||||||
|
className="p-1 text-gray-400 hover:text-red-600 hover:bg-red-50 rounded transition-colors"
|
||||||
|
>
|
||||||
|
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
export interface VerificationItem {
|
||||||
|
id: string
|
||||||
|
title: string
|
||||||
|
description: string
|
||||||
|
method: string
|
||||||
|
status: 'pending' | 'in_progress' | 'completed' | 'failed'
|
||||||
|
result: string | null
|
||||||
|
linked_hazard_id: string | null
|
||||||
|
linked_hazard_name: string | null
|
||||||
|
linked_mitigation_id: string | null
|
||||||
|
linked_mitigation_name: string | null
|
||||||
|
completed_at: string | null
|
||||||
|
completed_by: string | null
|
||||||
|
created_at: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface VerificationFormData {
|
||||||
|
title: string
|
||||||
|
description: string
|
||||||
|
method: string
|
||||||
|
linked_hazard_id: string
|
||||||
|
linked_mitigation_id: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SuggestedEvidence {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
description: string
|
||||||
|
method: string
|
||||||
|
tags?: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export const VERIFICATION_METHODS = [
|
||||||
|
{ value: 'design_review', label: 'Design-Review', description: 'Systematische Pruefung der Konstruktionsunterlagen' },
|
||||||
|
{ value: 'calculation', label: 'Berechnung', description: 'Rechnerischer Nachweis (FEM, Festigkeit, Thermik)' },
|
||||||
|
{ value: 'test_report', label: 'Pruefbericht', description: 'Dokumentierter Test mit Messprotokoll' },
|
||||||
|
{ value: 'validation', label: 'Validierung', description: 'Nachweis der Eignung unter realen Betriebsbedingungen' },
|
||||||
|
{ value: 'electrical_test', label: 'Elektrische Pruefung', description: 'Isolationsmessung, Schutzleiter, Spannungsfestigkeit' },
|
||||||
|
{ value: 'software_test', label: 'Software-Test', description: 'Unit-, Integrations- oder Systemtest der Steuerungssoftware' },
|
||||||
|
{ value: 'penetration_test', label: 'Penetrationstest', description: 'Security-Test der Netzwerk- und Steuerungskomponenten' },
|
||||||
|
{ value: 'acceptance_protocol', label: 'Abnahmeprotokoll', description: 'Formelle Abnahme mit Checkliste und Unterschrift' },
|
||||||
|
{ value: 'user_test', label: 'Anwendertest', description: 'Pruefung durch Bediener unter realen Einsatzbedingungen' },
|
||||||
|
{ value: 'documentation_release', label: 'Dokumentenfreigabe', description: 'Formelle Freigabe der technischen Dokumentation' },
|
||||||
|
]
|
||||||
|
|
||||||
|
export const STATUS_CONFIG: Record<string, { label: string; color: string }> = {
|
||||||
|
pending: { label: 'Ausstehend', color: 'bg-gray-100 text-gray-700' },
|
||||||
|
in_progress: { label: 'In Bearbeitung', color: 'bg-blue-100 text-blue-700' },
|
||||||
|
completed: { label: 'Abgeschlossen', color: 'bg-green-100 text-green-700' },
|
||||||
|
failed: { label: 'Fehlgeschlagen', color: 'bg-red-100 text-red-700' },
|
||||||
|
}
|
||||||
@@ -2,376 +2,11 @@
|
|||||||
|
|
||||||
import React, { useState, useEffect } from 'react'
|
import React, { useState, useEffect } from 'react'
|
||||||
import { useParams } from 'next/navigation'
|
import { useParams } from 'next/navigation'
|
||||||
|
import type { VerificationItem, VerificationFormData } from './_components/verification-types'
|
||||||
interface VerificationItem {
|
import { VerificationForm } from './_components/VerificationForm'
|
||||||
id: string
|
import { CompleteModal } from './_components/CompleteModal'
|
||||||
title: string
|
import { SuggestEvidenceModal } from './_components/SuggestEvidenceModal'
|
||||||
description: string
|
import { VerificationTable } from './_components/VerificationTable'
|
||||||
method: string
|
|
||||||
status: 'pending' | 'in_progress' | 'completed' | 'failed'
|
|
||||||
result: string | null
|
|
||||||
linked_hazard_id: string | null
|
|
||||||
linked_hazard_name: string | null
|
|
||||||
linked_mitigation_id: string | null
|
|
||||||
linked_mitigation_name: string | null
|
|
||||||
completed_at: string | null
|
|
||||||
completed_by: string | null
|
|
||||||
created_at: string
|
|
||||||
}
|
|
||||||
|
|
||||||
interface SuggestedEvidence {
|
|
||||||
id: string
|
|
||||||
name: string
|
|
||||||
description: string
|
|
||||||
method: string
|
|
||||||
tags?: string[]
|
|
||||||
}
|
|
||||||
|
|
||||||
const VERIFICATION_METHODS = [
|
|
||||||
{ value: 'design_review', label: 'Design-Review', description: 'Systematische Pruefung der Konstruktionsunterlagen' },
|
|
||||||
{ value: 'calculation', label: 'Berechnung', description: 'Rechnerischer Nachweis (FEM, Festigkeit, Thermik)' },
|
|
||||||
{ value: 'test_report', label: 'Pruefbericht', description: 'Dokumentierter Test mit Messprotokoll' },
|
|
||||||
{ value: 'validation', label: 'Validierung', description: 'Nachweis der Eignung unter realen Betriebsbedingungen' },
|
|
||||||
{ value: 'electrical_test', label: 'Elektrische Pruefung', description: 'Isolationsmessung, Schutzleiter, Spannungsfestigkeit' },
|
|
||||||
{ value: 'software_test', label: 'Software-Test', description: 'Unit-, Integrations- oder Systemtest der Steuerungssoftware' },
|
|
||||||
{ value: 'penetration_test', label: 'Penetrationstest', description: 'Security-Test der Netzwerk- und Steuerungskomponenten' },
|
|
||||||
{ value: 'acceptance_protocol', label: 'Abnahmeprotokoll', description: 'Formelle Abnahme mit Checkliste und Unterschrift' },
|
|
||||||
{ value: 'user_test', label: 'Anwendertest', description: 'Pruefung durch Bediener unter realen Einsatzbedingungen' },
|
|
||||||
{ value: 'documentation_release', label: 'Dokumentenfreigabe', description: 'Formelle Freigabe der technischen Dokumentation' },
|
|
||||||
]
|
|
||||||
|
|
||||||
const STATUS_CONFIG: Record<string, { label: string; color: string }> = {
|
|
||||||
pending: { label: 'Ausstehend', color: 'bg-gray-100 text-gray-700' },
|
|
||||||
in_progress: { label: 'In Bearbeitung', color: 'bg-blue-100 text-blue-700' },
|
|
||||||
completed: { label: 'Abgeschlossen', color: 'bg-green-100 text-green-700' },
|
|
||||||
failed: { label: 'Fehlgeschlagen', color: 'bg-red-100 text-red-700' },
|
|
||||||
}
|
|
||||||
|
|
||||||
function StatusBadge({ status }: { status: string }) {
|
|
||||||
const config = STATUS_CONFIG[status] || STATUS_CONFIG.pending
|
|
||||||
return (
|
|
||||||
<span className={`inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium ${config.color}`}>
|
|
||||||
{config.label}
|
|
||||||
</span>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
interface VerificationFormData {
|
|
||||||
title: string
|
|
||||||
description: string
|
|
||||||
method: string
|
|
||||||
linked_hazard_id: string
|
|
||||||
linked_mitigation_id: string
|
|
||||||
}
|
|
||||||
|
|
||||||
function VerificationForm({
|
|
||||||
onSubmit,
|
|
||||||
onCancel,
|
|
||||||
hazards,
|
|
||||||
mitigations,
|
|
||||||
}: {
|
|
||||||
onSubmit: (data: VerificationFormData) => void
|
|
||||||
onCancel: () => void
|
|
||||||
hazards: { id: string; name: string }[]
|
|
||||||
mitigations: { id: string; title: string }[]
|
|
||||||
}) {
|
|
||||||
const [formData, setFormData] = useState<VerificationFormData>({
|
|
||||||
title: '',
|
|
||||||
description: '',
|
|
||||||
method: 'test',
|
|
||||||
linked_hazard_id: '',
|
|
||||||
linked_mitigation_id: '',
|
|
||||||
})
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 p-6">
|
|
||||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-white mb-4">Neues Verifikationselement</h3>
|
|
||||||
<div className="space-y-4">
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Titel *</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={formData.title}
|
|
||||||
onChange={(e) => setFormData({ ...formData, title: e.target.value })}
|
|
||||||
placeholder="z.B. Funktionstest Lichtvorhang"
|
|
||||||
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent dark:bg-gray-700 dark:border-gray-600 dark:text-white"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Methode</label>
|
|
||||||
<select
|
|
||||||
value={formData.method}
|
|
||||||
onChange={(e) => setFormData({ ...formData, method: e.target.value })}
|
|
||||||
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent dark:bg-gray-700 dark:border-gray-600 dark:text-white"
|
|
||||||
>
|
|
||||||
{VERIFICATION_METHODS.map((m) => (
|
|
||||||
<option key={m.value} value={m.value}>{m.label}</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Beschreibung</label>
|
|
||||||
<textarea
|
|
||||||
value={formData.description}
|
|
||||||
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
|
|
||||||
rows={2}
|
|
||||||
placeholder="Beschreiben Sie den Verifikationsschritt..."
|
|
||||||
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent dark:bg-gray-700 dark:border-gray-600 dark:text-white"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Verknuepfte Gefaehrdung</label>
|
|
||||||
<select
|
|
||||||
value={formData.linked_hazard_id}
|
|
||||||
onChange={(e) => setFormData({ ...formData, linked_hazard_id: e.target.value })}
|
|
||||||
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent dark:bg-gray-700 dark:border-gray-600 dark:text-white"
|
|
||||||
>
|
|
||||||
<option value="">-- Keine --</option>
|
|
||||||
{hazards.map((h) => (
|
|
||||||
<option key={h.id} value={h.id}>{h.name}</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Verknuepfte Massnahme</label>
|
|
||||||
<select
|
|
||||||
value={formData.linked_mitigation_id}
|
|
||||||
onChange={(e) => setFormData({ ...formData, linked_mitigation_id: e.target.value })}
|
|
||||||
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent dark:bg-gray-700 dark:border-gray-600 dark:text-white"
|
|
||||||
>
|
|
||||||
<option value="">-- Keine --</option>
|
|
||||||
{mitigations.map((m) => (
|
|
||||||
<option key={m.id} value={m.id}>{m.title}</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="mt-4 flex items-center gap-3">
|
|
||||||
<button
|
|
||||||
onClick={() => onSubmit(formData)}
|
|
||||||
disabled={!formData.title}
|
|
||||||
className={`px-6 py-2 rounded-lg font-medium transition-colors ${
|
|
||||||
formData.title
|
|
||||||
? 'bg-purple-600 text-white hover:bg-purple-700'
|
|
||||||
: 'bg-gray-200 text-gray-400 cursor-not-allowed'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
Hinzufuegen
|
|
||||||
</button>
|
|
||||||
<button onClick={onCancel} className="px-4 py-2 text-gray-600 hover:bg-gray-100 rounded-lg transition-colors">
|
|
||||||
Abbrechen
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function CompleteModal({
|
|
||||||
item,
|
|
||||||
onSubmit,
|
|
||||||
onClose,
|
|
||||||
}: {
|
|
||||||
item: VerificationItem
|
|
||||||
onSubmit: (id: string, result: string, passed: boolean) => void
|
|
||||||
onClose: () => void
|
|
||||||
}) {
|
|
||||||
const [result, setResult] = useState('')
|
|
||||||
const [passed, setPassed] = useState(true)
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
|
|
||||||
<div className="bg-white dark:bg-gray-800 rounded-xl w-full max-w-lg p-6">
|
|
||||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-white mb-4">
|
|
||||||
Verifikation abschliessen: {item.title}
|
|
||||||
</h3>
|
|
||||||
<div className="space-y-4">
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Ergebnis</label>
|
|
||||||
<textarea
|
|
||||||
value={result}
|
|
||||||
onChange={(e) => setResult(e.target.value)}
|
|
||||||
rows={3}
|
|
||||||
placeholder="Beschreiben Sie das Ergebnis der Verifikation..."
|
|
||||||
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent dark:bg-gray-700 dark:border-gray-600 dark:text-white"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Bewertung</label>
|
|
||||||
<div className="flex gap-3">
|
|
||||||
<button
|
|
||||||
onClick={() => setPassed(true)}
|
|
||||||
className={`flex-1 py-2 rounded-lg border text-sm font-medium transition-colors ${
|
|
||||||
passed
|
|
||||||
? 'border-green-400 bg-green-50 text-green-700'
|
|
||||||
: 'border-gray-200 text-gray-500 hover:bg-gray-50'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
Bestanden
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={() => setPassed(false)}
|
|
||||||
className={`flex-1 py-2 rounded-lg border text-sm font-medium transition-colors ${
|
|
||||||
!passed
|
|
||||||
? 'border-red-400 bg-red-50 text-red-700'
|
|
||||||
: 'border-gray-200 text-gray-500 hover:bg-gray-50'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
Nicht bestanden
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="mt-6 flex items-center gap-3">
|
|
||||||
<button
|
|
||||||
onClick={() => onSubmit(item.id, result, passed)}
|
|
||||||
disabled={!result}
|
|
||||||
className={`px-6 py-2 rounded-lg font-medium transition-colors ${
|
|
||||||
result
|
|
||||||
? 'bg-purple-600 text-white hover:bg-purple-700'
|
|
||||||
: 'bg-gray-200 text-gray-400 cursor-not-allowed'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
Abschliessen
|
|
||||||
</button>
|
|
||||||
<button onClick={onClose} className="px-4 py-2 text-gray-600 hover:bg-gray-100 rounded-lg transition-colors">
|
|
||||||
Abbrechen
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ============================================================================
|
|
||||||
// Suggest Evidence Modal (Phase 5)
|
|
||||||
// ============================================================================
|
|
||||||
|
|
||||||
function SuggestEvidenceModal({
|
|
||||||
mitigations,
|
|
||||||
projectId,
|
|
||||||
onAddEvidence,
|
|
||||||
onClose,
|
|
||||||
}: {
|
|
||||||
mitigations: { id: string; title: string }[]
|
|
||||||
projectId: string
|
|
||||||
onAddEvidence: (title: string, description: string, method: string, mitigationId: string) => void
|
|
||||||
onClose: () => void
|
|
||||||
}) {
|
|
||||||
const [selectedMitigation, setSelectedMitigation] = useState<string>('')
|
|
||||||
const [suggested, setSuggested] = useState<SuggestedEvidence[]>([])
|
|
||||||
const [loadingSuggestions, setLoadingSuggestions] = useState(false)
|
|
||||||
|
|
||||||
async function handleSelectMitigation(mitigationId: string) {
|
|
||||||
setSelectedMitigation(mitigationId)
|
|
||||||
setSuggested([])
|
|
||||||
if (!mitigationId) return
|
|
||||||
|
|
||||||
setLoadingSuggestions(true)
|
|
||||||
try {
|
|
||||||
const res = await fetch(`/api/sdk/v1/iace/projects/${projectId}/mitigations/${mitigationId}/suggest-evidence`, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
})
|
|
||||||
if (res.ok) {
|
|
||||||
const json = await res.json()
|
|
||||||
setSuggested(json.suggested_evidence || [])
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Failed to suggest evidence:', err)
|
|
||||||
} finally {
|
|
||||||
setLoadingSuggestions(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
|
|
||||||
<div className="bg-white dark:bg-gray-800 rounded-xl w-full max-w-3xl max-h-[85vh] flex flex-col">
|
|
||||||
<div className="p-6 border-b border-gray-200 dark:border-gray-700">
|
|
||||||
<div className="flex items-center justify-between mb-4">
|
|
||||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">Nachweise vorschlagen</h3>
|
|
||||||
<button onClick={onClose} className="p-1 text-gray-400 hover:text-gray-600 rounded">
|
|
||||||
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<p className="text-sm text-gray-500 mb-3">
|
|
||||||
Waehlen Sie eine Massnahme, um passende Nachweismethoden vorgeschlagen zu bekommen.
|
|
||||||
</p>
|
|
||||||
<div className="flex flex-wrap gap-2">
|
|
||||||
{mitigations.map(m => (
|
|
||||||
<button
|
|
||||||
key={m.id}
|
|
||||||
onClick={() => handleSelectMitigation(m.id)}
|
|
||||||
className={`px-3 py-1.5 text-xs rounded-lg border transition-colors ${
|
|
||||||
selectedMitigation === m.id
|
|
||||||
? 'border-purple-400 bg-purple-50 text-purple-700 font-medium'
|
|
||||||
: 'border-gray-200 bg-white text-gray-700 hover:border-purple-300'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{m.title}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex-1 overflow-auto p-6">
|
|
||||||
{loadingSuggestions ? (
|
|
||||||
<div className="flex items-center justify-center py-12">
|
|
||||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-purple-600" />
|
|
||||||
</div>
|
|
||||||
) : suggested.length > 0 ? (
|
|
||||||
<div className="space-y-3">
|
|
||||||
{suggested.map(ev => (
|
|
||||||
<div key={ev.id} className="border border-gray-200 rounded-lg p-4 hover:bg-gray-50 transition-colors">
|
|
||||||
<div className="flex items-start justify-between">
|
|
||||||
<div className="flex-1">
|
|
||||||
<div className="flex items-center gap-2 mb-1">
|
|
||||||
<span className="text-xs font-mono text-gray-400">{ev.id}</span>
|
|
||||||
{ev.method && (
|
|
||||||
<span className="text-xs px-1.5 py-0.5 rounded bg-blue-50 text-blue-600">
|
|
||||||
{VERIFICATION_METHODS.find(m => m.value === ev.method)?.label || ev.method}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div className="text-sm font-medium text-gray-900 dark:text-white">{ev.name}</div>
|
|
||||||
<div className="text-xs text-gray-500 mt-0.5">{ev.description}</div>
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
onClick={() => onAddEvidence(ev.name, ev.description, ev.method || 'test_report', selectedMitigation)}
|
|
||||||
className="ml-3 px-3 py-1.5 text-xs bg-purple-600 text-white rounded-lg hover:bg-purple-700 transition-colors flex-shrink-0"
|
|
||||||
>
|
|
||||||
Uebernehmen
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
) : selectedMitigation ? (
|
|
||||||
<div className="text-center py-12 text-gray-500">
|
|
||||||
Keine Vorschlaege fuer diese Massnahme gefunden.
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="text-center py-12 text-gray-500">
|
|
||||||
Waehlen Sie eine Massnahme aus, um Nachweise vorgeschlagen zu bekommen.
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ============================================================================
|
|
||||||
// Main Page
|
|
||||||
// ============================================================================
|
|
||||||
|
|
||||||
export default function VerificationPage() {
|
export default function VerificationPage() {
|
||||||
const params = useParams()
|
const params = useParams()
|
||||||
@@ -382,12 +17,9 @@ export default function VerificationPage() {
|
|||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
const [showForm, setShowForm] = useState(false)
|
const [showForm, setShowForm] = useState(false)
|
||||||
const [completingItem, setCompletingItem] = useState<VerificationItem | null>(null)
|
const [completingItem, setCompletingItem] = useState<VerificationItem | null>(null)
|
||||||
// Phase 5: Suggest evidence
|
|
||||||
const [showSuggest, setShowSuggest] = useState(false)
|
const [showSuggest, setShowSuggest] = useState(false)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => { fetchData() }, [projectId])
|
||||||
fetchData()
|
|
||||||
}, [projectId])
|
|
||||||
|
|
||||||
async function fetchData() {
|
async function fetchData() {
|
||||||
try {
|
try {
|
||||||
@@ -396,100 +28,58 @@ export default function VerificationPage() {
|
|||||||
fetch(`/api/sdk/v1/iace/projects/${projectId}/hazards`),
|
fetch(`/api/sdk/v1/iace/projects/${projectId}/hazards`),
|
||||||
fetch(`/api/sdk/v1/iace/projects/${projectId}/mitigations`),
|
fetch(`/api/sdk/v1/iace/projects/${projectId}/mitigations`),
|
||||||
])
|
])
|
||||||
if (verRes.ok) {
|
if (verRes.ok) { const j = await verRes.json(); setItems(j.verifications || j || []) }
|
||||||
const json = await verRes.json()
|
if (hazRes.ok) { const j = await hazRes.json(); setHazards((j.hazards || j || []).map((h: { id: string; name: string }) => ({ id: h.id, name: h.name }))) }
|
||||||
setItems(json.verifications || json || [])
|
if (mitRes.ok) { const j = await mitRes.json(); setMitigations((j.mitigations || j || []).map((m: { id: string; title: string }) => ({ id: m.id, title: m.title }))) }
|
||||||
}
|
} catch (err) { console.error('Failed to fetch data:', err) }
|
||||||
if (hazRes.ok) {
|
finally { setLoading(false) }
|
||||||
const json = await hazRes.json()
|
|
||||||
setHazards((json.hazards || json || []).map((h: { id: string; name: string }) => ({ id: h.id, name: h.name })))
|
|
||||||
}
|
|
||||||
if (mitRes.ok) {
|
|
||||||
const json = await mitRes.json()
|
|
||||||
setMitigations((json.mitigations || json || []).map((m: { id: string; title: string }) => ({ id: m.id, title: m.title })))
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Failed to fetch data:', err)
|
|
||||||
} finally {
|
|
||||||
setLoading(false)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleSubmit(data: VerificationFormData) {
|
async function handleSubmit(data: VerificationFormData) {
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`/api/sdk/v1/iace/projects/${projectId}/verifications`, {
|
const res = await fetch(`/api/sdk/v1/iace/projects/${projectId}/verifications`, {
|
||||||
method: 'POST',
|
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(data),
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify(data),
|
|
||||||
})
|
})
|
||||||
if (res.ok) {
|
if (res.ok) { setShowForm(false); await fetchData() }
|
||||||
setShowForm(false)
|
} catch (err) { console.error('Failed to add verification:', err) }
|
||||||
await fetchData()
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Failed to add verification:', err)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleAddSuggestedEvidence(title: string, description: string, method: string, mitigationId: string) {
|
async function handleAddSuggestedEvidence(title: string, description: string, method: string, mitigationId: string) {
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`/api/sdk/v1/iace/projects/${projectId}/verifications`, {
|
const res = await fetch(`/api/sdk/v1/iace/projects/${projectId}/verifications`, {
|
||||||
method: 'POST',
|
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||||
headers: { 'Content-Type': 'application/json' },
|
body: JSON.stringify({ title, description, method, linked_mitigation_id: mitigationId }),
|
||||||
body: JSON.stringify({
|
|
||||||
title,
|
|
||||||
description,
|
|
||||||
method,
|
|
||||||
linked_mitigation_id: mitigationId,
|
|
||||||
}),
|
|
||||||
})
|
})
|
||||||
if (res.ok) {
|
if (res.ok) await fetchData()
|
||||||
await fetchData()
|
} catch (err) { console.error('Failed to add suggested evidence:', err) }
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Failed to add suggested evidence:', err)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleComplete(id: string, result: string, passed: boolean) {
|
async function handleComplete(id: string, result: string, passed: boolean) {
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`/api/sdk/v1/iace/projects/${projectId}/verifications/${id}/complete`, {
|
const res = await fetch(`/api/sdk/v1/iace/projects/${projectId}/verifications/${id}/complete`, {
|
||||||
method: 'POST',
|
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ result, passed }),
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({ result, passed }),
|
|
||||||
})
|
})
|
||||||
if (res.ok) {
|
if (res.ok) { setCompletingItem(null); await fetchData() }
|
||||||
setCompletingItem(null)
|
} catch (err) { console.error('Failed to complete verification:', err) }
|
||||||
await fetchData()
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Failed to complete verification:', err)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleDelete(id: string) {
|
async function handleDelete(id: string) {
|
||||||
if (!confirm('Verifikation wirklich loeschen?')) return
|
if (!confirm('Verifikation wirklich loeschen?')) return
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`/api/sdk/v1/iace/projects/${projectId}/verifications/${id}`, { method: 'DELETE' })
|
const res = await fetch(`/api/sdk/v1/iace/projects/${projectId}/verifications/${id}`, { method: 'DELETE' })
|
||||||
if (res.ok) {
|
if (res.ok) await fetchData()
|
||||||
await fetchData()
|
} catch (err) { console.error('Failed to delete verification:', err) }
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Failed to delete verification:', err)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const completed = items.filter((i) => i.status === 'completed').length
|
const completed = items.filter(i => i.status === 'completed').length
|
||||||
const failed = items.filter((i) => i.status === 'failed').length
|
const failed = items.filter(i => i.status === 'failed').length
|
||||||
const pending = items.filter((i) => i.status === 'pending' || i.status === 'in_progress').length
|
const pending = items.filter(i => i.status === 'pending' || i.status === 'in_progress').length
|
||||||
|
|
||||||
if (loading) {
|
if (loading) return (
|
||||||
return (
|
|
||||||
<div className="flex items-center justify-center h-64">
|
<div className="flex items-center justify-center h-64">
|
||||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-purple-600" />
|
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-purple-600" />
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
@@ -497,26 +87,18 @@ export default function VerificationPage() {
|
|||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-2xl font-bold text-gray-900 dark:text-white">Verifikationsplan</h1>
|
<h1 className="text-2xl font-bold text-gray-900 dark:text-white">Verifikationsplan</h1>
|
||||||
<p className="mt-1 text-sm text-gray-500 dark:text-gray-400">
|
<p className="mt-1 text-sm text-gray-500 dark:text-gray-400">Nachweisfuehrung fuer alle Schutzmassnahmen und Sicherheitsanforderungen.</p>
|
||||||
Nachweisfuehrung fuer alle Schutzmassnahmen und Sicherheitsanforderungen.
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
{mitigations.length > 0 && (
|
{mitigations.length > 0 && (
|
||||||
<button
|
<button onClick={() => setShowSuggest(true)} className="flex items-center gap-2 px-3 py-2 border border-green-300 text-green-700 rounded-lg hover:bg-green-50 transition-colors text-sm">
|
||||||
onClick={() => setShowSuggest(true)}
|
|
||||||
className="flex items-center gap-2 px-3 py-2 border border-green-300 text-green-700 rounded-lg hover:bg-green-50 transition-colors text-sm"
|
|
||||||
>
|
|
||||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 3v2m6-2v2M9 19v2m6-2v2M5 9H3m2 6H3m18-6h-2m2 6h-2M7 19h10a2 2 0 002-2V7a2 2 0 00-2-2H7a2 2 0 00-2 2v10a2 2 0 002 2zM9 9h6v6H9V9z" />
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 3v2m6-2v2M9 19v2m6-2v2M5 9H3m2 6H3m18-6h-2m2 6h-2M7 19h10a2 2 0 002-2V7a2 2 0 00-2-2H7a2 2 0 00-2 2v10a2 2 0 002 2zM9 9h6v6H9V9z" />
|
||||||
</svg>
|
</svg>
|
||||||
Nachweise vorschlagen
|
Nachweise vorschlagen
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
<button
|
<button onClick={() => setShowForm(true)} className="flex items-center gap-2 px-4 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700 transition-colors">
|
||||||
onClick={() => setShowForm(true)}
|
|
||||||
className="flex items-center gap-2 px-4 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700 transition-colors"
|
|
||||||
>
|
|
||||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 6v6m0 0v6m0-6h6m-6 0H6" />
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 6v6m0 0v6m0-6h6m-6 0H6" />
|
||||||
</svg>
|
</svg>
|
||||||
@@ -547,97 +129,13 @@ export default function VerificationPage() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Form */}
|
{showForm && <VerificationForm onSubmit={handleSubmit} onCancel={() => setShowForm(false)} hazards={hazards} mitigations={mitigations} />}
|
||||||
{showForm && (
|
{completingItem && <CompleteModal item={completingItem} onSubmit={handleComplete} onClose={() => setCompletingItem(null)} />}
|
||||||
<VerificationForm
|
{showSuggest && <SuggestEvidenceModal mitigations={mitigations} projectId={projectId} onAddEvidence={handleAddSuggestedEvidence} onClose={() => setShowSuggest(false)} />}
|
||||||
onSubmit={handleSubmit}
|
|
||||||
onCancel={() => setShowForm(false)}
|
|
||||||
hazards={hazards}
|
|
||||||
mitigations={mitigations}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Complete Modal */}
|
|
||||||
{completingItem && (
|
|
||||||
<CompleteModal
|
|
||||||
item={completingItem}
|
|
||||||
onSubmit={handleComplete}
|
|
||||||
onClose={() => setCompletingItem(null)}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Suggest Evidence Modal (Phase 5) */}
|
|
||||||
{showSuggest && (
|
|
||||||
<SuggestEvidenceModal
|
|
||||||
mitigations={mitigations}
|
|
||||||
projectId={projectId}
|
|
||||||
onAddEvidence={handleAddSuggestedEvidence}
|
|
||||||
onClose={() => setShowSuggest(false)}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Table */}
|
|
||||||
{items.length > 0 ? (
|
{items.length > 0 ? (
|
||||||
<div className="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 overflow-hidden">
|
<VerificationTable items={items} onComplete={setCompletingItem} onDelete={handleDelete} />
|
||||||
<div className="overflow-x-auto">
|
) : !showForm && (
|
||||||
<table className="w-full">
|
|
||||||
<thead>
|
|
||||||
<tr className="bg-gray-50 dark:bg-gray-750 border-b border-gray-200 dark:border-gray-700">
|
|
||||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Titel</th>
|
|
||||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Methode</th>
|
|
||||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Gefaehrdung</th>
|
|
||||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Massnahme</th>
|
|
||||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Status</th>
|
|
||||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Ergebnis</th>
|
|
||||||
<th className="px-4 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">Aktionen</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody className="divide-y divide-gray-200 dark:divide-gray-700">
|
|
||||||
{items.map((item) => (
|
|
||||||
<tr key={item.id} className="hover:bg-gray-50 dark:hover:bg-gray-750 transition-colors">
|
|
||||||
<td className="px-4 py-3">
|
|
||||||
<div className="text-sm font-medium text-gray-900 dark:text-white">{item.title}</div>
|
|
||||||
{item.description && (
|
|
||||||
<div className="text-xs text-gray-500 truncate max-w-[200px]">{item.description}</div>
|
|
||||||
)}
|
|
||||||
</td>
|
|
||||||
<td className="px-4 py-3">
|
|
||||||
<span className="text-xs px-2 py-0.5 rounded bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-300">
|
|
||||||
{VERIFICATION_METHODS.find((m) => m.value === item.method)?.label || item.method}
|
|
||||||
</span>
|
|
||||||
</td>
|
|
||||||
<td className="px-4 py-3 text-sm text-gray-600">{item.linked_hazard_name || '--'}</td>
|
|
||||||
<td className="px-4 py-3 text-sm text-gray-600">{item.linked_mitigation_name || '--'}</td>
|
|
||||||
<td className="px-4 py-3"><StatusBadge status={item.status} /></td>
|
|
||||||
<td className="px-4 py-3 text-sm text-gray-600 max-w-[150px] truncate">{item.result || '--'}</td>
|
|
||||||
<td className="px-4 py-3 text-right">
|
|
||||||
<div className="flex items-center justify-end gap-1">
|
|
||||||
{item.status !== 'completed' && item.status !== 'failed' && (
|
|
||||||
<button
|
|
||||||
onClick={() => setCompletingItem(item)}
|
|
||||||
className="text-xs px-2.5 py-1 bg-green-50 text-green-700 border border-green-200 rounded-lg hover:bg-green-100 transition-colors"
|
|
||||||
>
|
|
||||||
Abschliessen
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
<button
|
|
||||||
onClick={() => handleDelete(item.id)}
|
|
||||||
className="p-1 text-gray-400 hover:text-red-600 hover:bg-red-50 rounded transition-colors"
|
|
||||||
>
|
|
||||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
!showForm && (
|
|
||||||
<div className="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 p-12 text-center">
|
<div className="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 p-12 text-center">
|
||||||
<div className="w-16 h-16 mx-auto bg-purple-100 dark:bg-purple-900/30 rounded-full flex items-center justify-center mb-4">
|
<div className="w-16 h-16 mx-auto bg-purple-100 dark:bg-purple-900/30 rounded-full flex items-center justify-center mb-4">
|
||||||
<svg className="w-8 h-8 text-purple-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
<svg className="w-8 h-8 text-purple-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
@@ -651,22 +149,15 @@ export default function VerificationPage() {
|
|||||||
</p>
|
</p>
|
||||||
<div className="mt-6 flex items-center justify-center gap-3">
|
<div className="mt-6 flex items-center justify-center gap-3">
|
||||||
{mitigations.length > 0 && (
|
{mitigations.length > 0 && (
|
||||||
<button
|
<button onClick={() => setShowSuggest(true)} className="px-6 py-3 border border-green-300 text-green-700 rounded-lg hover:bg-green-50 transition-colors">
|
||||||
onClick={() => setShowSuggest(true)}
|
|
||||||
className="px-6 py-3 border border-green-300 text-green-700 rounded-lg hover:bg-green-50 transition-colors"
|
|
||||||
>
|
|
||||||
Nachweise vorschlagen
|
Nachweise vorschlagen
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
<button
|
<button onClick={() => setShowForm(true)} className="px-6 py-3 bg-purple-600 text-white rounded-lg hover:bg-purple-700 transition-colors">
|
||||||
onClick={() => setShowForm(true)}
|
|
||||||
className="px-6 py-3 bg-purple-600 text-white rounded-lg hover:bg-purple-700 transition-colors"
|
|
||||||
>
|
|
||||||
Erste Verifikation anlegen
|
Erste Verifikation anlegen
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
245
admin-compliance/app/sdk/training/_components/BlocksSection.tsx
Normal file
245
admin-compliance/app/sdk/training/_components/BlocksSection.tsx
Normal file
@@ -0,0 +1,245 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import type {
|
||||||
|
TrainingModule, TrainingBlockConfig, CanonicalControlMeta, BlockPreview, BlockGenerateResult,
|
||||||
|
} from '@/lib/sdk/training/types'
|
||||||
|
import { TARGET_AUDIENCE_LABELS, ROLE_LABELS, REGULATION_LABELS } from '@/lib/sdk/training/types'
|
||||||
|
|
||||||
|
interface BlocksSectionProps {
|
||||||
|
blocks: TrainingBlockConfig[]
|
||||||
|
canonicalMeta: CanonicalControlMeta | null
|
||||||
|
blockPreview: BlockPreview | null
|
||||||
|
blockPreviewId: string
|
||||||
|
blockGenerating: boolean
|
||||||
|
blockResult: BlockGenerateResult | null
|
||||||
|
showBlockCreate: boolean
|
||||||
|
onShowBlockCreate: (show: boolean) => void
|
||||||
|
onPreviewBlock: (id: string) => void
|
||||||
|
onGenerateBlock: (id: string) => void
|
||||||
|
onDeleteBlock: (id: string) => void
|
||||||
|
onCreateBlock: (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;
|
||||||
|
}) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function BlocksSection({
|
||||||
|
blocks, canonicalMeta, blockPreview, blockPreviewId, blockGenerating,
|
||||||
|
blockResult, showBlockCreate, onShowBlockCreate, onPreviewBlock, onGenerateBlock,
|
||||||
|
onDeleteBlock, onCreateBlock,
|
||||||
|
}: BlocksSectionProps) {
|
||||||
|
return (
|
||||||
|
<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={() => onShowBlockCreate(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>
|
||||||
|
|
||||||
|
{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={() => onPreviewBlock(block.id)} className="px-2 py-1 text-xs bg-gray-100 text-gray-700 rounded hover:bg-gray-200">Preview</button>
|
||||||
|
<button onClick={() => onGenerateBlock(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={() => onDeleteBlock(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>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{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>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{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>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{showBlockCreate && (
|
||||||
|
<BlockCreateModal
|
||||||
|
canonicalMeta={canonicalMeta}
|
||||||
|
onSubmit={onCreateBlock}
|
||||||
|
onClose={() => onShowBlockCreate(false)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function BlockCreateModal({
|
||||||
|
canonicalMeta,
|
||||||
|
onSubmit,
|
||||||
|
onClose,
|
||||||
|
}: {
|
||||||
|
canonicalMeta: CanonicalControlMeta | null
|
||||||
|
onSubmit: BlocksSectionProps['onCreateBlock']
|
||||||
|
onClose: () => void
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<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)
|
||||||
|
onSubmit({
|
||||||
|
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={onClose} 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -14,12 +14,14 @@ interface ContentTabProps {
|
|||||||
bulkGenerating: boolean
|
bulkGenerating: boolean
|
||||||
bulkResult: { generated: number; skipped: number; errors: string[] } | null
|
bulkResult: { generated: number; skipped: number; errors: string[] } | null
|
||||||
moduleMedia: TrainingMedia[]
|
moduleMedia: TrainingMedia[]
|
||||||
|
interactiveGenerating?: boolean
|
||||||
onGenerateContent: () => void
|
onGenerateContent: () => void
|
||||||
onGenerateQuiz: () => void
|
onGenerateQuiz: () => void
|
||||||
onBulkContent: () => void
|
onBulkContent: () => void
|
||||||
onBulkQuiz: () => void
|
onBulkQuiz: () => void
|
||||||
onPublishContent: (contentId: string) => void
|
onPublishContent: (contentId: string) => void
|
||||||
onReloadMedia: () => void
|
onReloadMedia: () => void
|
||||||
|
onGenerateInteractiveVideo?: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ContentTab({
|
export function ContentTab({
|
||||||
@@ -31,12 +33,14 @@ export function ContentTab({
|
|||||||
bulkGenerating,
|
bulkGenerating,
|
||||||
bulkResult,
|
bulkResult,
|
||||||
moduleMedia,
|
moduleMedia,
|
||||||
|
interactiveGenerating,
|
||||||
onGenerateContent,
|
onGenerateContent,
|
||||||
onGenerateQuiz,
|
onGenerateQuiz,
|
||||||
onBulkContent,
|
onBulkContent,
|
||||||
onBulkQuiz,
|
onBulkQuiz,
|
||||||
onPublishContent,
|
onPublishContent,
|
||||||
onReloadMedia,
|
onReloadMedia,
|
||||||
|
onGenerateInteractiveVideo,
|
||||||
}: ContentTabProps) {
|
}: ContentTabProps) {
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
@@ -139,6 +143,35 @@ export function ContentTab({
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Interactive Video */}
|
||||||
|
{selectedModuleId && generatedContent?.is_published && onGenerateInteractiveVideo && (
|
||||||
|
<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={onGenerateInteractiveVideo}
|
||||||
|
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 */}
|
{/* Script Preview */}
|
||||||
{selectedModuleId && generatedContent?.is_published && (
|
{selectedModuleId && generatedContent?.is_published && (
|
||||||
<ScriptPreview moduleId={selectedModuleId} />
|
<ScriptPreview moduleId={selectedModuleId} />
|
||||||
|
|||||||
@@ -0,0 +1,67 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import type { TrainingAssignment } from '@/lib/sdk/training/types'
|
||||||
|
import { STATUS_LABELS, STATUS_COLORS } from '@/lib/sdk/training/types'
|
||||||
|
|
||||||
|
interface AssignmentsListProps {
|
||||||
|
assignments: TrainingAssignment[]
|
||||||
|
loading: boolean
|
||||||
|
certGenerating: boolean
|
||||||
|
onStart: (a: TrainingAssignment) => void
|
||||||
|
onResume: (a: TrainingAssignment) => void
|
||||||
|
onGenerateCertificate: (id: string) => void
|
||||||
|
onDownloadPDF: (certId: string) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AssignmentsList({
|
||||||
|
assignments, loading, certGenerating, onStart, onResume, onGenerateCertificate, onDownloadPDF,
|
||||||
|
}: AssignmentsListProps) {
|
||||||
|
if (loading) return <div className="text-center py-12 text-gray-400">Lade Schulungen...</div>
|
||||||
|
if (assignments.length === 0) return <div className="text-center py-12 text-gray-400">Keine Schulungen zugewiesen</div>
|
||||||
|
|
||||||
|
return (
|
||||||
|
<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>
|
||||||
|
<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={() => onStart(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={() => onResume(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={() => onGenerateCertificate(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={() => onDownloadPDF(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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import type { TrainingAssignment } from '@/lib/sdk/training/types'
|
||||||
|
|
||||||
|
interface CertificatesViewProps {
|
||||||
|
certificates: TrainingAssignment[]
|
||||||
|
onDownloadPDF: (certId: string) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CertificatesView({ certificates, onDownloadPDF }: CertificatesViewProps) {
|
||||||
|
if (certificates.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className="text-center py-12 text-gray-400">
|
||||||
|
Noch keine Zertifikate vorhanden. Schliessen Sie eine Schulung mit Quiz ab.
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<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={() => onDownloadPDF(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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import type { TrainingAssignment, ModuleContent, TrainingMedia, InteractiveVideoManifest } from '@/lib/sdk/training/types'
|
||||||
|
import { getMediaStreamURL } from '@/lib/sdk/training/api'
|
||||||
|
import InteractiveVideoPlayer from '@/components/training/InteractiveVideoPlayer'
|
||||||
|
|
||||||
|
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/>')
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ContentViewProps {
|
||||||
|
selectedAssignment: TrainingAssignment | null
|
||||||
|
content: ModuleContent | null
|
||||||
|
media: TrainingMedia[]
|
||||||
|
interactiveManifest: InteractiveVideoManifest | null
|
||||||
|
onStartQuiz: () => void
|
||||||
|
onAllCheckpointsPassed: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ContentView({
|
||||||
|
selectedAssignment, content, media, interactiveManifest, onStartQuiz, onAllCheckpointsPassed,
|
||||||
|
}: ContentViewProps) {
|
||||||
|
if (!selectedAssignment) {
|
||||||
|
return <div className="text-center py-12 text-gray-400">Waehlen Sie eine Schulung aus dem Tab "Meine Schulungen"</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className="mb-4 flex items-center justify-between">
|
||||||
|
<h2 className="text-lg font-semibold">{selectedAssignment.module_title}</h2>
|
||||||
|
<button onClick={onStartQuiz} className="px-4 py-2 bg-indigo-600 text-white text-sm rounded-lg hover:bg-indigo-700">Quiz starten</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{interactiveManifest && (
|
||||||
|
<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={onAllCheckpointsPassed}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{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 ? (
|
||||||
|
<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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import type { TrainingAssignment, QuizSubmitResponse } from '@/lib/sdk/training/types'
|
||||||
|
|
||||||
|
interface QuizQuestion {
|
||||||
|
id: string
|
||||||
|
question: string
|
||||||
|
options: string[]
|
||||||
|
difficulty: string
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatTimer(seconds: number): string {
|
||||||
|
const m = Math.floor(seconds / 60)
|
||||||
|
const s = seconds % 60
|
||||||
|
return `${m}:${s.toString().padStart(2, '0')}`
|
||||||
|
}
|
||||||
|
|
||||||
|
interface QuizViewProps {
|
||||||
|
questions: QuizQuestion[]
|
||||||
|
answers: Record<string, number>
|
||||||
|
quizResult: QuizSubmitResponse | null
|
||||||
|
quizSubmitting: boolean
|
||||||
|
quizTimer: number
|
||||||
|
selectedAssignment: TrainingAssignment | null
|
||||||
|
certGenerating: boolean
|
||||||
|
onAnswerChange: (questionId: string, optionIndex: number) => void
|
||||||
|
onSubmitQuiz: () => void
|
||||||
|
onRetryQuiz: () => void
|
||||||
|
onGenerateCertificate: (assignmentId: string) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function QuizView({
|
||||||
|
questions, answers, quizResult, quizSubmitting, quizTimer,
|
||||||
|
selectedAssignment, certGenerating, onAnswerChange, onSubmitQuiz, onRetryQuiz, onGenerateCertificate,
|
||||||
|
}: QuizViewProps) {
|
||||||
|
if (questions.length === 0) {
|
||||||
|
return <div className="text-center py-12 text-gray-400">Starten Sie ein Quiz aus dem Schulungsinhalt-Tab</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
if (quizResult) {
|
||||||
|
return (
|
||||||
|
<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={() => onGenerateCertificate(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={onRetryQuiz} className="mt-4 px-6 py-2 bg-indigo-600 text-white rounded-lg hover:bg-indigo-700">
|
||||||
|
Quiz erneut versuchen
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<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={() => onAnswerChange(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={onSubmitQuiz} 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -4,16 +4,16 @@ import { useEffect, useState, useCallback } from 'react'
|
|||||||
import {
|
import {
|
||||||
getAssignments, getContent, getModuleMedia, getQuiz, submitQuiz,
|
getAssignments, getContent, getModuleMedia, getQuiz, submitQuiz,
|
||||||
startAssignment, generateCertificate, listCertificates, downloadCertificatePDF,
|
startAssignment, generateCertificate, listCertificates, downloadCertificatePDF,
|
||||||
getMediaStreamURL, getInteractiveManifest, completeAssignment,
|
getInteractiveManifest, completeAssignment,
|
||||||
} from '@/lib/sdk/training/api'
|
} from '@/lib/sdk/training/api'
|
||||||
import type {
|
import type {
|
||||||
TrainingAssignment, ModuleContent, TrainingMedia, QuizSubmitResponse,
|
TrainingAssignment, ModuleContent, TrainingMedia, QuizSubmitResponse,
|
||||||
InteractiveVideoManifest,
|
InteractiveVideoManifest,
|
||||||
} from '@/lib/sdk/training/types'
|
} from '@/lib/sdk/training/types'
|
||||||
import {
|
import { AssignmentsList } from './_components/AssignmentsList'
|
||||||
STATUS_LABELS, STATUS_COLORS, REGULATION_LABELS,
|
import { ContentView } from './_components/ContentView'
|
||||||
} from '@/lib/sdk/training/types'
|
import { QuizView } from './_components/QuizView'
|
||||||
import InteractiveVideoPlayer from '@/components/training/InteractiveVideoPlayer'
|
import { CertificatesView } from './_components/CertificatesView'
|
||||||
|
|
||||||
type Tab = 'assignments' | 'content' | 'quiz' | 'certificates'
|
type Tab = 'assignments' | 'content' | 'quiz' | 'certificates'
|
||||||
|
|
||||||
@@ -24,64 +24,44 @@ interface QuizQuestionItem {
|
|||||||
difficulty: string
|
difficulty: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const TABS: { key: Tab; label: string }[] = [
|
||||||
|
{ key: 'assignments', label: 'Meine Schulungen' },
|
||||||
|
{ key: 'content', label: 'Schulungsinhalt' },
|
||||||
|
{ key: 'quiz', label: 'Quiz' },
|
||||||
|
{ key: 'certificates', label: 'Zertifikate' },
|
||||||
|
]
|
||||||
|
|
||||||
export default function LearnerPage() {
|
export default function LearnerPage() {
|
||||||
const [activeTab, setActiveTab] = useState<Tab>('assignments')
|
const [activeTab, setActiveTab] = useState<Tab>('assignments')
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
const [error, setError] = useState<string | null>(null)
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
|
||||||
// Assignments
|
|
||||||
const [assignments, setAssignments] = useState<TrainingAssignment[]>([])
|
const [assignments, setAssignments] = useState<TrainingAssignment[]>([])
|
||||||
|
|
||||||
// Content
|
|
||||||
const [selectedAssignment, setSelectedAssignment] = useState<TrainingAssignment | null>(null)
|
const [selectedAssignment, setSelectedAssignment] = useState<TrainingAssignment | null>(null)
|
||||||
const [content, setContent] = useState<ModuleContent | null>(null)
|
const [content, setContent] = useState<ModuleContent | null>(null)
|
||||||
const [media, setMedia] = useState<TrainingMedia[]>([])
|
const [media, setMedia] = useState<TrainingMedia[]>([])
|
||||||
|
|
||||||
// Quiz
|
|
||||||
const [questions, setQuestions] = useState<QuizQuestionItem[]>([])
|
const [questions, setQuestions] = useState<QuizQuestionItem[]>([])
|
||||||
const [answers, setAnswers] = useState<Record<string, number>>({})
|
const [answers, setAnswers] = useState<Record<string, number>>({})
|
||||||
const [quizResult, setQuizResult] = useState<QuizSubmitResponse | null>(null)
|
const [quizResult, setQuizResult] = useState<QuizSubmitResponse | null>(null)
|
||||||
const [quizSubmitting, setQuizSubmitting] = useState(false)
|
const [quizSubmitting, setQuizSubmitting] = useState(false)
|
||||||
const [quizTimer, setQuizTimer] = useState(0)
|
const [quizTimer, setQuizTimer] = useState(0)
|
||||||
const [quizActive, setQuizActive] = useState(false)
|
const [quizActive, setQuizActive] = useState(false)
|
||||||
|
|
||||||
// Certificates
|
|
||||||
const [certificates, setCertificates] = useState<TrainingAssignment[]>([])
|
const [certificates, setCertificates] = useState<TrainingAssignment[]>([])
|
||||||
const [certGenerating, setCertGenerating] = useState(false)
|
const [certGenerating, setCertGenerating] = useState(false)
|
||||||
|
|
||||||
// Interactive Video
|
|
||||||
const [interactiveManifest, setInteractiveManifest] = useState<InteractiveVideoManifest | null>(null)
|
const [interactiveManifest, setInteractiveManifest] = useState<InteractiveVideoManifest | null>(null)
|
||||||
|
|
||||||
// User simulation
|
|
||||||
const [userId] = useState('00000000-0000-0000-0000-000000000001')
|
const [userId] = useState('00000000-0000-0000-0000-000000000001')
|
||||||
|
|
||||||
const loadAssignments = useCallback(async () => {
|
const loadAssignments = useCallback(async () => {
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
try {
|
try { const d = await getAssignments({ user_id: userId, limit: 100 }); setAssignments(d.assignments || []) }
|
||||||
const data = await getAssignments({ user_id: userId, limit: 100 })
|
catch (e) { setError(e instanceof Error ? e.message : 'Fehler beim Laden') }
|
||||||
setAssignments(data.assignments || [])
|
finally { setLoading(false) }
|
||||||
} catch (e) {
|
|
||||||
setError(e instanceof Error ? e.message : 'Fehler beim Laden')
|
|
||||||
} finally {
|
|
||||||
setLoading(false)
|
|
||||||
}
|
|
||||||
}, [userId])
|
}, [userId])
|
||||||
|
|
||||||
const loadCertificates = useCallback(async () => {
|
const loadCertificates = useCallback(async () => {
|
||||||
try {
|
try { const d = await listCertificates(); setCertificates(d.certificates || []) } catch { /* may not exist */ }
|
||||||
const data = await listCertificates()
|
|
||||||
setCertificates(data.certificates || [])
|
|
||||||
} catch {
|
|
||||||
// Certificates may not exist yet
|
|
||||||
}
|
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => { loadAssignments(); loadCertificates() }, [loadAssignments, loadCertificates])
|
||||||
loadAssignments()
|
|
||||||
loadCertificates()
|
|
||||||
}, [loadAssignments, loadCertificates])
|
|
||||||
|
|
||||||
// Quiz timer
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!quizActive) return
|
if (!quizActive) return
|
||||||
const interval = setInterval(() => setQuizTimer(t => t + 1), 1000)
|
const interval = setInterval(() => setQuizTimer(t => t + 1), 1000)
|
||||||
@@ -91,33 +71,22 @@ export default function LearnerPage() {
|
|||||||
async function loadInteractiveManifest(moduleId: string, assignmentId: string) {
|
async function loadInteractiveManifest(moduleId: string, assignmentId: string) {
|
||||||
try {
|
try {
|
||||||
const manifest = await getInteractiveManifest(moduleId, assignmentId)
|
const manifest = await getInteractiveManifest(moduleId, assignmentId)
|
||||||
if (manifest && manifest.checkpoints && manifest.checkpoints.length > 0) {
|
setInteractiveManifest(manifest?.checkpoints?.length > 0 ? manifest : null)
|
||||||
setInteractiveManifest(manifest)
|
} catch { setInteractiveManifest(null) }
|
||||||
} else {
|
|
||||||
setInteractiveManifest(null)
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
setInteractiveManifest(null)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleStartAssignment(assignment: TrainingAssignment) {
|
async function handleStartAssignment(assignment: TrainingAssignment) {
|
||||||
try {
|
try {
|
||||||
await startAssignment(assignment.id)
|
await startAssignment(assignment.id)
|
||||||
setSelectedAssignment({ ...assignment, status: 'in_progress' })
|
setSelectedAssignment({ ...assignment, status: 'in_progress' })
|
||||||
// Load content
|
|
||||||
const [contentData, mediaData] = await Promise.all([
|
const [contentData, mediaData] = await Promise.all([
|
||||||
getContent(assignment.module_id).catch(() => null),
|
getContent(assignment.module_id).catch(() => null),
|
||||||
getModuleMedia(assignment.module_id).catch(() => ({ media: [] })),
|
getModuleMedia(assignment.module_id).catch(() => ({ media: [] })),
|
||||||
])
|
])
|
||||||
setContent(contentData)
|
setContent(contentData); setMedia(mediaData.media || [])
|
||||||
setMedia(mediaData.media || [])
|
|
||||||
await loadInteractiveManifest(assignment.module_id, assignment.id)
|
await loadInteractiveManifest(assignment.module_id, assignment.id)
|
||||||
setActiveTab('content')
|
setActiveTab('content'); loadAssignments()
|
||||||
loadAssignments()
|
} catch (e) { setError(e instanceof Error ? e.message : 'Fehler beim Starten') }
|
||||||
} catch (e) {
|
|
||||||
setError(e instanceof Error ? e.message : 'Fehler beim Starten')
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleResumeContent(assignment: TrainingAssignment) {
|
async function handleResumeContent(assignment: TrainingAssignment) {
|
||||||
@@ -127,13 +96,10 @@ export default function LearnerPage() {
|
|||||||
getContent(assignment.module_id).catch(() => null),
|
getContent(assignment.module_id).catch(() => null),
|
||||||
getModuleMedia(assignment.module_id).catch(() => ({ media: [] })),
|
getModuleMedia(assignment.module_id).catch(() => ({ media: [] })),
|
||||||
])
|
])
|
||||||
setContent(contentData)
|
setContent(contentData); setMedia(mediaData.media || [])
|
||||||
setMedia(mediaData.media || [])
|
|
||||||
await loadInteractiveManifest(assignment.module_id, assignment.id)
|
await loadInteractiveManifest(assignment.module_id, assignment.id)
|
||||||
setActiveTab('content')
|
setActiveTab('content')
|
||||||
} catch (e) {
|
} catch (e) { setError(e instanceof Error ? e.message : 'Fehler beim Laden') }
|
||||||
setError(e instanceof Error ? e.message : 'Fehler beim Laden')
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleAllCheckpointsPassed() {
|
async function handleAllCheckpointsPassed() {
|
||||||
@@ -142,47 +108,30 @@ export default function LearnerPage() {
|
|||||||
await completeAssignment(selectedAssignment.id)
|
await completeAssignment(selectedAssignment.id)
|
||||||
setSelectedAssignment({ ...selectedAssignment, status: 'completed' })
|
setSelectedAssignment({ ...selectedAssignment, status: 'completed' })
|
||||||
loadAssignments()
|
loadAssignments()
|
||||||
} catch {
|
} catch { /* already completed */ }
|
||||||
// Assignment completion may already be handled
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleStartQuiz() {
|
async function handleStartQuiz() {
|
||||||
if (!selectedAssignment) return
|
if (!selectedAssignment) return
|
||||||
try {
|
try {
|
||||||
const data = await getQuiz(selectedAssignment.module_id)
|
const data = await getQuiz(selectedAssignment.module_id)
|
||||||
setQuestions(data.questions || [])
|
setQuestions(data.questions || []); setAnswers({}); setQuizResult(null)
|
||||||
setAnswers({})
|
setQuizTimer(0); setQuizActive(true); setActiveTab('quiz')
|
||||||
setQuizResult(null)
|
} catch (e) { setError(e instanceof Error ? e.message : 'Fehler beim Quiz-Laden') }
|
||||||
setQuizTimer(0)
|
|
||||||
setQuizActive(true)
|
|
||||||
setActiveTab('quiz')
|
|
||||||
} catch (e) {
|
|
||||||
setError(e instanceof Error ? e.message : 'Fehler beim Quiz-Laden')
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleSubmitQuiz() {
|
async function handleSubmitQuiz() {
|
||||||
if (!selectedAssignment || questions.length === 0) return
|
if (!selectedAssignment || questions.length === 0) return
|
||||||
setQuizSubmitting(true)
|
setQuizSubmitting(true); setQuizActive(false)
|
||||||
setQuizActive(false)
|
|
||||||
try {
|
try {
|
||||||
const answerList = questions.map(q => ({
|
|
||||||
question_id: q.id,
|
|
||||||
selected_index: answers[q.id] ?? -1,
|
|
||||||
}))
|
|
||||||
const result = await submitQuiz(selectedAssignment.module_id, {
|
const result = await submitQuiz(selectedAssignment.module_id, {
|
||||||
assignment_id: selectedAssignment.id,
|
assignment_id: selectedAssignment.id,
|
||||||
answers: answerList,
|
answers: questions.map(q => ({ question_id: q.id, selected_index: answers[q.id] ?? -1 })),
|
||||||
duration_seconds: quizTimer,
|
duration_seconds: quizTimer,
|
||||||
})
|
})
|
||||||
setQuizResult(result)
|
setQuizResult(result); loadAssignments()
|
||||||
loadAssignments()
|
} catch (e) { setError(e instanceof Error ? e.message : 'Quiz-Abgabe fehlgeschlagen') }
|
||||||
} catch (e) {
|
finally { setQuizSubmitting(false) }
|
||||||
setError(e instanceof Error ? e.message : 'Quiz-Abgabe fehlgeschlagen')
|
|
||||||
} finally {
|
|
||||||
setQuizSubmitting(false)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleGenerateCertificate(assignmentId: string) {
|
async function handleGenerateCertificate(assignmentId: string) {
|
||||||
@@ -192,59 +141,24 @@ export default function LearnerPage() {
|
|||||||
if (data.certificate_id) {
|
if (data.certificate_id) {
|
||||||
const blob = await downloadCertificatePDF(data.certificate_id)
|
const blob = await downloadCertificatePDF(data.certificate_id)
|
||||||
const url = URL.createObjectURL(blob)
|
const url = URL.createObjectURL(blob)
|
||||||
const a = document.createElement('a')
|
const a = document.createElement('a'); a.href = url
|
||||||
a.href = url
|
|
||||||
a.download = `zertifikat-${data.certificate_id.substring(0, 8)}.pdf`
|
a.download = `zertifikat-${data.certificate_id.substring(0, 8)}.pdf`
|
||||||
a.click()
|
a.click(); URL.revokeObjectURL(url)
|
||||||
URL.revokeObjectURL(url)
|
|
||||||
}
|
|
||||||
loadAssignments()
|
|
||||||
loadCertificates()
|
|
||||||
} catch (e) {
|
|
||||||
setError(e instanceof Error ? e.message : 'Zertifikat-Erstellung fehlgeschlagen')
|
|
||||||
} finally {
|
|
||||||
setCertGenerating(false)
|
|
||||||
}
|
}
|
||||||
|
loadAssignments(); loadCertificates()
|
||||||
|
} catch (e) { setError(e instanceof Error ? e.message : 'Zertifikat-Erstellung fehlgeschlagen') }
|
||||||
|
finally { setCertGenerating(false) }
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleDownloadPDF(certId: string) {
|
async function handleDownloadPDF(certId: string) {
|
||||||
try {
|
try {
|
||||||
const blob = await downloadCertificatePDF(certId)
|
const blob = await downloadCertificatePDF(certId)
|
||||||
const url = URL.createObjectURL(blob)
|
const url = URL.createObjectURL(blob)
|
||||||
const a = document.createElement('a')
|
const a = document.createElement('a'); a.href = url
|
||||||
a.href = url
|
|
||||||
a.download = `zertifikat-${certId.substring(0, 8)}.pdf`
|
a.download = `zertifikat-${certId.substring(0, 8)}.pdf`
|
||||||
a.click()
|
a.click(); URL.revokeObjectURL(url)
|
||||||
URL.revokeObjectURL(url)
|
} catch (e) { setError(e instanceof Error ? e.message : 'PDF-Download fehlgeschlagen') }
|
||||||
} 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 (
|
return (
|
||||||
<div className="max-w-7xl mx-auto p-6">
|
<div className="max-w-7xl mx-auto p-6">
|
||||||
@@ -255,305 +169,47 @@ export default function LearnerPage() {
|
|||||||
|
|
||||||
{error && (
|
{error && (
|
||||||
<div className="mb-4 p-3 bg-red-50 border border-red-200 rounded-lg text-red-700 text-sm">
|
<div className="mb-4 p-3 bg-red-50 border border-red-200 rounded-lg text-red-700 text-sm">
|
||||||
{error}
|
{error}<button onClick={() => setError(null)} className="ml-2 text-red-500 hover:text-red-700">x</button>
|
||||||
<button onClick={() => setError(null)} className="ml-2 text-red-500 hover:text-red-700">x</button>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Tabs */}
|
|
||||||
<div className="border-b border-gray-200 mb-6">
|
<div className="border-b border-gray-200 mb-6">
|
||||||
<div className="flex gap-6">
|
<div className="flex gap-6">
|
||||||
{tabs.map(tab => (
|
{TABS.map(tab => (
|
||||||
<button
|
<button key={tab.key} onClick={() => setActiveTab(tab.key)}
|
||||||
key={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'}`}>
|
||||||
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}
|
{tab.label}
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Tab: Meine Schulungen */}
|
|
||||||
{activeTab === 'assignments' && (
|
{activeTab === 'assignments' && (
|
||||||
<div>
|
<AssignmentsList
|
||||||
{loading ? (
|
assignments={assignments} loading={loading} certGenerating={certGenerating}
|
||||||
<div className="text-center py-12 text-gray-400">Lade Schulungen...</div>
|
onStart={handleStartAssignment} onResume={handleResumeContent}
|
||||||
) : assignments.length === 0 ? (
|
onGenerateCertificate={handleGenerateCertificate} onDownloadPDF={handleDownloadPDF}
|
||||||
<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' && (
|
{activeTab === 'content' && (
|
||||||
<div>
|
<ContentView
|
||||||
{!selectedAssignment ? (
|
selectedAssignment={selectedAssignment} content={content} media={media}
|
||||||
<div className="text-center py-12 text-gray-400">
|
interactiveManifest={interactiveManifest} onStartQuiz={handleStartQuiz}
|
||||||
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}
|
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' && (
|
{activeTab === 'quiz' && (
|
||||||
<div>
|
<QuizView
|
||||||
{questions.length === 0 ? (
|
questions={questions} answers={answers} quizResult={quizResult}
|
||||||
<div className="text-center py-12 text-gray-400">
|
quizSubmitting={quizSubmitting} quizTimer={quizTimer} selectedAssignment={selectedAssignment}
|
||||||
Starten Sie ein Quiz aus dem Schulungsinhalt-Tab
|
certGenerating={certGenerating}
|
||||||
</div>
|
onAnswerChange={(qId, oi) => setAnswers(prev => ({ ...prev, [qId]: oi }))}
|
||||||
) : quizResult ? (
|
onSubmitQuiz={handleSubmitQuiz} onRetryQuiz={handleStartQuiz}
|
||||||
/* Quiz Results */
|
onGenerateCertificate={handleGenerateCertificate}
|
||||||
<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' && (
|
{activeTab === 'certificates' && (
|
||||||
<div>
|
<CertificatesView certificates={certificates} onDownloadPDF={handleDownloadPDF} />
|
||||||
{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>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -18,15 +18,30 @@ import type {
|
|||||||
MatrixResponse, TrainingStats, DeadlineInfo, AuditLogEntry, ModuleContent, TrainingMedia,
|
MatrixResponse, TrainingStats, DeadlineInfo, AuditLogEntry, ModuleContent, TrainingMedia,
|
||||||
TrainingBlockConfig, CanonicalControlMeta, BlockPreview, BlockGenerateResult,
|
TrainingBlockConfig, CanonicalControlMeta, BlockPreview, BlockGenerateResult,
|
||||||
} from '@/lib/sdk/training/types'
|
} from '@/lib/sdk/training/types'
|
||||||
import {
|
import { REGULATION_LABELS, REGULATION_COLORS, FREQUENCY_LABELS, STATUS_LABELS, STATUS_COLORS, ROLE_LABELS, ALL_ROLES, TARGET_AUDIENCE_LABELS } from '@/lib/sdk/training/types'
|
||||||
REGULATION_LABELS, REGULATION_COLORS, FREQUENCY_LABELS,
|
import { OverviewTab } from './_components/OverviewTab'
|
||||||
STATUS_LABELS, STATUS_COLORS, ROLE_LABELS, ALL_ROLES, TARGET_AUDIENCE_LABELS,
|
import { ModulesTab } from './_components/ModulesTab'
|
||||||
} from '@/lib/sdk/training/types'
|
import { MatrixTab } from './_components/MatrixTab'
|
||||||
import AudioPlayer from '@/components/training/AudioPlayer'
|
import { AssignmentsTab } from './_components/AssignmentsTab'
|
||||||
import VideoPlayer from '@/components/training/VideoPlayer'
|
import { ContentTab } from './_components/ContentTab'
|
||||||
import ScriptPreview from '@/components/training/ScriptPreview'
|
import { AuditTab } from './_components/AuditTab'
|
||||||
|
import { BlocksSection } from './_components/BlocksSection'
|
||||||
|
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'
|
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() {
|
export default function TrainingPage() {
|
||||||
const [activeTab, setActiveTab] = useState<Tab>('overview')
|
const [activeTab, setActiveTab] = useState<Tab>('overview')
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
@@ -50,14 +65,12 @@ export default function TrainingPage() {
|
|||||||
const [statusFilter, setStatusFilter] = useState<string>('')
|
const [statusFilter, setStatusFilter] = useState<string>('')
|
||||||
const [regulationFilter, setRegulationFilter] = useState<string>('')
|
const [regulationFilter, setRegulationFilter] = useState<string>('')
|
||||||
|
|
||||||
// Modal/Drawer states
|
|
||||||
const [showModuleCreate, setShowModuleCreate] = useState(false)
|
const [showModuleCreate, setShowModuleCreate] = useState(false)
|
||||||
const [selectedModule, setSelectedModule] = useState<TrainingModule | null>(null)
|
const [selectedModule, setSelectedModule] = useState<TrainingModule | null>(null)
|
||||||
const [matrixAddRole, setMatrixAddRole] = useState<string | null>(null)
|
const [matrixAddRole, setMatrixAddRole] = useState<string | null>(null)
|
||||||
const [selectedAssignment, setSelectedAssignment] = useState<TrainingAssignment | null>(null)
|
const [selectedAssignment, setSelectedAssignment] = useState<TrainingAssignment | null>(null)
|
||||||
const [escalationResult, setEscalationResult] = useState<{ total_checked: number; escalated: number } | null>(null)
|
const [escalationResult, setEscalationResult] = useState<{ total_checked: number; escalated: number } | null>(null)
|
||||||
|
|
||||||
// Block (Controls → Module) state
|
|
||||||
const [blocks, setBlocks] = useState<TrainingBlockConfig[]>([])
|
const [blocks, setBlocks] = useState<TrainingBlockConfig[]>([])
|
||||||
const [canonicalMeta, setCanonicalMeta] = useState<CanonicalControlMeta | null>(null)
|
const [canonicalMeta, setCanonicalMeta] = useState<CanonicalControlMeta | null>(null)
|
||||||
const [showBlockCreate, setShowBlockCreate] = useState(false)
|
const [showBlockCreate, setShowBlockCreate] = useState(false)
|
||||||
@@ -66,31 +79,16 @@ export default function TrainingPage() {
|
|||||||
const [blockGenerating, setBlockGenerating] = useState(false)
|
const [blockGenerating, setBlockGenerating] = useState(false)
|
||||||
const [blockResult, setBlockResult] = useState<BlockGenerateResult | null>(null)
|
const [blockResult, setBlockResult] = useState<BlockGenerateResult | null>(null)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => { loadData() }, [])
|
||||||
loadData()
|
useEffect(() => { if (selectedModuleId) loadModuleMedia(selectedModuleId) }, [selectedModuleId])
|
||||||
}, [])
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (selectedModuleId) {
|
|
||||||
loadModuleMedia(selectedModuleId)
|
|
||||||
}
|
|
||||||
}, [selectedModuleId])
|
|
||||||
|
|
||||||
async function loadData() {
|
async function loadData() {
|
||||||
setLoading(true)
|
setLoading(true); setError(null)
|
||||||
setError(null)
|
|
||||||
try {
|
try {
|
||||||
const [statsRes, modulesRes, matrixRes, assignmentsRes, deadlinesRes, auditRes, blocksRes, metaRes] = await Promise.allSettled([
|
const [statsRes, modulesRes, matrixRes, assignmentsRes, deadlinesRes, auditRes, blocksRes, metaRes] = await Promise.allSettled([
|
||||||
getStats(),
|
getStats(), getModules(), getMatrix(), getAssignments({ limit: 50 }),
|
||||||
getModules(),
|
getDeadlines(10), getAuditLog({ limit: 30 }), listBlockConfigs(), getCanonicalMeta(),
|
||||||
getMatrix(),
|
|
||||||
getAssignments({ limit: 50 }),
|
|
||||||
getDeadlines(10),
|
|
||||||
getAuditLog({ limit: 30 }),
|
|
||||||
listBlockConfigs(),
|
|
||||||
getCanonicalMeta(),
|
|
||||||
])
|
])
|
||||||
|
|
||||||
if (statsRes.status === 'fulfilled') setStats(statsRes.value)
|
if (statsRes.status === 'fulfilled') setStats(statsRes.value)
|
||||||
if (modulesRes.status === 'fulfilled') setModules(modulesRes.value.modules)
|
if (modulesRes.status === 'fulfilled') setModules(modulesRes.value.modules)
|
||||||
if (matrixRes.status === 'fulfilled') setMatrix(matrixRes.value)
|
if (matrixRes.status === 'fulfilled') setMatrix(matrixRes.value)
|
||||||
@@ -101,680 +99,180 @@ export default function TrainingPage() {
|
|||||||
if (metaRes.status === 'fulfilled') setCanonicalMeta(metaRes.value)
|
if (metaRes.status === 'fulfilled') setCanonicalMeta(metaRes.value)
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setError(e instanceof Error ? e.message : 'Fehler beim Laden')
|
setError(e instanceof Error ? e.message : 'Fehler beim Laden')
|
||||||
} finally {
|
} finally { setLoading(false) }
|
||||||
setLoading(false)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function loadModuleMedia(moduleId: string) {
|
||||||
|
try { const r = await getModuleMedia(moduleId); setModuleMedia(r.media) } catch { setModuleMedia([]) }
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleGenerateContent() {
|
async function handleGenerateContent() {
|
||||||
if (!selectedModuleId) return
|
if (!selectedModuleId) return; setGenerating(true)
|
||||||
setGenerating(true)
|
try { setGeneratedContent(await generateContent(selectedModuleId)) }
|
||||||
try {
|
catch (e) { setError(e instanceof Error ? e.message : 'Fehler') }
|
||||||
const content = await generateContent(selectedModuleId)
|
finally { setGenerating(false) }
|
||||||
setGeneratedContent(content)
|
|
||||||
} catch (e) {
|
|
||||||
setError(e instanceof Error ? e.message : 'Fehler bei der Content-Generierung')
|
|
||||||
} finally {
|
|
||||||
setGenerating(false)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleGenerateQuiz() {
|
async function handleGenerateQuiz() {
|
||||||
if (!selectedModuleId) return
|
if (!selectedModuleId) return; setGenerating(true)
|
||||||
setGenerating(true)
|
try { await generateQuiz(selectedModuleId, 5); await loadData() }
|
||||||
try {
|
catch (e) { setError(e instanceof Error ? e.message : 'Fehler') }
|
||||||
await generateQuiz(selectedModuleId, 5)
|
finally { setGenerating(false) }
|
||||||
await loadData()
|
|
||||||
} catch (e) {
|
|
||||||
setError(e instanceof Error ? e.message : 'Fehler bei der Quiz-Generierung')
|
|
||||||
} finally {
|
|
||||||
setGenerating(false)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleGenerateInteractiveVideo() {
|
async function handleGenerateInteractiveVideo() {
|
||||||
if (!selectedModuleId) return
|
if (!selectedModuleId) return; setInteractiveGenerating(true)
|
||||||
setInteractiveGenerating(true)
|
try { await generateInteractiveVideo(selectedModuleId); await loadModuleMedia(selectedModuleId) }
|
||||||
try {
|
catch (e) { setError(e instanceof Error ? e.message : 'Fehler') }
|
||||||
await generateInteractiveVideo(selectedModuleId)
|
finally { setInteractiveGenerating(false) }
|
||||||
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) {
|
async function handlePublishContent(contentId: string) {
|
||||||
try {
|
try { await publishContent(contentId); setGeneratedContent(null); await loadData() }
|
||||||
await publishContent(contentId)
|
catch (e) { setError(e instanceof Error ? e.message : 'Fehler') }
|
||||||
setGeneratedContent(null)
|
|
||||||
await loadData()
|
|
||||||
} catch (e) {
|
|
||||||
setError(e instanceof Error ? e.message : 'Fehler beim Veroeffentlichen')
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleCheckEscalation() {
|
async function handleCheckEscalation() {
|
||||||
try {
|
try {
|
||||||
const result = await checkEscalation()
|
const r = await checkEscalation()
|
||||||
setEscalationResult({ total_checked: result.total_checked, escalated: result.escalated })
|
setEscalationResult({ total_checked: r.total_checked, escalated: r.escalated })
|
||||||
await loadData()
|
await loadData()
|
||||||
} catch (e) {
|
} catch (e) { setError(e instanceof Error ? e.message : 'Fehler') }
|
||||||
setError(e instanceof Error ? e.message : 'Fehler bei der Eskalationspruefung')
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleDeleteMatrixEntry(roleCode: string, moduleId: string) {
|
async function handleDeleteMatrixEntry(roleCode: string, moduleId: string) {
|
||||||
if (!window.confirm('Modulzuordnung entfernen?')) return
|
if (!window.confirm('Modulzuordnung entfernen?')) return
|
||||||
try {
|
try { await deleteMatrixEntry(roleCode, moduleId); await loadData() }
|
||||||
await deleteMatrixEntry(roleCode, moduleId)
|
catch (e) { setError(e instanceof Error ? e.message : 'Fehler') }
|
||||||
await loadData()
|
|
||||||
} catch (e) {
|
|
||||||
setError(e instanceof Error ? e.message : 'Fehler beim Entfernen')
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleLoadContent(moduleId: string) {
|
async function handleLoadContent(moduleId: string) {
|
||||||
try {
|
try { setGeneratedContent(await getContent(moduleId)) } catch { setGeneratedContent(null) }
|
||||||
const content = await getContent(moduleId)
|
|
||||||
setGeneratedContent(content)
|
|
||||||
} catch {
|
|
||||||
setGeneratedContent(null)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleBulkContent() {
|
async function handleBulkContent() {
|
||||||
setBulkGenerating(true)
|
setBulkGenerating(true); setBulkResult(null)
|
||||||
setBulkResult(null)
|
|
||||||
try {
|
try {
|
||||||
const result = await generateAllContent('de')
|
const r = await generateAllContent('de')
|
||||||
setBulkResult({ generated: result.generated ?? 0, skipped: result.skipped ?? 0, errors: result.errors ?? [] })
|
setBulkResult({ generated: r.generated ?? 0, skipped: r.skipped ?? 0, errors: r.errors ?? [] })
|
||||||
await loadData()
|
await loadData()
|
||||||
} catch (e) {
|
} catch (e) { setError(e instanceof Error ? e.message : 'Fehler') }
|
||||||
setError(e instanceof Error ? e.message : 'Fehler bei der Bulk-Generierung')
|
finally { setBulkGenerating(false) }
|
||||||
} finally {
|
|
||||||
setBulkGenerating(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function loadModuleMedia(moduleId: string) {
|
|
||||||
try {
|
|
||||||
const result = await getModuleMedia(moduleId)
|
|
||||||
setModuleMedia(result.media)
|
|
||||||
} catch {
|
|
||||||
setModuleMedia([])
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleBulkQuiz() {
|
async function handleBulkQuiz() {
|
||||||
setBulkGenerating(true)
|
setBulkGenerating(true); setBulkResult(null)
|
||||||
setBulkResult(null)
|
|
||||||
try {
|
try {
|
||||||
const result = await generateAllQuizzes()
|
const r = await generateAllQuizzes()
|
||||||
setBulkResult({ generated: result.generated ?? 0, skipped: result.skipped ?? 0, errors: result.errors ?? [] })
|
setBulkResult({ generated: r.generated ?? 0, skipped: r.skipped ?? 0, errors: r.errors ?? [] })
|
||||||
await loadData()
|
await loadData()
|
||||||
} catch (e) {
|
} catch (e) { setError(e instanceof Error ? e.message : 'Fehler') }
|
||||||
setError(e instanceof Error ? e.message : 'Fehler bei der Bulk-Quiz-Generierung')
|
finally { setBulkGenerating(false) }
|
||||||
} finally {
|
|
||||||
setBulkGenerating(false)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Block handlers
|
async function handleCreateBlock(data: Parameters<typeof createBlockConfig>[0]) {
|
||||||
async function handleCreateBlock(data: {
|
try { await createBlockConfig(data); setShowBlockCreate(false); const r = await listBlockConfigs(); setBlocks(r.blocks) }
|
||||||
name: string; description?: string; domain_filter?: string; category_filter?: string;
|
catch (e) { setError(e instanceof Error ? e.message : 'Fehler') }
|
||||||
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) {
|
async function handleDeleteBlock(id: string) {
|
||||||
if (!confirm('Block-Konfiguration wirklich loeschen?')) return
|
if (!confirm('Block-Konfiguration wirklich loeschen?')) return
|
||||||
try {
|
try { await deleteBlockConfig(id); const r = await listBlockConfigs(); setBlocks(r.blocks) }
|
||||||
await deleteBlockConfig(id)
|
catch (e) { setError(e instanceof Error ? e.message : 'Fehler') }
|
||||||
const res = await listBlockConfigs()
|
|
||||||
setBlocks(res.blocks)
|
|
||||||
} catch (e) {
|
|
||||||
setError(e instanceof Error ? e.message : 'Fehler beim Loeschen')
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handlePreviewBlock(id: string) {
|
async function handlePreviewBlock(id: string) {
|
||||||
setBlockPreviewId(id)
|
setBlockPreviewId(id); setBlockPreview(null); setBlockResult(null)
|
||||||
setBlockPreview(null)
|
try { setBlockPreview(await previewBlock(id)) }
|
||||||
setBlockResult(null)
|
catch (e) { setError(e instanceof Error ? e.message : 'Fehler') }
|
||||||
try {
|
|
||||||
const preview = await previewBlock(id)
|
|
||||||
setBlockPreview(preview)
|
|
||||||
} catch (e) {
|
|
||||||
setError(e instanceof Error ? e.message : 'Fehler beim Preview')
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleGenerateBlock(id: string) {
|
async function handleGenerateBlock(id: string) {
|
||||||
setBlockGenerating(true)
|
setBlockGenerating(true); setBlockResult(null)
|
||||||
setBlockResult(null)
|
try { setBlockResult(await generateBlock(id, { language: 'de', auto_matrix: true })); await loadData() }
|
||||||
try {
|
catch (e) { setError(e instanceof Error ? e.message : 'Fehler') }
|
||||||
const result = await generateBlock(id, { language: 'de', auto_matrix: true })
|
finally { setBlockGenerating(false) }
|
||||||
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 }[] = [
|
const filteredModules = modules.filter(m => !regulationFilter || m.regulation_area === regulationFilter)
|
||||||
{ id: 'overview', label: 'Uebersicht' },
|
const filteredAssignments = assignments.filter(a => !statusFilter || a.status === statusFilter)
|
||||||
{ 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 =>
|
if (loading) return (
|
||||||
(!regulationFilter || m.regulation_area === regulationFilter)
|
<div className="p-6"><div className="animate-pulse space-y-4">
|
||||||
)
|
|
||||||
|
|
||||||
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="h-8 bg-gray-200 rounded w-1/3"></div>
|
||||||
<div className="grid grid-cols-4 gap-4">
|
<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>
|
||||||
{[1,2,3,4].map(i => <div key={i} className="h-24 bg-gray-200 rounded"></div>)}
|
</div></div>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
)
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="p-6 space-y-6">
|
<div className="p-6 space-y-6">
|
||||||
{/* Header */}
|
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-2xl font-bold text-gray-900">Compliance Training Engine</h1>
|
<h1 className="text-2xl font-bold text-gray-900">Compliance Training Engine</h1>
|
||||||
<p className="text-sm text-gray-500 mt-1">
|
<p className="text-sm text-gray-500 mt-1">Training-Module, Zuweisungen und Compliance-Schulungen verwalten</p>
|
||||||
Training-Module, Zuweisungen und Compliance-Schulungen verwalten
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
<button
|
<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">
|
||||||
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
|
Eskalation pruefen
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{error && (
|
{error && (
|
||||||
<div className="bg-red-50 border border-red-200 text-red-700 px-4 py-3 rounded-lg text-sm">
|
<div className="bg-red-50 border border-red-200 text-red-700 px-4 py-3 rounded-lg text-sm">
|
||||||
{error}
|
{error}<button onClick={() => setError(null)} className="ml-2 underline">Schliessen</button>
|
||||||
<button onClick={() => setError(null)} className="ml-2 underline">Schliessen</button>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Tabs */}
|
|
||||||
<div className="border-b border-gray-200">
|
<div className="border-b border-gray-200">
|
||||||
<nav className="flex -mb-px space-x-6">
|
<nav className="flex -mb-px space-x-6">
|
||||||
{tabs.map(tab => (
|
{TABS.map(tab => (
|
||||||
<button
|
<button key={tab.id} onClick={() => setActiveTab(tab.id)}
|
||||||
key={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'}`}>
|
||||||
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}
|
{tab.label}
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</nav>
|
</nav>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Tab Content */}
|
|
||||||
{activeTab === 'overview' && stats && (
|
{activeTab === 'overview' && stats && (
|
||||||
<OverviewTab
|
<OverviewTab stats={stats} deadlines={deadlines} escalationResult={escalationResult} onDismissEscalation={() => setEscalationResult(null)} />
|
||||||
stats={stats}
|
|
||||||
deadlines={deadlines}
|
|
||||||
escalationResult={escalationResult}
|
|
||||||
onDismissEscalation={() => setEscalationResult(null)}
|
|
||||||
/>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{activeTab === 'modules' && (
|
{activeTab === 'modules' && (
|
||||||
<ModulesTab
|
<ModulesTab modules={filteredModules} regulationFilter={regulationFilter} onRegulationFilterChange={setRegulationFilter} onCreateClick={() => setShowModuleCreate(true)} onModuleClick={setSelectedModule} />
|
||||||
modules={filteredModules}
|
|
||||||
regulationFilter={regulationFilter}
|
|
||||||
onRegulationFilterChange={setRegulationFilter}
|
|
||||||
onCreateClick={() => setShowModuleCreate(true)}
|
|
||||||
onModuleClick={setSelectedModule}
|
|
||||||
/>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{activeTab === 'matrix' && matrix && (
|
{activeTab === 'matrix' && matrix && (
|
||||||
<MatrixTab
|
<MatrixTab matrix={matrix} onDeleteEntry={handleDeleteMatrixEntry} onAddEntry={setMatrixAddRole} />
|
||||||
matrix={matrix}
|
|
||||||
onDeleteEntry={handleDeleteMatrixEntry}
|
|
||||||
onAddEntry={setMatrixAddRole}
|
|
||||||
/>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{activeTab === 'assignments' && (
|
{activeTab === 'assignments' && (
|
||||||
<AssignmentsTab
|
<AssignmentsTab assignments={filteredAssignments} statusFilter={statusFilter} onStatusFilterChange={setStatusFilter} onAssignmentClick={setSelectedAssignment} />
|
||||||
assignments={filteredAssignments}
|
|
||||||
statusFilter={statusFilter}
|
|
||||||
onStatusFilterChange={setStatusFilter}
|
|
||||||
onAssignmentClick={setSelectedAssignment}
|
|
||||||
/>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{activeTab === 'content' && (
|
{activeTab === 'content' && (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
|
<BlocksSection
|
||||||
{/* Training Blocks — Controls → Schulungsmodule */}
|
blocks={blocks} canonicalMeta={canonicalMeta} blockPreview={blockPreview}
|
||||||
<div className="bg-white border rounded-lg p-4">
|
blockPreviewId={blockPreviewId} blockGenerating={blockGenerating} blockResult={blockResult}
|
||||||
<div className="flex items-center justify-between mb-3">
|
showBlockCreate={showBlockCreate} onShowBlockCreate={setShowBlockCreate}
|
||||||
<div>
|
onPreviewBlock={handlePreviewBlock} onGenerateBlock={handleGenerateBlock}
|
||||||
<h3 className="text-sm font-medium text-gray-700">Schulungsbloecke aus Controls</h3>
|
onDeleteBlock={handleDeleteBlock} onCreateBlock={handleCreateBlock}
|
||||||
<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>
|
|
||||||
<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)}
|
|
||||||
/>
|
/>
|
||||||
)}
|
<ContentTab
|
||||||
|
modules={modules} selectedModuleId={selectedModuleId}
|
||||||
{/* Video Player */}
|
onSelectModule={id => { setSelectedModuleId(id); setGeneratedContent(null); setModuleMedia([]); if (id) { handleLoadContent(id); loadModuleMedia(id) } }}
|
||||||
{selectedModuleId && generatedContent?.is_published && (
|
generatedContent={generatedContent} generating={generating}
|
||||||
<VideoPlayer
|
bulkGenerating={bulkGenerating} bulkResult={bulkResult} moduleMedia={moduleMedia}
|
||||||
moduleId={selectedModuleId}
|
interactiveGenerating={interactiveGenerating}
|
||||||
video={moduleMedia.find(m => m.media_type === 'video') || null}
|
onGenerateContent={handleGenerateContent} onGenerateQuiz={handleGenerateQuiz}
|
||||||
onMediaUpdate={() => loadModuleMedia(selectedModuleId)}
|
onBulkContent={handleBulkContent} onBulkQuiz={handleBulkQuiz}
|
||||||
|
onPublishContent={handlePublishContent} onReloadMedia={() => loadModuleMedia(selectedModuleId)}
|
||||||
|
onGenerateInteractiveVideo={handleGenerateInteractiveVideo}
|
||||||
/>
|
/>
|
||||||
)}
|
|
||||||
|
|
||||||
{/* 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>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Script Preview */}
|
|
||||||
{selectedModuleId && generatedContent?.is_published && (
|
|
||||||
<ScriptPreview moduleId={selectedModuleId} />
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{activeTab === 'audit' && <AuditTab auditLog={auditLog} />}
|
{activeTab === 'audit' && <AuditTab auditLog={auditLog} />}
|
||||||
|
|
||||||
{/* Modals & Drawers */}
|
{showModuleCreate && <ModuleCreateModal onClose={() => setShowModuleCreate(false)} onSaved={() => { setShowModuleCreate(false); loadData() }} />}
|
||||||
{showModuleCreate && (
|
{selectedModule && <ModuleEditDrawer module={selectedModule} onClose={() => setSelectedModule(null)} onSaved={() => { setSelectedModule(null); loadData() }} />}
|
||||||
<ModuleCreateModal
|
{matrixAddRole && <MatrixAddModal roleCode={matrixAddRole} modules={modules} onClose={() => setMatrixAddRole(null)} onSaved={() => { setMatrixAddRole(null); loadData() }} />}
|
||||||
onClose={() => setShowModuleCreate(false)}
|
{selectedAssignment && <AssignmentDetailDrawer assignment={selectedAssignment} onClose={() => setSelectedAssignment(null)} onSaved={() => { setSelectedAssignment(null); loadData() }} />}
|
||||||
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>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user