merge: sync with origin/main, take upstream on conflicts
# Conflicts: # admin-compliance/lib/sdk/types.ts # admin-compliance/lib/sdk/vendor-compliance/types.ts
This commit is contained in:
@@ -160,6 +160,8 @@ export const ARCH_SERVICES: ArchService[] = [
|
||||
'security_backlog', 'quality_entries',
|
||||
'notfallplan_incidents', 'notfallplan_templates',
|
||||
'data_processing_agreement',
|
||||
'vendor_vendors', 'vendor_contracts', 'vendor_findings',
|
||||
'vendor_control_instances', 'compliance_templates',
|
||||
'compliance_isms_scope', 'compliance_isms_context', 'compliance_isms_policy',
|
||||
'compliance_security_objectives', 'compliance_soa',
|
||||
'compliance_audit_findings', 'compliance_corrective_actions',
|
||||
@@ -178,6 +180,10 @@ export const ARCH_SERVICES: ArchService[] = [
|
||||
'CRUD /api/compliance/vvt',
|
||||
'CRUD /api/compliance/loeschfristen',
|
||||
'CRUD /api/compliance/obligations',
|
||||
'CRUD /api/sdk/v1/vendor-compliance/vendors',
|
||||
'CRUD /api/sdk/v1/vendor-compliance/contracts',
|
||||
'CRUD /api/sdk/v1/vendor-compliance/findings',
|
||||
'CRUD /api/sdk/v1/vendor-compliance/control-instances',
|
||||
'CRUD /api/isms/scope',
|
||||
'CRUD /api/isms/policies',
|
||||
'CRUD /api/isms/objectives',
|
||||
|
||||
@@ -0,0 +1,468 @@
|
||||
'use client'
|
||||
|
||||
import React, { useState, useEffect } from 'react'
|
||||
|
||||
// =============================================================================
|
||||
// TYPES
|
||||
// =============================================================================
|
||||
|
||||
interface Assertion {
|
||||
id: string
|
||||
tenant_id: string | null
|
||||
entity_type: string
|
||||
entity_id: string
|
||||
sentence_text: string
|
||||
sentence_index: number
|
||||
assertion_type: string // 'assertion' | 'fact' | 'rationale'
|
||||
evidence_ids: string[]
|
||||
confidence: number
|
||||
normative_tier: string | null // 'pflicht' | 'empfehlung' | 'kann'
|
||||
verified_by: string | null
|
||||
verified_at: string | null
|
||||
created_at: string | null
|
||||
updated_at: string | null
|
||||
}
|
||||
|
||||
interface AssertionSummary {
|
||||
total_assertions: number
|
||||
total_facts: number
|
||||
total_rationale: number
|
||||
unverified_count: number
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// CONSTANTS
|
||||
// =============================================================================
|
||||
|
||||
const TIER_COLORS: Record<string, string> = {
|
||||
pflicht: 'bg-red-100 text-red-700',
|
||||
empfehlung: 'bg-yellow-100 text-yellow-700',
|
||||
kann: 'bg-blue-100 text-blue-700',
|
||||
}
|
||||
|
||||
const TIER_LABELS: Record<string, string> = {
|
||||
pflicht: 'Pflicht',
|
||||
empfehlung: 'Empfehlung',
|
||||
kann: 'Kann',
|
||||
}
|
||||
|
||||
const TYPE_COLORS: Record<string, string> = {
|
||||
assertion: 'bg-orange-100 text-orange-700',
|
||||
fact: 'bg-green-100 text-green-700',
|
||||
rationale: 'bg-purple-100 text-purple-700',
|
||||
}
|
||||
|
||||
const TYPE_LABELS: Record<string, string> = {
|
||||
assertion: 'Behauptung',
|
||||
fact: 'Fakt',
|
||||
rationale: 'Begruendung',
|
||||
}
|
||||
|
||||
const API_BASE = '/api/sdk/v1/compliance'
|
||||
|
||||
type TabKey = 'overview' | 'list' | 'extract'
|
||||
|
||||
// =============================================================================
|
||||
// ASSERTION CARD
|
||||
// =============================================================================
|
||||
|
||||
function AssertionCard({
|
||||
assertion,
|
||||
onVerify,
|
||||
}: {
|
||||
assertion: Assertion
|
||||
onVerify: (id: string) => void
|
||||
}) {
|
||||
const tierColor = assertion.normative_tier ? TIER_COLORS[assertion.normative_tier] || 'bg-gray-100 text-gray-600' : 'bg-gray-100 text-gray-600'
|
||||
const tierLabel = assertion.normative_tier ? TIER_LABELS[assertion.normative_tier] || assertion.normative_tier : '—'
|
||||
const typeColor = TYPE_COLORS[assertion.assertion_type] || 'bg-gray-100 text-gray-600'
|
||||
const typeLabel = TYPE_LABELS[assertion.assertion_type] || assertion.assertion_type
|
||||
|
||||
return (
|
||||
<div className="bg-white rounded-xl border border-gray-200 p-5">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<span className={`px-2 py-0.5 text-xs rounded font-medium ${tierColor}`}>
|
||||
{tierLabel}
|
||||
</span>
|
||||
<span className={`px-2 py-0.5 text-xs rounded ${typeColor}`}>
|
||||
{typeLabel}
|
||||
</span>
|
||||
{assertion.entity_type && (
|
||||
<span className="px-2 py-0.5 text-xs bg-gray-100 text-gray-500 rounded">
|
||||
{assertion.entity_type}: {assertion.entity_id?.slice(0, 8) || '—'}
|
||||
</span>
|
||||
)}
|
||||
{assertion.confidence > 0 && (
|
||||
<span className="text-xs text-gray-400">
|
||||
Konfidenz: {(assertion.confidence * 100).toFixed(0)}%
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-sm text-gray-900 leading-relaxed">
|
||||
“{assertion.sentence_text}”
|
||||
</p>
|
||||
<div className="mt-2 flex items-center gap-3 text-xs text-gray-400">
|
||||
{assertion.verified_by && (
|
||||
<span className="text-green-600">
|
||||
Verifiziert von {assertion.verified_by} am {assertion.verified_at ? new Date(assertion.verified_at).toLocaleDateString('de-DE') : '—'}
|
||||
</span>
|
||||
)}
|
||||
{assertion.evidence_ids.length > 0 && (
|
||||
<span>
|
||||
{assertion.evidence_ids.length} Evidence verknuepft
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
{assertion.assertion_type !== 'fact' && (
|
||||
<button
|
||||
onClick={() => onVerify(assertion.id)}
|
||||
className="px-3 py-1.5 text-xs bg-green-600 text-white rounded-lg hover:bg-green-700 transition-colors whitespace-nowrap"
|
||||
>
|
||||
Als Fakt pruefen
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// MAIN PAGE
|
||||
// =============================================================================
|
||||
|
||||
export default function AssertionsPage() {
|
||||
const [activeTab, setActiveTab] = useState<TabKey>('overview')
|
||||
const [summary, setSummary] = useState<AssertionSummary | null>(null)
|
||||
const [assertions, setAssertions] = useState<Assertion[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
// Filters
|
||||
const [filterEntityType, setFilterEntityType] = useState('')
|
||||
const [filterAssertionType, setFilterAssertionType] = useState('')
|
||||
|
||||
// Extract tab
|
||||
const [extractText, setExtractText] = useState('')
|
||||
const [extractEntityType, setExtractEntityType] = useState('control')
|
||||
const [extractEntityId, setExtractEntityId] = useState('')
|
||||
const [extracting, setExtracting] = useState(false)
|
||||
const [extractedAssertions, setExtractedAssertions] = useState<Assertion[]>([])
|
||||
|
||||
// Verify dialog
|
||||
const [verifyingId, setVerifyingId] = useState<string | null>(null)
|
||||
const [verifyEmail, setVerifyEmail] = useState('')
|
||||
|
||||
useEffect(() => {
|
||||
loadSummary()
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (activeTab === 'list') loadAssertions()
|
||||
}, [activeTab, filterEntityType, filterAssertionType]) // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
const loadSummary = async () => {
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/assertions/summary`)
|
||||
if (res.ok) setSummary(await res.json())
|
||||
} catch { /* silent */ }
|
||||
finally { setLoading(false) }
|
||||
}
|
||||
|
||||
const loadAssertions = async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const params = new URLSearchParams()
|
||||
if (filterEntityType) params.set('entity_type', filterEntityType)
|
||||
if (filterAssertionType) params.set('assertion_type', filterAssertionType)
|
||||
params.set('limit', '200')
|
||||
|
||||
const res = await fetch(`${API_BASE}/assertions?${params}`)
|
||||
if (res.ok) {
|
||||
const data = await res.json()
|
||||
setAssertions(data.assertions || [])
|
||||
}
|
||||
} catch {
|
||||
setError('Assertions konnten nicht geladen werden')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleExtract = async () => {
|
||||
if (!extractText.trim()) { setError('Bitte Text eingeben'); return }
|
||||
setExtracting(true)
|
||||
setError(null)
|
||||
setExtractedAssertions([])
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/assertions/extract`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
text: extractText,
|
||||
entity_type: extractEntityType || 'control',
|
||||
entity_id: extractEntityId || undefined,
|
||||
}),
|
||||
})
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(() => ({ detail: 'Extraktion fehlgeschlagen' }))
|
||||
throw new Error(typeof err.detail === 'string' ? err.detail : JSON.stringify(err.detail))
|
||||
}
|
||||
const data = await res.json()
|
||||
setExtractedAssertions(data.assertions || [])
|
||||
// Refresh summary
|
||||
loadSummary()
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Extraktion fehlgeschlagen')
|
||||
} finally {
|
||||
setExtracting(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleVerify = async (assertionId: string) => {
|
||||
setVerifyingId(assertionId)
|
||||
}
|
||||
|
||||
const submitVerify = async () => {
|
||||
if (!verifyingId || !verifyEmail.trim()) return
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/assertions/${verifyingId}/verify?verified_by=${encodeURIComponent(verifyEmail)}`, {
|
||||
method: 'POST',
|
||||
})
|
||||
if (res.ok) {
|
||||
setVerifyingId(null)
|
||||
setVerifyEmail('')
|
||||
loadAssertions()
|
||||
loadSummary()
|
||||
} else {
|
||||
const err = await res.json().catch(() => ({ detail: 'Verifizierung fehlgeschlagen' }))
|
||||
setError(typeof err.detail === 'string' ? err.detail : 'Verifizierung fehlgeschlagen')
|
||||
}
|
||||
} catch {
|
||||
setError('Netzwerkfehler')
|
||||
}
|
||||
}
|
||||
|
||||
const tabs: { key: TabKey; label: string }[] = [
|
||||
{ key: 'overview', label: 'Uebersicht' },
|
||||
{ key: 'list', label: 'Assertion-Liste' },
|
||||
{ key: 'extract', label: 'Extraktion' },
|
||||
]
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="bg-white rounded-xl shadow-sm border p-6">
|
||||
<h1 className="text-2xl font-bold text-slate-900">Assertions</h1>
|
||||
<p className="text-slate-500 mt-1">
|
||||
Behauptungen vs. Fakten in Compliance-Texten trennen und verifizieren.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Tabs */}
|
||||
<div className="bg-white rounded-xl shadow-sm border">
|
||||
<div className="flex border-b">
|
||||
{tabs.map(tab => (
|
||||
<button
|
||||
key={tab.key}
|
||||
onClick={() => setActiveTab(tab.key)}
|
||||
className={`px-6 py-3 text-sm font-medium transition-colors ${
|
||||
activeTab === tab.key
|
||||
? 'text-purple-600 border-b-2 border-purple-600'
|
||||
: 'text-slate-500 hover:text-slate-700'
|
||||
}`}
|
||||
>
|
||||
{tab.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Error */}
|
||||
{error && (
|
||||
<div className="p-4 bg-red-50 border border-red-200 rounded-lg text-red-700 flex items-center justify-between">
|
||||
<span>{error}</span>
|
||||
<button onClick={() => setError(null)} className="text-red-500 hover:text-red-700">×</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ============================================================ */}
|
||||
{/* TAB: Uebersicht */}
|
||||
{/* ============================================================ */}
|
||||
{activeTab === 'overview' && (
|
||||
<>
|
||||
{loading ? (
|
||||
<div className="flex justify-center py-12">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-purple-600" />
|
||||
</div>
|
||||
) : summary ? (
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
|
||||
<div className="bg-white rounded-xl border border-gray-200 p-6">
|
||||
<div className="text-sm text-gray-500">Gesamt Assertions</div>
|
||||
<div className="text-3xl font-bold text-gray-900">{summary.total_assertions}</div>
|
||||
</div>
|
||||
<div className="bg-white rounded-xl border border-green-200 p-6">
|
||||
<div className="text-sm text-green-600">Verifizierte Fakten</div>
|
||||
<div className="text-3xl font-bold text-green-600">{summary.total_facts}</div>
|
||||
</div>
|
||||
<div className="bg-white rounded-xl border border-purple-200 p-6">
|
||||
<div className="text-sm text-purple-600">Begruendungen</div>
|
||||
<div className="text-3xl font-bold text-purple-600">{summary.total_rationale}</div>
|
||||
</div>
|
||||
<div className="bg-white rounded-xl border border-orange-200 p-6">
|
||||
<div className="text-sm text-orange-600">Unverifizizt</div>
|
||||
<div className="text-3xl font-bold text-orange-600">{summary.unverified_count}</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="bg-white rounded-xl border border-gray-200 p-12 text-center">
|
||||
<p className="text-gray-500">Keine Assertions vorhanden. Nutzen Sie die Extraktion, um Behauptungen aus Texten zu identifizieren.</p>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* ============================================================ */}
|
||||
{/* TAB: Assertion-Liste */}
|
||||
{/* ============================================================ */}
|
||||
{activeTab === 'list' && (
|
||||
<>
|
||||
{/* Filters */}
|
||||
<div className="flex items-center gap-4 flex-wrap">
|
||||
<div>
|
||||
<label className="block text-xs text-gray-500 mb-1">Entity-Typ</label>
|
||||
<select value={filterEntityType} onChange={e => setFilterEntityType(e.target.value)}
|
||||
className="px-3 py-1.5 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-purple-500 focus:border-transparent">
|
||||
<option value="">Alle</option>
|
||||
<option value="control">Control</option>
|
||||
<option value="evidence">Evidence</option>
|
||||
<option value="requirement">Requirement</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs text-gray-500 mb-1">Assertion-Typ</label>
|
||||
<select value={filterAssertionType} onChange={e => setFilterAssertionType(e.target.value)}
|
||||
className="px-3 py-1.5 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-purple-500 focus:border-transparent">
|
||||
<option value="">Alle</option>
|
||||
<option value="assertion">Behauptung</option>
|
||||
<option value="fact">Fakt</option>
|
||||
<option value="rationale">Begruendung</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div className="flex justify-center py-12">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-purple-600" />
|
||||
</div>
|
||||
) : assertions.length === 0 ? (
|
||||
<div className="bg-white rounded-xl border border-gray-200 p-12 text-center">
|
||||
<p className="text-gray-500">Keine Assertions gefunden.</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
<p className="text-sm text-gray-500">{assertions.length} Assertions</p>
|
||||
{assertions.map(a => (
|
||||
<AssertionCard key={a.id} assertion={a} onVerify={handleVerify} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* ============================================================ */}
|
||||
{/* TAB: Extraktion */}
|
||||
{/* ============================================================ */}
|
||||
{activeTab === 'extract' && (
|
||||
<div className="bg-white rounded-xl shadow-sm border p-6">
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-4">Assertions aus Text extrahieren</h3>
|
||||
<p className="text-sm text-gray-500 mb-4">
|
||||
Geben Sie einen Compliance-Text ein. Das System identifiziert automatisch Behauptungen, Fakten und Begruendungen.
|
||||
</p>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4 mb-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Entity-Typ</label>
|
||||
<select value={extractEntityType} onChange={e => setExtractEntityType(e.target.value)}
|
||||
className="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm focus:ring-2 focus:ring-purple-500 focus:border-transparent">
|
||||
<option value="control">Control</option>
|
||||
<option value="evidence">Evidence</option>
|
||||
<option value="requirement">Requirement</option>
|
||||
<option value="policy">Policy</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Entity-ID (optional)</label>
|
||||
<input type="text" value={extractEntityId} onChange={e => setExtractEntityId(e.target.value)}
|
||||
placeholder="z.B. GOV-001 oder UUID"
|
||||
className="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm focus:ring-2 focus:ring-purple-500 focus:border-transparent" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mb-4">
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Text</label>
|
||||
<textarea
|
||||
value={extractText}
|
||||
onChange={e => setExtractText(e.target.value)}
|
||||
placeholder="Die Organisation muss ein ISMS gemaess ISO 27001 implementieren. Es sollte regelmaessig ein internes Audit durchgefuehrt werden. Optional kann ein externer Auditor hinzugezogen werden."
|
||||
rows={6}
|
||||
className="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm focus:ring-2 focus:ring-purple-500 focus:border-transparent resize-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={handleExtract}
|
||||
disabled={extracting || !extractText.trim()}
|
||||
className={`px-5 py-2 rounded-lg font-medium transition-colors ${
|
||||
extracting || !extractText.trim()
|
||||
? 'bg-gray-200 text-gray-400 cursor-not-allowed'
|
||||
: 'bg-purple-600 text-white hover:bg-purple-700'
|
||||
}`}
|
||||
>
|
||||
{extracting ? 'Extrahiere...' : 'Extrahieren'}
|
||||
</button>
|
||||
|
||||
{/* Extracted results */}
|
||||
{extractedAssertions.length > 0 && (
|
||||
<div className="mt-6">
|
||||
<h4 className="text-sm font-semibold text-gray-800 mb-3">{extractedAssertions.length} Assertions extrahiert:</h4>
|
||||
<div className="space-y-3">
|
||||
{extractedAssertions.map(a => (
|
||||
<AssertionCard key={a.id} assertion={a} onVerify={handleVerify} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Verify Dialog */}
|
||||
{verifyingId && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50" onClick={() => setVerifyingId(null)}>
|
||||
<div className="bg-white rounded-2xl shadow-xl w-full max-w-md mx-4 p-6" onClick={e => e.stopPropagation()}>
|
||||
<h2 className="text-lg font-bold text-gray-900 mb-4">Als Fakt verifizieren</h2>
|
||||
<div className="mb-4">
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Verifiziert von (E-Mail)</label>
|
||||
<input type="email" value={verifyEmail} onChange={e => setVerifyEmail(e.target.value)}
|
||||
placeholder="auditor@unternehmen.de"
|
||||
className="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm focus:ring-2 focus:ring-purple-500 focus:border-transparent" />
|
||||
</div>
|
||||
<div className="flex justify-end gap-3">
|
||||
<button onClick={() => setVerifyingId(null)} className="px-4 py-2 text-sm text-gray-600 hover:bg-gray-100 rounded-lg transition-colors">
|
||||
Abbrechen
|
||||
</button>
|
||||
<button onClick={submitVerify} disabled={!verifyEmail.trim()}
|
||||
className="px-4 py-2 text-sm bg-green-600 text-white rounded-lg hover:bg-green-700 transition-colors disabled:opacity-50">
|
||||
Verifizieren
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,413 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect, useCallback, useRef } from 'react'
|
||||
import {
|
||||
Atom, Search, ChevronRight, ChevronLeft, Filter,
|
||||
BarChart3, ChevronsLeft, ChevronsRight, ArrowUpDown,
|
||||
Clock, RefreshCw,
|
||||
} from 'lucide-react'
|
||||
import {
|
||||
CanonicalControl, BACKEND_URL,
|
||||
SeverityBadge, StateBadge, CategoryBadge, TargetAudienceBadge,
|
||||
GenerationStrategyBadge, ObligationTypeBadge, RegulationCountBadge,
|
||||
CATEGORY_OPTIONS,
|
||||
} from '../control-library/components/helpers'
|
||||
import { ControlDetail } from '../control-library/components/ControlDetail'
|
||||
|
||||
// =============================================================================
|
||||
// TYPES
|
||||
// =============================================================================
|
||||
|
||||
interface AtomicStats {
|
||||
total_active: number
|
||||
total_duplicate: number
|
||||
by_domain: Array<{ domain: string; count: number }>
|
||||
by_regulation: Array<{ regulation: string; count: number }>
|
||||
avg_regulation_coverage: number
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// ATOMIC CONTROLS PAGE
|
||||
// =============================================================================
|
||||
|
||||
const PAGE_SIZE = 50
|
||||
|
||||
export default function AtomicControlsPage() {
|
||||
const [controls, setControls] = useState<CanonicalControl[]>([])
|
||||
const [totalCount, setTotalCount] = useState(0)
|
||||
const [stats, setStats] = useState<AtomicStats | null>(null)
|
||||
const [selectedControl, setSelectedControl] = useState<CanonicalControl | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
// Filters
|
||||
const [searchQuery, setSearchQuery] = useState('')
|
||||
const [debouncedSearch, setDebouncedSearch] = useState('')
|
||||
const [severityFilter, setSeverityFilter] = useState<string>('')
|
||||
const [domainFilter, setDomainFilter] = useState<string>('')
|
||||
const [categoryFilter, setCategoryFilter] = useState<string>('')
|
||||
const [sortBy, setSortBy] = useState<'id' | 'newest' | 'oldest'>('id')
|
||||
|
||||
// Pagination
|
||||
const [currentPage, setCurrentPage] = useState(1)
|
||||
|
||||
// Mode
|
||||
const [mode, setMode] = useState<'list' | 'detail'>('list')
|
||||
|
||||
// Debounce search
|
||||
const searchTimer = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
useEffect(() => {
|
||||
if (searchTimer.current) clearTimeout(searchTimer.current)
|
||||
searchTimer.current = setTimeout(() => setDebouncedSearch(searchQuery), 400)
|
||||
return () => { if (searchTimer.current) clearTimeout(searchTimer.current) }
|
||||
}, [searchQuery])
|
||||
|
||||
// Build query params
|
||||
const buildParams = useCallback((extra?: Record<string, string>) => {
|
||||
const p = new URLSearchParams()
|
||||
p.set('control_type', 'atomic')
|
||||
// Exclude duplicates — show only active masters
|
||||
if (!extra?.release_state) {
|
||||
// Don't filter by state for count queries that already have it
|
||||
}
|
||||
if (severityFilter) p.set('severity', severityFilter)
|
||||
if (domainFilter) p.set('domain', domainFilter)
|
||||
if (categoryFilter) p.set('category', categoryFilter)
|
||||
if (debouncedSearch) p.set('search', debouncedSearch)
|
||||
if (extra) for (const [k, v] of Object.entries(extra)) p.set(k, v)
|
||||
return p.toString()
|
||||
}, [severityFilter, domainFilter, categoryFilter, debouncedSearch])
|
||||
|
||||
// Load stats
|
||||
const loadStats = useCallback(async () => {
|
||||
try {
|
||||
const res = await fetch(`${BACKEND_URL}?endpoint=atomic-stats`)
|
||||
if (res.ok) setStats(await res.json())
|
||||
} catch { /* ignore */ }
|
||||
}, [])
|
||||
|
||||
// Load controls page
|
||||
const loadControls = useCallback(async () => {
|
||||
try {
|
||||
setLoading(true)
|
||||
const sortField = sortBy === 'id' ? 'control_id' : 'created_at'
|
||||
const sortOrder = sortBy === 'newest' ? 'desc' : 'asc'
|
||||
const offset = (currentPage - 1) * PAGE_SIZE
|
||||
|
||||
const qs = buildParams({
|
||||
sort: sortField,
|
||||
order: sortOrder,
|
||||
limit: String(PAGE_SIZE),
|
||||
offset: String(offset),
|
||||
})
|
||||
|
||||
const countQs = buildParams()
|
||||
|
||||
const [ctrlRes, countRes] = await Promise.all([
|
||||
fetch(`${BACKEND_URL}?endpoint=controls&${qs}`),
|
||||
fetch(`${BACKEND_URL}?endpoint=controls-count&${countQs}`),
|
||||
])
|
||||
|
||||
if (ctrlRes.ok) setControls(await ctrlRes.json())
|
||||
if (countRes.ok) {
|
||||
const data = await countRes.json()
|
||||
setTotalCount(data.total || 0)
|
||||
}
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Fehler beim Laden')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [buildParams, sortBy, currentPage])
|
||||
|
||||
// Initial load
|
||||
useEffect(() => { loadStats() }, [loadStats])
|
||||
useEffect(() => { loadControls() }, [loadControls])
|
||||
useEffect(() => { setCurrentPage(1) }, [severityFilter, domainFilter, categoryFilter, debouncedSearch, sortBy])
|
||||
|
||||
const totalPages = Math.max(1, Math.ceil(totalCount / PAGE_SIZE))
|
||||
|
||||
// Loading
|
||||
if (loading && controls.length === 0) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-96">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-2 border-violet-600 border-t-transparent" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-96">
|
||||
<p className="text-red-600">{error}</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// DETAIL MODE
|
||||
if (mode === 'detail' && selectedControl) {
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
<div className="flex-1 overflow-hidden">
|
||||
<ControlDetail
|
||||
ctrl={selectedControl}
|
||||
onBack={() => { setMode('list'); setSelectedControl(null) }}
|
||||
onEdit={() => {}}
|
||||
onDelete={() => {}}
|
||||
onReview={() => {}}
|
||||
onNavigateToControl={async (controlId: string) => {
|
||||
try {
|
||||
const res = await fetch(`${BACKEND_URL}?endpoint=control&id=${controlId}`)
|
||||
if (res.ok) {
|
||||
const data = await res.json()
|
||||
setSelectedControl(data)
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// LIST VIEW
|
||||
// =========================================================================
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
{/* Header */}
|
||||
<div className="border-b border-gray-200 bg-white px-6 py-4">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<Atom className="w-6 h-6 text-violet-600" />
|
||||
<div>
|
||||
<h1 className="text-lg font-semibold text-gray-900">Atomare Controls</h1>
|
||||
<p className="text-xs text-gray-500">
|
||||
Deduplizierte atomare Controls mit Herkunftsnachweis
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => { loadControls(); loadStats() }}
|
||||
className="p-2 text-gray-400 hover:text-violet-600"
|
||||
title="Aktualisieren"
|
||||
>
|
||||
<RefreshCw className={`w-4 h-4 ${loading ? 'animate-spin' : ''}`} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Stats Bar */}
|
||||
{stats && (
|
||||
<div className="grid grid-cols-4 gap-3 mb-4">
|
||||
<div className="bg-violet-50 border border-violet-200 rounded-lg p-3">
|
||||
<div className="text-2xl font-bold text-violet-700">{stats.total_active.toLocaleString('de-DE')}</div>
|
||||
<div className="text-xs text-violet-500">Master Controls</div>
|
||||
</div>
|
||||
<div className="bg-gray-50 border border-gray-200 rounded-lg p-3">
|
||||
<div className="text-2xl font-bold text-gray-600">{stats.total_duplicate.toLocaleString('de-DE')}</div>
|
||||
<div className="text-xs text-gray-500">Duplikate (entfernt)</div>
|
||||
</div>
|
||||
<div className="bg-indigo-50 border border-indigo-200 rounded-lg p-3">
|
||||
<div className="text-2xl font-bold text-indigo-700">{stats.by_regulation.length}</div>
|
||||
<div className="text-xs text-indigo-500">Regulierungen</div>
|
||||
</div>
|
||||
<div className="bg-emerald-50 border border-emerald-200 rounded-lg p-3">
|
||||
<div className="text-2xl font-bold text-emerald-700">{stats.avg_regulation_coverage}</div>
|
||||
<div className="text-xs text-emerald-500">Avg. Regulierungen / Control</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Filters */}
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="relative flex-1">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Atomare Controls durchsuchen (ID, Titel, Objective)..."
|
||||
value={searchQuery}
|
||||
onChange={e => setSearchQuery(e.target.value)}
|
||||
className="w-full pl-9 pr-4 py-2 text-sm border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-violet-500"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<Filter className="w-4 h-4 text-gray-400" />
|
||||
<select
|
||||
value={domainFilter}
|
||||
onChange={e => setDomainFilter(e.target.value)}
|
||||
className="text-sm border border-gray-300 rounded-lg px-2 py-1.5 focus:outline-none focus:ring-2 focus:ring-violet-500"
|
||||
>
|
||||
<option value="">Domain</option>
|
||||
{stats?.by_domain.map(d => (
|
||||
<option key={d.domain} value={d.domain}>{d.domain} ({d.count})</option>
|
||||
))}
|
||||
</select>
|
||||
<select
|
||||
value={severityFilter}
|
||||
onChange={e => setSeverityFilter(e.target.value)}
|
||||
className="text-sm border border-gray-300 rounded-lg px-2 py-1.5 focus:outline-none focus:ring-2 focus:ring-violet-500"
|
||||
>
|
||||
<option value="">Schweregrad</option>
|
||||
<option value="critical">Kritisch</option>
|
||||
<option value="high">Hoch</option>
|
||||
<option value="medium">Mittel</option>
|
||||
<option value="low">Niedrig</option>
|
||||
</select>
|
||||
<select
|
||||
value={categoryFilter}
|
||||
onChange={e => setCategoryFilter(e.target.value)}
|
||||
className="text-sm border border-gray-300 rounded-lg px-2 py-1.5 focus:outline-none focus:ring-2 focus:ring-violet-500"
|
||||
>
|
||||
<option value="">Kategorie</option>
|
||||
{CATEGORY_OPTIONS.map(c => (
|
||||
<option key={c.value} value={c.value}>{c.label}</option>
|
||||
))}
|
||||
</select>
|
||||
<span className="text-gray-300 mx-1">|</span>
|
||||
<ArrowUpDown className="w-4 h-4 text-gray-400" />
|
||||
<select
|
||||
value={sortBy}
|
||||
onChange={e => setSortBy(e.target.value as 'id' | 'newest' | 'oldest')}
|
||||
className="text-sm border border-gray-300 rounded-lg px-2 py-1.5 focus:outline-none focus:ring-2 focus:ring-violet-500"
|
||||
>
|
||||
<option value="id">Sortierung: ID</option>
|
||||
<option value="newest">Neueste zuerst</option>
|
||||
<option value="oldest">Aelteste zuerst</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Pagination Header */}
|
||||
<div className="px-6 py-2 bg-gray-50 border-b border-gray-200 flex items-center justify-between text-xs text-gray-500">
|
||||
<span>
|
||||
{totalCount} Controls gefunden
|
||||
{stats && totalCount !== stats.total_active && ` (von ${stats.total_active.toLocaleString('de-DE')} Master Controls)`}
|
||||
{loading && <span className="ml-2 text-violet-500">Lade...</span>}
|
||||
</span>
|
||||
<span>Seite {currentPage} von {totalPages}</span>
|
||||
</div>
|
||||
|
||||
{/* Control List */}
|
||||
<div className="flex-1 overflow-y-auto p-6">
|
||||
<div className="space-y-3">
|
||||
{controls.map((ctrl) => (
|
||||
<button
|
||||
key={ctrl.control_id}
|
||||
onClick={() => { setSelectedControl(ctrl); setMode('detail') }}
|
||||
className="w-full text-left bg-white border border-gray-200 rounded-lg p-4 hover:border-violet-300 hover:shadow-sm transition-all group"
|
||||
>
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 mb-1 flex-wrap">
|
||||
<span className="text-xs font-mono text-purple-600 bg-purple-50 px-1.5 py-0.5 rounded">{ctrl.control_id}</span>
|
||||
<SeverityBadge severity={ctrl.severity} />
|
||||
<StateBadge state={ctrl.release_state} />
|
||||
<CategoryBadge category={ctrl.category} />
|
||||
<TargetAudienceBadge audience={ctrl.target_audience} />
|
||||
<GenerationStrategyBadge strategy={ctrl.generation_strategy} pipelineInfo={ctrl} />
|
||||
<ObligationTypeBadge type={ctrl.generation_metadata?.obligation_type as string} />
|
||||
</div>
|
||||
<h3 className="text-sm font-medium text-gray-900 group-hover:text-violet-700">{ctrl.title}</h3>
|
||||
<p className="text-xs text-gray-500 mt-1 line-clamp-2">{ctrl.objective}</p>
|
||||
<div className="flex items-center gap-2 mt-2">
|
||||
{ctrl.source_citation?.source && (
|
||||
<>
|
||||
<span className="text-xs text-blue-600">
|
||||
{ctrl.source_citation.source}
|
||||
{ctrl.source_citation.article && ` ${ctrl.source_citation.article}`}
|
||||
</span>
|
||||
<span className="text-gray-300">|</span>
|
||||
</>
|
||||
)}
|
||||
{ctrl.parent_control_id && (
|
||||
<>
|
||||
<span className="text-xs text-violet-500">via {ctrl.parent_control_id}</span>
|
||||
<span className="text-gray-300">|</span>
|
||||
</>
|
||||
)}
|
||||
<Clock className="w-3 h-3 text-gray-400" />
|
||||
<span className="text-xs text-gray-400" title={ctrl.created_at}>
|
||||
{ctrl.created_at ? new Date(ctrl.created_at).toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit', year: '2-digit' }) : '-'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<ChevronRight className="w-4 h-4 text-gray-300 group-hover:text-violet-500 flex-shrink-0 mt-1 ml-4" />
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
|
||||
{controls.length === 0 && !loading && (
|
||||
<div className="text-center py-12 text-gray-400 text-sm">
|
||||
Keine atomaren Controls gefunden.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Pagination Controls */}
|
||||
{totalPages > 1 && (
|
||||
<div className="flex items-center justify-center gap-2 mt-6 pb-4">
|
||||
<button
|
||||
onClick={() => setCurrentPage(1)}
|
||||
disabled={currentPage === 1}
|
||||
className="p-2 text-gray-500 hover:text-violet-600 disabled:opacity-30 disabled:cursor-not-allowed"
|
||||
>
|
||||
<ChevronsLeft className="w-4 h-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setCurrentPage(p => Math.max(1, p - 1))}
|
||||
disabled={currentPage === 1}
|
||||
className="p-2 text-gray-500 hover:text-violet-600 disabled:opacity-30 disabled:cursor-not-allowed"
|
||||
>
|
||||
<ChevronLeft className="w-4 h-4" />
|
||||
</button>
|
||||
|
||||
{Array.from({ length: totalPages }, (_, i) => i + 1)
|
||||
.filter(p => p === 1 || p === totalPages || Math.abs(p - currentPage) <= 2)
|
||||
.reduce<(number | 'dots')[]>((acc, p, i, arr) => {
|
||||
if (i > 0 && p - (arr[i - 1] as number) > 1) acc.push('dots')
|
||||
acc.push(p)
|
||||
return acc
|
||||
}, [])
|
||||
.map((p, i) =>
|
||||
p === 'dots' ? (
|
||||
<span key={`dots-${i}`} className="px-1 text-gray-400">...</span>
|
||||
) : (
|
||||
<button
|
||||
key={p}
|
||||
onClick={() => setCurrentPage(p as number)}
|
||||
className={`w-8 h-8 text-sm rounded-lg ${
|
||||
currentPage === p
|
||||
? 'bg-violet-600 text-white'
|
||||
: 'text-gray-600 hover:bg-violet-50 hover:text-violet-600'
|
||||
}`}
|
||||
>
|
||||
{p}
|
||||
</button>
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
<button
|
||||
onClick={() => setCurrentPage(p => Math.min(totalPages, p + 1))}
|
||||
disabled={currentPage === totalPages}
|
||||
className="p-2 text-gray-500 hover:text-violet-600 disabled:opacity-30 disabled:cursor-not-allowed"
|
||||
>
|
||||
<ChevronRight className="w-4 h-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setCurrentPage(totalPages)}
|
||||
disabled={currentPage === totalPages}
|
||||
className="p-2 text-gray-500 hover:text-violet-600 disabled:opacity-30 disabled:cursor-not-allowed"
|
||||
>
|
||||
<ChevronsRight className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,56 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { getDomain, BACKEND_URL, EMPTY_CONTROL, DOMAIN_OPTIONS, COLLECTION_OPTIONS } from '../components/helpers'
|
||||
|
||||
describe('getDomain', () => {
|
||||
it('extracts domain from control_id', () => {
|
||||
expect(getDomain('AUTH-001')).toBe('AUTH')
|
||||
expect(getDomain('NET-042')).toBe('NET')
|
||||
expect(getDomain('CRYPT-003')).toBe('CRYPT')
|
||||
})
|
||||
|
||||
it('returns empty string for invalid control_id', () => {
|
||||
expect(getDomain('')).toBe('')
|
||||
expect(getDomain('NODASH')).toBe('NODASH')
|
||||
})
|
||||
})
|
||||
|
||||
describe('BACKEND_URL', () => {
|
||||
it('points to canonical API proxy', () => {
|
||||
expect(BACKEND_URL).toBe('/api/sdk/v1/canonical')
|
||||
})
|
||||
})
|
||||
|
||||
describe('EMPTY_CONTROL', () => {
|
||||
it('has required fields with default values', () => {
|
||||
expect(EMPTY_CONTROL.framework_id).toBe('bp_security_v1')
|
||||
expect(EMPTY_CONTROL.severity).toBe('medium')
|
||||
expect(EMPTY_CONTROL.release_state).toBe('draft')
|
||||
expect(EMPTY_CONTROL.tags).toEqual([])
|
||||
expect(EMPTY_CONTROL.requirements).toEqual([''])
|
||||
expect(EMPTY_CONTROL.test_procedure).toEqual([''])
|
||||
expect(EMPTY_CONTROL.evidence).toEqual([{ type: '', description: '' }])
|
||||
expect(EMPTY_CONTROL.open_anchors).toEqual([{ framework: '', ref: '', url: '' }])
|
||||
})
|
||||
})
|
||||
|
||||
describe('DOMAIN_OPTIONS', () => {
|
||||
it('contains expected domains', () => {
|
||||
const values = DOMAIN_OPTIONS.map(d => d.value)
|
||||
expect(values).toContain('AUTH')
|
||||
expect(values).toContain('NET')
|
||||
expect(values).toContain('CRYPT')
|
||||
expect(values).toContain('AI')
|
||||
expect(values).toContain('COMP')
|
||||
expect(values.length).toBe(10)
|
||||
})
|
||||
})
|
||||
|
||||
describe('COLLECTION_OPTIONS', () => {
|
||||
it('contains expected collections', () => {
|
||||
const values = COLLECTION_OPTIONS.map(c => c.value)
|
||||
expect(values).toContain('bp_compliance_ce')
|
||||
expect(values).toContain('bp_compliance_gesetze')
|
||||
expect(values).toContain('bp_compliance_datenschutz')
|
||||
expect(values.length).toBe(6)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,322 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
||||
import { render, screen, waitFor, fireEvent, act } from '@testing-library/react'
|
||||
import ControlLibraryPage from '../page'
|
||||
|
||||
// ============================================================================
|
||||
// Mock data
|
||||
// ============================================================================
|
||||
|
||||
const MOCK_FRAMEWORK = {
|
||||
id: 'fw-1',
|
||||
framework_id: 'bp_security_v1',
|
||||
name: 'BreakPilot Security',
|
||||
version: '1.0',
|
||||
description: 'Test framework',
|
||||
release_state: 'draft',
|
||||
}
|
||||
|
||||
const MOCK_CONTROL = {
|
||||
id: 'ctrl-1',
|
||||
framework_id: 'fw-1',
|
||||
control_id: 'AUTH-001',
|
||||
title: 'Multi-Factor Authentication',
|
||||
objective: 'Require MFA for all admin accounts.',
|
||||
rationale: 'Passwords alone are insufficient.',
|
||||
scope: {},
|
||||
requirements: ['MFA for admin'],
|
||||
test_procedure: ['Test admin login'],
|
||||
evidence: [{ type: 'config', description: 'MFA enabled' }],
|
||||
severity: 'high',
|
||||
risk_score: 4.0,
|
||||
implementation_effort: 'm',
|
||||
evidence_confidence: null,
|
||||
open_anchors: [{ framework: 'OWASP', ref: 'V2.8', url: 'https://owasp.org' }],
|
||||
release_state: 'draft',
|
||||
tags: ['mfa'],
|
||||
license_rule: 1,
|
||||
source_original_text: null,
|
||||
source_citation: { source: 'DSGVO' },
|
||||
customer_visible: true,
|
||||
verification_method: 'automated',
|
||||
category: 'authentication',
|
||||
target_audience: 'developer',
|
||||
generation_metadata: null,
|
||||
generation_strategy: 'ungrouped',
|
||||
created_at: '2026-03-15T10:00:00+00:00',
|
||||
updated_at: '2026-03-15T10:00:00+00:00',
|
||||
}
|
||||
|
||||
const MOCK_META = {
|
||||
total: 1,
|
||||
domains: [{ domain: 'AUTH', count: 1 }],
|
||||
sources: [{ source: 'DSGVO', count: 1 }],
|
||||
no_source_count: 0,
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Fetch mock
|
||||
// ============================================================================
|
||||
|
||||
function createFetchMock(overrides?: Record<string, unknown>) {
|
||||
const responses: Record<string, unknown> = {
|
||||
frameworks: [MOCK_FRAMEWORK],
|
||||
controls: [MOCK_CONTROL],
|
||||
'controls-count': { total: 1 },
|
||||
'controls-meta': MOCK_META,
|
||||
...overrides,
|
||||
}
|
||||
|
||||
return vi.fn((url: string) => {
|
||||
const urlStr = typeof url === 'string' ? url : ''
|
||||
// Match endpoint param
|
||||
const match = urlStr.match(/endpoint=([^&]+)/)
|
||||
const endpoint = match?.[1] || ''
|
||||
const data = responses[endpoint] ?? []
|
||||
|
||||
return Promise.resolve({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: () => Promise.resolve(data),
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Tests
|
||||
// ============================================================================
|
||||
|
||||
describe('ControlLibraryPage', () => {
|
||||
let fetchMock: ReturnType<typeof createFetchMock>
|
||||
|
||||
beforeEach(() => {
|
||||
fetchMock = createFetchMock()
|
||||
global.fetch = fetchMock as unknown as typeof fetch
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it('renders the page header', async () => {
|
||||
render(<ControlLibraryPage />)
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Canonical Control Library')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('shows control count from meta', async () => {
|
||||
fetchMock = createFetchMock({ 'controls-meta': { ...MOCK_META, total: 42 } })
|
||||
global.fetch = fetchMock as unknown as typeof fetch
|
||||
|
||||
render(<ControlLibraryPage />)
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/42 Security Controls/)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('renders control list with data', async () => {
|
||||
render(<ControlLibraryPage />)
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('AUTH-001')).toBeInTheDocument()
|
||||
expect(screen.getByText('Multi-Factor Authentication')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('shows timestamp on control cards', async () => {
|
||||
render(<ControlLibraryPage />)
|
||||
await waitFor(() => {
|
||||
// The date should be rendered in German locale format
|
||||
expect(screen.getByText(/15\.03\.26/)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('shows source citation on control cards', async () => {
|
||||
render(<ControlLibraryPage />)
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('DSGVO')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('fetches with limit and offset params', async () => {
|
||||
render(<ControlLibraryPage />)
|
||||
await waitFor(() => {
|
||||
expect(fetchMock).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
// Find the controls fetch call
|
||||
const controlsCalls = fetchMock.mock.calls.filter(
|
||||
(call: unknown[]) => typeof call[0] === 'string' && (call[0] as string).includes('endpoint=controls&')
|
||||
)
|
||||
expect(controlsCalls.length).toBeGreaterThan(0)
|
||||
|
||||
const url = controlsCalls[0][0] as string
|
||||
expect(url).toContain('limit=50')
|
||||
expect(url).toContain('offset=0')
|
||||
})
|
||||
|
||||
it('fetches controls-count alongside controls', async () => {
|
||||
render(<ControlLibraryPage />)
|
||||
await waitFor(() => {
|
||||
const countCalls = fetchMock.mock.calls.filter(
|
||||
(call: unknown[]) => typeof call[0] === 'string' && (call[0] as string).includes('endpoint=controls-count')
|
||||
)
|
||||
expect(countCalls.length).toBeGreaterThan(0)
|
||||
})
|
||||
})
|
||||
|
||||
it('fetches controls-meta on mount', async () => {
|
||||
render(<ControlLibraryPage />)
|
||||
await waitFor(() => {
|
||||
const metaCalls = fetchMock.mock.calls.filter(
|
||||
(call: unknown[]) => typeof call[0] === 'string' && (call[0] as string).includes('endpoint=controls-meta')
|
||||
)
|
||||
expect(metaCalls.length).toBeGreaterThan(0)
|
||||
})
|
||||
})
|
||||
|
||||
it('renders domain dropdown from meta', async () => {
|
||||
render(<ControlLibraryPage />)
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('AUTH (1)')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('renders source dropdown from meta', async () => {
|
||||
render(<ControlLibraryPage />)
|
||||
await waitFor(() => {
|
||||
// The source option should appear in the dropdown
|
||||
expect(screen.getByText('DSGVO (1)')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('has sort dropdown with all sort options', async () => {
|
||||
render(<ControlLibraryPage />)
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Sortierung: ID')).toBeInTheDocument()
|
||||
expect(screen.getByText('Nach Quelle')).toBeInTheDocument()
|
||||
expect(screen.getByText('Neueste zuerst')).toBeInTheDocument()
|
||||
expect(screen.getByText('Aelteste zuerst')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('sends sort params when sorting by newest', async () => {
|
||||
render(<ControlLibraryPage />)
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('AUTH-001')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
// Clear previous calls
|
||||
fetchMock.mockClear()
|
||||
|
||||
// Change sort to newest
|
||||
const sortSelect = screen.getByDisplayValue('Sortierung: ID')
|
||||
await act(async () => {
|
||||
fireEvent.change(sortSelect, { target: { value: 'newest' } })
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
const controlsCalls = fetchMock.mock.calls.filter(
|
||||
(call: unknown[]) => typeof call[0] === 'string' && (call[0] as string).includes('endpoint=controls&')
|
||||
)
|
||||
expect(controlsCalls.length).toBeGreaterThan(0)
|
||||
const url = controlsCalls[0][0] as string
|
||||
expect(url).toContain('sort=created_at')
|
||||
expect(url).toContain('order=desc')
|
||||
})
|
||||
})
|
||||
|
||||
it('sends search param after debounce', async () => {
|
||||
render(<ControlLibraryPage />)
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('AUTH-001')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
fetchMock.mockClear()
|
||||
|
||||
const searchInput = screen.getByPlaceholderText(/Controls durchsuchen/)
|
||||
await act(async () => {
|
||||
fireEvent.change(searchInput, { target: { value: 'encryption' } })
|
||||
})
|
||||
|
||||
// Wait for debounce (400ms)
|
||||
await waitFor(
|
||||
() => {
|
||||
const controlsCalls = fetchMock.mock.calls.filter(
|
||||
(call: unknown[]) => typeof call[0] === 'string' && (call[0] as string).includes('search=encryption')
|
||||
)
|
||||
expect(controlsCalls.length).toBeGreaterThan(0)
|
||||
},
|
||||
{ timeout: 1000 }
|
||||
)
|
||||
})
|
||||
|
||||
it('shows empty state when no controls', async () => {
|
||||
fetchMock = createFetchMock({
|
||||
controls: [],
|
||||
'controls-count': { total: 0 },
|
||||
'controls-meta': { ...MOCK_META, total: 0 },
|
||||
})
|
||||
global.fetch = fetchMock as unknown as typeof fetch
|
||||
|
||||
render(<ControlLibraryPage />)
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/Noch keine Controls/)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('shows "Keine Controls gefunden" when filter matches nothing', async () => {
|
||||
fetchMock = createFetchMock({
|
||||
controls: [],
|
||||
'controls-count': { total: 0 },
|
||||
'controls-meta': { ...MOCK_META, total: 50 },
|
||||
})
|
||||
global.fetch = fetchMock as unknown as typeof fetch
|
||||
|
||||
render(<ControlLibraryPage />)
|
||||
|
||||
// Wait for initial load to finish
|
||||
await waitFor(() => {
|
||||
expect(screen.getByPlaceholderText(/Controls durchsuchen/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
// Trigger a search to have a filter active
|
||||
const searchInput = screen.getByPlaceholderText(/Controls durchsuchen/)
|
||||
await act(async () => {
|
||||
fireEvent.change(searchInput, { target: { value: 'zzzzzzz' } })
|
||||
})
|
||||
|
||||
await waitFor(
|
||||
() => {
|
||||
expect(screen.getByText('Keine Controls gefunden.')).toBeInTheDocument()
|
||||
},
|
||||
{ timeout: 1000 }
|
||||
)
|
||||
})
|
||||
|
||||
it('has a refresh button', async () => {
|
||||
render(<ControlLibraryPage />)
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTitle('Aktualisieren')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('renders pagination info', async () => {
|
||||
render(<ControlLibraryPage />)
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/Seite 1 von 1/)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('shows pagination buttons for many controls', async () => {
|
||||
fetchMock = createFetchMock({
|
||||
'controls-count': { total: 150 },
|
||||
'controls-meta': { ...MOCK_META, total: 150 },
|
||||
})
|
||||
global.fetch = fetchMock as unknown as typeof fetch
|
||||
|
||||
render(<ControlLibraryPage />)
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/Seite 1 von 3/)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,878 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import {
|
||||
ArrowLeft, ExternalLink, BookOpen, Scale, FileText,
|
||||
Eye, CheckCircle2, Trash2, Pencil, Clock,
|
||||
ChevronLeft, SkipForward, GitMerge, Search, Landmark,
|
||||
} from 'lucide-react'
|
||||
import {
|
||||
CanonicalControl, EFFORT_LABELS, BACKEND_URL,
|
||||
SeverityBadge, StateBadge, LicenseRuleBadge, VerificationMethodBadge, CategoryBadge, EvidenceTypeBadge, TargetAudienceBadge,
|
||||
ObligationTypeBadge, GenerationStrategyBadge, isEigenentwicklung,
|
||||
ExtractionMethodBadge, RegulationCountBadge,
|
||||
VERIFICATION_METHODS, CATEGORY_OPTIONS, EVIDENCE_TYPE_OPTIONS,
|
||||
ObligationInfo, DocumentReference, MergedDuplicate, RegulationSummary,
|
||||
} from './helpers'
|
||||
|
||||
interface SimilarControl {
|
||||
control_id: string
|
||||
title: string
|
||||
severity: string
|
||||
release_state: string
|
||||
tags: string[]
|
||||
license_rule: number | null
|
||||
verification_method: string | null
|
||||
category: string | null
|
||||
similarity: number
|
||||
}
|
||||
|
||||
interface ParentLink {
|
||||
parent_control_id: string
|
||||
parent_title: string
|
||||
link_type: string
|
||||
confidence: number
|
||||
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 {
|
||||
control_id: string
|
||||
title: string
|
||||
is_atomic: boolean
|
||||
parent_links: ParentLink[]
|
||||
children: Array<{
|
||||
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 {
|
||||
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 ControlDetailProps {
|
||||
ctrl: CanonicalControl
|
||||
onBack: () => void
|
||||
onEdit: () => void
|
||||
onDelete: (controlId: string) => void
|
||||
onReview: (controlId: string, action: string) => void
|
||||
onRefresh?: () => void
|
||||
onNavigateToControl?: (controlId: string) => void
|
||||
onCompare?: (ctrl: CanonicalControl, matches: V1Match[]) => void
|
||||
// Review mode navigation
|
||||
reviewMode?: boolean
|
||||
reviewIndex?: number
|
||||
reviewTotal?: number
|
||||
onReviewPrev?: () => void
|
||||
onReviewNext?: () => void
|
||||
}
|
||||
|
||||
export function ControlDetail({
|
||||
ctrl,
|
||||
onBack,
|
||||
onEdit,
|
||||
onDelete,
|
||||
onReview,
|
||||
onRefresh,
|
||||
onNavigateToControl,
|
||||
onCompare,
|
||||
reviewMode,
|
||||
reviewIndex = 0,
|
||||
reviewTotal = 0,
|
||||
onReviewPrev,
|
||||
onReviewNext,
|
||||
}: ControlDetailProps) {
|
||||
const [similarControls, setSimilarControls] = useState<SimilarControl[]>([])
|
||||
const [loadingSimilar, setLoadingSimilar] = useState(false)
|
||||
const [selectedDuplicates, setSelectedDuplicates] = useState<Set<string>>(new Set())
|
||||
const [merging, setMerging] = useState(false)
|
||||
const [traceability, setTraceability] = useState<TraceabilityData | null>(null)
|
||||
const [loadingTrace, setLoadingTrace] = useState(false)
|
||||
const [v1Matches, setV1Matches] = useState<V1Match[]>([])
|
||||
const [loadingV1, setLoadingV1] = useState(false)
|
||||
const eigenentwicklung = isEigenentwicklung(ctrl)
|
||||
|
||||
const loadTraceability = useCallback(async () => {
|
||||
setLoadingTrace(true)
|
||||
try {
|
||||
// Try provenance first (extended data), fall back to traceability
|
||||
let res = await fetch(`${BACKEND_URL}?endpoint=provenance&id=${ctrl.control_id}`)
|
||||
if (!res.ok) {
|
||||
res = await fetch(`${BACKEND_URL}?endpoint=traceability&id=${ctrl.control_id}`)
|
||||
}
|
||||
if (res.ok) {
|
||||
setTraceability(await res.json())
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
finally { setLoadingTrace(false) }
|
||||
}, [ctrl.control_id])
|
||||
|
||||
const loadV1Matches = useCallback(async () => {
|
||||
if (!eigenentwicklung) { setV1Matches([]); return }
|
||||
setLoadingV1(true)
|
||||
try {
|
||||
const res = await fetch(`${BACKEND_URL}?endpoint=v1-matches&id=${ctrl.control_id}`)
|
||||
if (res.ok) setV1Matches(await res.json())
|
||||
else setV1Matches([])
|
||||
} catch { setV1Matches([]) }
|
||||
finally { setLoadingV1(false) }
|
||||
}, [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 () => {
|
||||
setLoadingSimilar(true)
|
||||
try {
|
||||
const res = await fetch(`${BACKEND_URL}?endpoint=similar&id=${ctrl.control_id}`)
|
||||
if (res.ok) {
|
||||
setSimilarControls(await res.json())
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
finally { setLoadingSimilar(false) }
|
||||
}
|
||||
|
||||
const toggleDuplicate = (controlId: string) => {
|
||||
setSelectedDuplicates(prev => {
|
||||
const next = new Set(prev)
|
||||
if (next.has(controlId)) next.delete(controlId)
|
||||
else next.add(controlId)
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
const handleMergeDuplicates = async () => {
|
||||
if (selectedDuplicates.size === 0) return
|
||||
if (!confirm(`${selectedDuplicates.size} Controls als Duplikate markieren und Tags/Anchors in ${ctrl.control_id} zusammenfuehren?`)) return
|
||||
|
||||
setMerging(true)
|
||||
try {
|
||||
// For each duplicate: mark as deprecated
|
||||
for (const dupId of selectedDuplicates) {
|
||||
await fetch(`${BACKEND_URL}?endpoint=update-control&id=${dupId}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ release_state: 'deprecated' }),
|
||||
})
|
||||
}
|
||||
// Refresh to show updated state
|
||||
if (onRefresh) onRefresh()
|
||||
setSelectedDuplicates(new Set())
|
||||
loadSimilarControls()
|
||||
} catch {
|
||||
alert('Fehler beim Zusammenfuehren')
|
||||
} finally {
|
||||
setMerging(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
{/* Header */}
|
||||
<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">
|
||||
<button onClick={onBack} className="text-gray-400 hover:text-gray-600">
|
||||
<ArrowLeft className="w-5 h-5" />
|
||||
</button>
|
||||
<div>
|
||||
<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>
|
||||
<SeverityBadge severity={ctrl.severity} />
|
||||
<StateBadge state={ctrl.release_state} />
|
||||
<LicenseRuleBadge rule={ctrl.license_rule} />
|
||||
<VerificationMethodBadge method={ctrl.verification_method} />
|
||||
<CategoryBadge category={ctrl.category} />
|
||||
<EvidenceTypeBadge type={ctrl.evidence_type} />
|
||||
<TargetAudienceBadge audience={ctrl.target_audience} />
|
||||
<GenerationStrategyBadge strategy={ctrl.generation_strategy} pipelineInfo={ctrl} />
|
||||
<ObligationTypeBadge type={ctrl.generation_metadata?.obligation_type as string} />
|
||||
</div>
|
||||
<h2 className="text-lg font-semibold text-gray-900 mt-1">{ctrl.title}</h2>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{reviewMode && (
|
||||
<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">
|
||||
<ChevronLeft className="w-4 h-4" />
|
||||
</button>
|
||||
<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">
|
||||
<SkipForward className="w-4 h-4" />
|
||||
</button>
|
||||
</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">
|
||||
<Pencil className="w-3.5 h-3.5 inline mr-1" />Bearbeiten
|
||||
</button>
|
||||
<button onClick={() => onDelete(ctrl.control_id)} className="px-3 py-1.5 text-sm text-red-600 border border-red-300 rounded-lg hover:bg-red-50">
|
||||
<Trash2 className="w-3.5 h-3.5 inline mr-1" />Loeschen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<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>
|
||||
|
||||
{/* Rationale */}
|
||||
<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 && (
|
||||
<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>
|
||||
)}
|
||||
|
||||
{/* Rechtsgrundlagen / Traceability (atomic controls) */}
|
||||
{traceability && traceability.parent_links.length > 0 && (
|
||||
<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 && (
|
||||
<section className="bg-amber-50 border border-amber-200 rounded-lg p-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Scale className="w-4 h-4 text-amber-600" />
|
||||
<div className="flex-1">
|
||||
<p className="text-xs text-amber-800 font-medium">Abgeleitet aus regulatorischen Anforderungen</p>
|
||||
<p className="text-xs text-amber-700 mt-0.5">
|
||||
Dieser Control wurde aus geschuetzten Quellen reformuliert (z.B. BSI Grundschutz, ISO 27001).
|
||||
Die konkreten Massnahmen leiten sich aus den Open-Source-Referenzen unten ab.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* Scope */}
|
||||
{(ctrl.scope.platforms?.length || ctrl.scope.components?.length || ctrl.scope.data_classes?.length) ? (
|
||||
<section>
|
||||
<h3 className="text-sm font-semibold text-gray-900 mb-2">Geltungsbereich</h3>
|
||||
<div className="grid grid-cols-3 gap-4 text-xs">
|
||||
{ctrl.scope.platforms?.length ? (
|
||||
<div><span className="text-gray-500">Plattformen:</span> <span className="text-gray-700">{ctrl.scope.platforms.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>
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
{/* Requirements */}
|
||||
{ctrl.requirements.length > 0 && (
|
||||
<section>
|
||||
<h3 className="text-sm font-semibold text-gray-900 mb-2">Anforderungen</h3>
|
||||
<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>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* Test Procedure */}
|
||||
{ctrl.test_procedure.length > 0 && (
|
||||
<section>
|
||||
<h3 className="text-sm font-semibold text-gray-900 mb-2">Pruefverfahren</h3>
|
||||
<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>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* Evidence — handles both {type, description} objects and plain strings */}
|
||||
{ctrl.evidence.length > 0 && (
|
||||
<section>
|
||||
<h3 className="text-sm font-semibold text-gray-900 mb-2">Nachweise</h3>
|
||||
<div className="space-y-2">
|
||||
{ctrl.evidence.map((ev, i) => (
|
||||
<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" />
|
||||
{typeof ev === 'string' ? (
|
||||
<div>{ev}</div>
|
||||
) : (
|
||||
<div><span className="font-medium">{ev.type}:</span> {ev.description}</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* Meta */}
|
||||
<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.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 && (
|
||||
<div className="col-span-3 flex items-center gap-1 flex-wrap">
|
||||
{ctrl.tags.map(t => (
|
||||
<span key={t} className="px-2 py-0.5 bg-gray-100 text-gray-600 rounded text-xs">{t}</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{/* Open Anchors */}
|
||||
<section className="bg-green-50 border border-green-200 rounded-lg p-4">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<BookOpen className="w-4 h-4 text-green-700" />
|
||||
<h3 className="text-sm font-semibold text-green-900">Open-Source-Referenzen ({ctrl.open_anchors.length})</h3>
|
||||
</div>
|
||||
{ctrl.open_anchors.length > 0 ? (
|
||||
<div className="space-y-2">
|
||||
{ctrl.open_anchors.map((anchor, i) => (
|
||||
<div key={i} className="flex items-center gap-2 text-sm">
|
||||
<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="text-green-700">{anchor.ref}</span>
|
||||
{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>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-green-600">Keine Referenzen vorhanden.</p>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{/* Generation Metadata (internal) */}
|
||||
{ctrl.generation_metadata && (
|
||||
<section className="bg-gray-50 border border-gray-200 rounded-lg p-4">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<Clock className="w-4 h-4 text-gray-500" />
|
||||
<h3 className="text-sm font-semibold text-gray-700">Generierungsdetails (intern)</h3>
|
||||
</div>
|
||||
<div className="text-xs text-gray-600 space-y-1">
|
||||
{ctrl.generation_metadata.processing_path && (
|
||||
<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.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) && (
|
||||
<div>
|
||||
<p className="font-medium">Aehnliche Controls:</p>
|
||||
{(ctrl.generation_metadata.similar_controls as Array<Record<string, unknown>>).map((s, i) => (
|
||||
<p key={i} className="ml-2">{String(s.control_id)} — {String(s.title)} ({String(s.similarity)})</p>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* Similar Controls (Dedup) */}
|
||||
<section className="bg-gray-50 border border-gray-200 rounded-lg p-4">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<Search className="w-4 h-4 text-gray-600" />
|
||||
<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 && (
|
||||
<button
|
||||
onClick={handleMergeDuplicates}
|
||||
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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,317 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { BookOpen, Trash2, Save, X } from 'lucide-react'
|
||||
import { EMPTY_CONTROL, VERIFICATION_METHODS, CATEGORY_OPTIONS, TARGET_AUDIENCE_OPTIONS } from './helpers'
|
||||
|
||||
export function ControlForm({
|
||||
initial,
|
||||
onSave,
|
||||
onCancel,
|
||||
saving,
|
||||
}: {
|
||||
initial: typeof EMPTY_CONTROL
|
||||
onSave: (data: typeof EMPTY_CONTROL) => void
|
||||
onCancel: () => void
|
||||
saving: boolean
|
||||
}) {
|
||||
const [form, setForm] = useState(initial)
|
||||
const [tagInput, setTagInput] = useState(initial.tags.join(', '))
|
||||
const [platformInput, setPlatformInput] = useState((initial.scope.platforms || []).join(', '))
|
||||
const [componentInput, setComponentInput] = useState((initial.scope.components || []).join(', '))
|
||||
const [dataClassInput, setDataClassInput] = useState((initial.scope.data_classes || []).join(', '))
|
||||
|
||||
const handleSave = () => {
|
||||
const data = {
|
||||
...form,
|
||||
tags: tagInput.split(',').map(t => t.trim()).filter(Boolean),
|
||||
scope: {
|
||||
platforms: platformInput.split(',').map(t => t.trim()).filter(Boolean),
|
||||
components: componentInput.split(',').map(t => t.trim()).filter(Boolean),
|
||||
data_classes: dataClassInput.split(',').map(t => t.trim()).filter(Boolean),
|
||||
},
|
||||
requirements: form.requirements.filter(r => r.trim()),
|
||||
test_procedure: form.test_procedure.filter(r => r.trim()),
|
||||
evidence: form.evidence.filter(e => e.type.trim() || e.description.trim()),
|
||||
open_anchors: form.open_anchors.filter(a => a.framework.trim() || a.ref.trim()),
|
||||
}
|
||||
onSave(data)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="max-w-4xl mx-auto p-6 space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-lg font-semibold text-gray-900">
|
||||
{initial.control_id ? `Control ${initial.control_id} bearbeiten` : 'Neues Control erstellen'}
|
||||
</h2>
|
||||
<div className="flex items-center gap-2">
|
||||
<button onClick={onCancel} className="px-3 py-1.5 text-sm text-gray-600 border border-gray-300 rounded-lg hover:bg-gray-50">
|
||||
<X className="w-4 h-4 inline mr-1" />Abbrechen
|
||||
</button>
|
||||
<button onClick={handleSave} disabled={saving} className="px-3 py-1.5 text-sm text-white bg-purple-600 rounded-lg hover:bg-purple-700 disabled:opacity-50">
|
||||
<Save className="w-4 h-4 inline mr-1" />{saving ? 'Speichern...' : 'Speichern'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Basic fields */}
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-600 mb-1">Control-ID *</label>
|
||||
<input
|
||||
value={form.control_id}
|
||||
onChange={e => setForm({ ...form, control_id: e.target.value.toUpperCase() })}
|
||||
placeholder="AUTH-003"
|
||||
disabled={!!initial.control_id}
|
||||
className="w-full px-3 py-2 text-sm border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:outline-none disabled:bg-gray-100"
|
||||
/>
|
||||
<p className="text-xs text-gray-400 mt-1">Format: DOMAIN-NNN (z.B. AUTH-003, NET-005)</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-600 mb-1">Titel *</label>
|
||||
<input
|
||||
value={form.title}
|
||||
onChange={e => setForm({ ...form, title: e.target.value })}
|
||||
placeholder="Control-Titel"
|
||||
className="w-full px-3 py-2 text-sm border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-600 mb-1">Schweregrad</label>
|
||||
<select value={form.severity} onChange={e => setForm({ ...form, severity: e.target.value })} className="w-full px-3 py-2 text-sm border border-gray-300 rounded-lg">
|
||||
<option value="low">Niedrig</option>
|
||||
<option value="medium">Mittel</option>
|
||||
<option value="high">Hoch</option>
|
||||
<option value="critical">Kritisch</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-600 mb-1">Risiko-Score (0-10)</label>
|
||||
<input
|
||||
type="number" min="0" max="10" step="0.5"
|
||||
value={form.risk_score ?? ''}
|
||||
onChange={e => setForm({ ...form, risk_score: e.target.value ? parseFloat(e.target.value) : null })}
|
||||
className="w-full px-3 py-2 text-sm border border-gray-300 rounded-lg"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-600 mb-1">Aufwand</label>
|
||||
<select value={form.implementation_effort || ''} onChange={e => setForm({ ...form, implementation_effort: e.target.value || null })} className="w-full px-3 py-2 text-sm border border-gray-300 rounded-lg">
|
||||
<option value="">-</option>
|
||||
<option value="s">Klein (S)</option>
|
||||
<option value="m">Mittel (M)</option>
|
||||
<option value="l">Gross (L)</option>
|
||||
<option value="xl">Sehr gross (XL)</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Objective & Rationale */}
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-600 mb-1">Ziel *</label>
|
||||
<textarea
|
||||
value={form.objective}
|
||||
onChange={e => setForm({ ...form, objective: e.target.value })}
|
||||
rows={3}
|
||||
className="w-full px-3 py-2 text-sm border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-600 mb-1">Begruendung *</label>
|
||||
<textarea
|
||||
value={form.rationale}
|
||||
onChange={e => setForm({ ...form, rationale: e.target.value })}
|
||||
rows={3}
|
||||
className="w-full px-3 py-2 text-sm border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Scope */}
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-600 mb-1">Plattformen (komma-getrennt)</label>
|
||||
<input value={platformInput} onChange={e => setPlatformInput(e.target.value)} placeholder="web, mobile, api" className="w-full px-3 py-2 text-sm border border-gray-300 rounded-lg" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-600 mb-1">Komponenten (komma-getrennt)</label>
|
||||
<input value={componentInput} onChange={e => setComponentInput(e.target.value)} placeholder="auth-service, gateway" className="w-full px-3 py-2 text-sm border border-gray-300 rounded-lg" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-600 mb-1">Datenklassen (komma-getrennt)</label>
|
||||
<input value={dataClassInput} onChange={e => setDataClassInput(e.target.value)} placeholder="credentials, tokens" className="w-full px-3 py-2 text-sm border border-gray-300 rounded-lg" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Requirements */}
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<label className="text-xs font-medium text-gray-600">Anforderungen</label>
|
||||
<button onClick={() => setForm({ ...form, requirements: [...form.requirements, ''] })} className="text-xs text-purple-600 hover:text-purple-800">+ Hinzufuegen</button>
|
||||
</div>
|
||||
{form.requirements.map((req, i) => (
|
||||
<div key={i} className="flex gap-2 mb-2">
|
||||
<span className="text-xs text-gray-400 mt-2.5">{i + 1}.</span>
|
||||
<input
|
||||
value={req}
|
||||
onChange={e => { const r = [...form.requirements]; r[i] = e.target.value; setForm({ ...form, requirements: r }) }}
|
||||
className="flex-1 px-3 py-2 text-sm border border-gray-300 rounded-lg"
|
||||
/>
|
||||
<button onClick={() => setForm({ ...form, requirements: form.requirements.filter((_, j) => j !== i) })} className="text-red-400 hover:text-red-600">
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Test Procedure */}
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<label className="text-xs font-medium text-gray-600">Pruefverfahren</label>
|
||||
<button onClick={() => setForm({ ...form, test_procedure: [...form.test_procedure, ''] })} className="text-xs text-purple-600 hover:text-purple-800">+ Hinzufuegen</button>
|
||||
</div>
|
||||
{form.test_procedure.map((step, i) => (
|
||||
<div key={i} className="flex gap-2 mb-2">
|
||||
<span className="text-xs text-gray-400 mt-2.5">{i + 1}.</span>
|
||||
<input
|
||||
value={step}
|
||||
onChange={e => { const t = [...form.test_procedure]; t[i] = e.target.value; setForm({ ...form, test_procedure: t }) }}
|
||||
className="flex-1 px-3 py-2 text-sm border border-gray-300 rounded-lg"
|
||||
/>
|
||||
<button onClick={() => setForm({ ...form, test_procedure: form.test_procedure.filter((_, j) => j !== i) })} className="text-red-400 hover:text-red-600">
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Evidence */}
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<label className="text-xs font-medium text-gray-600">Nachweisanforderungen</label>
|
||||
<button onClick={() => setForm({ ...form, evidence: [...form.evidence, { type: '', description: '' }] })} className="text-xs text-purple-600 hover:text-purple-800">+ Hinzufuegen</button>
|
||||
</div>
|
||||
{form.evidence.map((ev, i) => (
|
||||
<div key={i} className="flex gap-2 mb-2">
|
||||
<input
|
||||
value={ev.type}
|
||||
onChange={e => { const evs = [...form.evidence]; evs[i] = { ...evs[i], type: e.target.value }; setForm({ ...form, evidence: evs }) }}
|
||||
placeholder="Typ (z.B. config, test_result)"
|
||||
className="w-32 px-3 py-2 text-sm border border-gray-300 rounded-lg"
|
||||
/>
|
||||
<input
|
||||
value={ev.description}
|
||||
onChange={e => { const evs = [...form.evidence]; evs[i] = { ...evs[i], description: e.target.value }; setForm({ ...form, evidence: evs }) }}
|
||||
placeholder="Beschreibung"
|
||||
className="flex-1 px-3 py-2 text-sm border border-gray-300 rounded-lg"
|
||||
/>
|
||||
<button onClick={() => setForm({ ...form, evidence: form.evidence.filter((_, j) => j !== i) })} className="text-red-400 hover:text-red-600">
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Open Anchors */}
|
||||
<div className="bg-green-50 border border-green-200 rounded-lg p-4">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<BookOpen className="w-4 h-4 text-green-700" />
|
||||
<label className="text-xs font-semibold text-green-900">Open-Source-Referenzen *</label>
|
||||
</div>
|
||||
<button onClick={() => setForm({ ...form, open_anchors: [...form.open_anchors, { framework: '', ref: '', url: '' }] })} className="text-xs text-green-700 hover:text-green-900">+ Hinzufuegen</button>
|
||||
</div>
|
||||
<p className="text-xs text-green-600 mb-3">Jedes Control braucht mindestens eine offene Referenz (OWASP, NIST, ENISA, etc.)</p>
|
||||
{form.open_anchors.map((anchor, i) => (
|
||||
<div key={i} className="flex gap-2 mb-2">
|
||||
<input
|
||||
value={anchor.framework}
|
||||
onChange={e => { const a = [...form.open_anchors]; a[i] = { ...a[i], framework: e.target.value }; setForm({ ...form, open_anchors: a }) }}
|
||||
placeholder="Framework (z.B. OWASP ASVS)"
|
||||
className="w-40 px-3 py-2 text-sm border border-green-200 rounded-lg bg-white"
|
||||
/>
|
||||
<input
|
||||
value={anchor.ref}
|
||||
onChange={e => { const a = [...form.open_anchors]; a[i] = { ...a[i], ref: e.target.value }; setForm({ ...form, open_anchors: a }) }}
|
||||
placeholder="Referenz (z.B. V2.8)"
|
||||
className="w-48 px-3 py-2 text-sm border border-green-200 rounded-lg bg-white"
|
||||
/>
|
||||
<input
|
||||
value={anchor.url}
|
||||
onChange={e => { const a = [...form.open_anchors]; a[i] = { ...a[i], url: e.target.value }; setForm({ ...form, open_anchors: a }) }}
|
||||
placeholder="https://..."
|
||||
className="flex-1 px-3 py-2 text-sm border border-green-200 rounded-lg bg-white"
|
||||
/>
|
||||
<button onClick={() => setForm({ ...form, open_anchors: form.open_anchors.filter((_, j) => j !== i) })} className="text-red-400 hover:text-red-600">
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Tags & State */}
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-600 mb-1">Tags (komma-getrennt)</label>
|
||||
<input value={tagInput} onChange={e => setTagInput(e.target.value)} placeholder="mfa, auth, iam" className="w-full px-3 py-2 text-sm border border-gray-300 rounded-lg" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-600 mb-1">Status</label>
|
||||
<select value={form.release_state} onChange={e => setForm({ ...form, release_state: e.target.value })} className="w-full px-3 py-2 text-sm border border-gray-300 rounded-lg">
|
||||
<option value="draft">Draft</option>
|
||||
<option value="review">Review</option>
|
||||
<option value="approved">Approved</option>
|
||||
<option value="deprecated">Deprecated</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Verification Method, Category & Target Audience */}
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-600 mb-1">Nachweismethode</label>
|
||||
<select
|
||||
value={form.verification_method || ''}
|
||||
onChange={e => setForm({ ...form, verification_method: e.target.value || null })}
|
||||
className="w-full px-3 py-2 text-sm border border-gray-300 rounded-lg"
|
||||
>
|
||||
<option value="">— Nicht zugewiesen —</option>
|
||||
{Object.entries(VERIFICATION_METHODS).map(([k, v]) => (
|
||||
<option key={k} value={k}>{v.label}</option>
|
||||
))}
|
||||
</select>
|
||||
<p className="text-xs text-gray-400 mt-1">Wie wird dieses Control nachgewiesen?</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-600 mb-1">Kategorie</label>
|
||||
<select
|
||||
value={form.category || ''}
|
||||
onChange={e => setForm({ ...form, category: e.target.value || null })}
|
||||
className="w-full px-3 py-2 text-sm border border-gray-300 rounded-lg"
|
||||
>
|
||||
<option value="">— Nicht zugewiesen —</option>
|
||||
{CATEGORY_OPTIONS.map(c => (
|
||||
<option key={c.value} value={c.value}>{c.label}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-600 mb-1">Zielgruppe</label>
|
||||
<select
|
||||
value={form.target_audience || ''}
|
||||
onChange={e => setForm({ ...form, target_audience: e.target.value || null })}
|
||||
className="w-full px-3 py-2 text-sm border border-gray-300 rounded-lg"
|
||||
>
|
||||
<option value="">— Nicht zugewiesen —</option>
|
||||
{Object.entries(TARGET_AUDIENCE_OPTIONS).map(([k, v]) => (
|
||||
<option key={k} value={k}>{v.label}</option>
|
||||
))}
|
||||
</select>
|
||||
<p className="text-xs text-gray-400 mt-1">Fuer wen ist dieses Control relevant?</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,222 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { Zap, X, RefreshCw, History, CheckCircle2 } from 'lucide-react'
|
||||
import { BACKEND_URL, DOMAIN_OPTIONS, COLLECTION_OPTIONS } from './helpers'
|
||||
|
||||
interface GeneratorModalProps {
|
||||
onClose: () => void
|
||||
onComplete: () => void
|
||||
}
|
||||
|
||||
export function GeneratorModal({ onClose, onComplete }: GeneratorModalProps) {
|
||||
const [generating, setGenerating] = useState(false)
|
||||
const [genResult, setGenResult] = useState<Record<string, unknown> | null>(null)
|
||||
const [genDomain, setGenDomain] = useState('')
|
||||
const [genMaxControls, setGenMaxControls] = useState(10)
|
||||
const [genDryRun, setGenDryRun] = useState(true)
|
||||
const [genCollections, setGenCollections] = useState<string[]>([])
|
||||
const [showJobHistory, setShowJobHistory] = useState(false)
|
||||
const [jobHistory, setJobHistory] = useState<Array<Record<string, unknown>>>([])
|
||||
|
||||
const handleGenerate = async () => {
|
||||
setGenerating(true)
|
||||
setGenResult(null)
|
||||
try {
|
||||
const res = await fetch(`${BACKEND_URL}?endpoint=generate`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
domain: genDomain || null,
|
||||
collections: genCollections.length > 0 ? genCollections : null,
|
||||
max_controls: genMaxControls,
|
||||
dry_run: genDryRun,
|
||||
skip_web_search: false,
|
||||
}),
|
||||
})
|
||||
if (!res.ok) {
|
||||
const err = await res.json()
|
||||
setGenResult({ status: 'error', message: err.error || err.details || 'Fehler' })
|
||||
return
|
||||
}
|
||||
const data = await res.json()
|
||||
setGenResult(data)
|
||||
if (!genDryRun) {
|
||||
onComplete()
|
||||
}
|
||||
} catch {
|
||||
setGenResult({ status: 'error', message: 'Netzwerkfehler' })
|
||||
} finally {
|
||||
setGenerating(false)
|
||||
}
|
||||
}
|
||||
|
||||
const loadJobHistory = async () => {
|
||||
try {
|
||||
const res = await fetch(`${BACKEND_URL}?endpoint=generate-jobs`)
|
||||
if (res.ok) {
|
||||
const data = await res.json()
|
||||
setJobHistory(data.jobs || [])
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
|
||||
const toggleCollection = (col: string) => {
|
||||
setGenCollections(prev =>
|
||||
prev.includes(col) ? prev.filter(c => c !== col) : [...prev, col]
|
||||
)
|
||||
}
|
||||
|
||||
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 mx-4 max-h-[90vh] overflow-y-auto">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<Zap className="w-5 h-5 text-amber-600" />
|
||||
<h2 className="text-lg font-semibold text-gray-900">Control Generator</h2>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => { setShowJobHistory(!showJobHistory); if (!showJobHistory) loadJobHistory() }}
|
||||
className="text-gray-400 hover:text-gray-600"
|
||||
title="Job-Verlauf"
|
||||
>
|
||||
<History className="w-5 h-5" />
|
||||
</button>
|
||||
<button onClick={onClose} className="text-gray-400 hover:text-gray-600">
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{showJobHistory ? (
|
||||
<div className="space-y-3">
|
||||
<h3 className="text-sm font-medium text-gray-700">Letzte Generierungs-Jobs</h3>
|
||||
{jobHistory.length === 0 ? (
|
||||
<p className="text-sm text-gray-400">Keine Jobs vorhanden.</p>
|
||||
) : (
|
||||
<div className="space-y-2 max-h-80 overflow-y-auto">
|
||||
{jobHistory.map((job, i) => (
|
||||
<div key={i} className="border border-gray-200 rounded-lg p-3 text-xs">
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<span className={`px-2 py-0.5 rounded font-medium ${
|
||||
job.status === 'completed' ? 'bg-green-100 text-green-700' :
|
||||
job.status === 'failed' ? 'bg-red-100 text-red-700' :
|
||||
job.status === 'running' ? 'bg-blue-100 text-blue-700' :
|
||||
'bg-gray-100 text-gray-600'
|
||||
}`}>
|
||||
{String(job.status)}
|
||||
</span>
|
||||
<span className="text-gray-400">{String(job.created_at || '').slice(0, 16)}</span>
|
||||
</div>
|
||||
<div className="grid grid-cols-3 gap-1 text-gray-500 mt-1">
|
||||
<span>Chunks: {String(job.total_chunks_scanned || 0)}</span>
|
||||
<span>Generiert: {String(job.controls_generated || 0)}</span>
|
||||
<span>Verifiziert: {String(job.controls_verified || 0)}</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<button
|
||||
onClick={() => setShowJobHistory(false)}
|
||||
className="w-full py-2 text-sm text-gray-600 border border-gray-300 rounded-lg hover:bg-gray-50"
|
||||
>
|
||||
Zurueck zum Generator
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-600 mb-1">Domain (optional)</label>
|
||||
<select value={genDomain} onChange={e => setGenDomain(e.target.value)} className="w-full px-3 py-2 text-sm border border-gray-300 rounded-lg">
|
||||
<option value="">Alle Domains</option>
|
||||
{DOMAIN_OPTIONS.map(d => (
|
||||
<option key={d.value} value={d.value}>{d.label}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-600 mb-2">Collections (optional)</label>
|
||||
<div className="grid grid-cols-2 gap-1.5">
|
||||
{COLLECTION_OPTIONS.map(col => (
|
||||
<label key={col.value} className="flex items-center gap-2 text-xs text-gray-700 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={genCollections.includes(col.value)}
|
||||
onChange={() => toggleCollection(col.value)}
|
||||
className="rounded border-gray-300"
|
||||
/>
|
||||
{col.label}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
{genCollections.length === 0 && (
|
||||
<p className="text-xs text-gray-400 mt-1">Keine Auswahl = alle Collections</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-600 mb-1">Max. Controls: {genMaxControls}</label>
|
||||
<input
|
||||
type="range" min="1" max="100" step="1"
|
||||
value={genMaxControls}
|
||||
onChange={e => setGenMaxControls(parseInt(e.target.value))}
|
||||
className="w-full"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="dryRun"
|
||||
checked={genDryRun}
|
||||
onChange={e => setGenDryRun(e.target.checked)}
|
||||
className="rounded border-gray-300"
|
||||
/>
|
||||
<label htmlFor="dryRun" className="text-sm text-gray-700">Dry Run (Vorschau ohne Speicherung)</label>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={handleGenerate}
|
||||
disabled={generating}
|
||||
className="w-full py-2 text-sm text-white bg-amber-600 rounded-lg hover:bg-amber-700 disabled:opacity-50 flex items-center justify-center gap-2"
|
||||
>
|
||||
{generating ? (
|
||||
<><RefreshCw className="w-4 h-4 animate-spin" /> Generiere...</>
|
||||
) : (
|
||||
<><Zap className="w-4 h-4" /> Generierung starten</>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{/* Results */}
|
||||
{genResult && (
|
||||
<div className={`p-4 rounded-lg text-sm ${genResult.status === 'error' ? 'bg-red-50 text-red-800' : 'bg-green-50 text-green-800'}`}>
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
{genResult.status !== 'error' && <CheckCircle2 className="w-4 h-4" />}
|
||||
<p className="font-medium">{String(genResult.message || genResult.status)}</p>
|
||||
</div>
|
||||
{genResult.status !== 'error' && (
|
||||
<div className="grid grid-cols-2 gap-1 text-xs mt-2">
|
||||
<span>Chunks gescannt: {String(genResult.total_chunks_scanned)}</span>
|
||||
<span>Controls generiert: {String(genResult.controls_generated)}</span>
|
||||
<span>Verifiziert: {String(genResult.controls_verified)}</span>
|
||||
<span>Review noetig: {String(genResult.controls_needs_review)}</span>
|
||||
<span>Zu aehnlich: {String(genResult.controls_too_close)}</span>
|
||||
<span>Duplikate: {String(genResult.controls_duplicates_found)}</span>
|
||||
</div>
|
||||
)}
|
||||
{Array.isArray(genResult.errors) && (genResult.errors as string[]).length > 0 && (
|
||||
<div className="mt-2 text-xs text-red-600">
|
||||
{(genResult.errors as string[]).slice(0, 3).map((e, i) => <p key={i}>{e}</p>)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,264 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect } from 'react'
|
||||
import {
|
||||
ArrowLeft, CheckCircle2, Trash2, Pencil, SkipForward,
|
||||
ChevronLeft, Scale, BookOpen, ExternalLink, AlertTriangle,
|
||||
FileText, Clock,
|
||||
} from 'lucide-react'
|
||||
import {
|
||||
CanonicalControl, BACKEND_URL,
|
||||
SeverityBadge, StateBadge, LicenseRuleBadge, CategoryBadge, TargetAudienceBadge,
|
||||
} from './helpers'
|
||||
|
||||
// =============================================================================
|
||||
// Compact Control Panel (used on both sides of the comparison)
|
||||
// =============================================================================
|
||||
|
||||
export function ControlPanel({ ctrl, label, highlight }: { ctrl: CanonicalControl; label: string; highlight?: boolean }) {
|
||||
return (
|
||||
<div className={`flex flex-col h-full overflow-y-auto ${highlight ? 'bg-yellow-50' : 'bg-white'}`}>
|
||||
{/* Panel Header */}
|
||||
<div className={`sticky top-0 z-10 px-4 py-3 border-b ${highlight ? 'bg-yellow-100 border-yellow-200' : 'bg-gray-50 border-gray-200'}`}>
|
||||
<div className="text-xs font-semibold uppercase tracking-wide text-gray-500 mb-1">{label}</div>
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span className="text-sm font-mono text-purple-600 bg-purple-50 px-2 py-0.5 rounded">{ctrl.control_id}</span>
|
||||
<SeverityBadge severity={ctrl.severity} />
|
||||
<StateBadge state={ctrl.release_state} />
|
||||
<LicenseRuleBadge rule={ctrl.license_rule} />
|
||||
<CategoryBadge category={ctrl.category} />
|
||||
<TargetAudienceBadge audience={ctrl.target_audience} />
|
||||
</div>
|
||||
<h3 className="text-sm font-semibold text-gray-900 mt-1 leading-snug">{ctrl.title}</h3>
|
||||
</div>
|
||||
|
||||
{/* Panel Content */}
|
||||
<div className="p-4 space-y-4 text-sm">
|
||||
{/* Objective */}
|
||||
<section>
|
||||
<h4 className="text-xs font-semibold text-gray-500 uppercase tracking-wide mb-1">Ziel</h4>
|
||||
<p className="text-gray-700 leading-relaxed">{ctrl.objective}</p>
|
||||
</section>
|
||||
|
||||
{/* Rationale */}
|
||||
{ctrl.rationale && (
|
||||
<section>
|
||||
<h4 className="text-xs font-semibold text-gray-500 uppercase tracking-wide mb-1">Begruendung</h4>
|
||||
<p className="text-gray-700 leading-relaxed">{ctrl.rationale}</p>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* Source Citation (Rule 1+2) */}
|
||||
{ctrl.source_citation && (
|
||||
<section className="bg-blue-50 border border-blue-200 rounded-lg p-3">
|
||||
<div className="flex items-center gap-1.5 mb-1">
|
||||
<Scale className="w-3.5 h-3.5 text-blue-600" />
|
||||
<span className="text-xs font-semibold text-blue-900">Gesetzliche Grundlage</span>
|
||||
</div>
|
||||
{ctrl.source_citation.source && (
|
||||
<p className="text-xs text-blue-800">
|
||||
{ctrl.source_citation.source}
|
||||
{ctrl.source_citation.article && ` — ${ctrl.source_citation.article}`}
|
||||
{ctrl.source_citation.paragraph && ` ${ctrl.source_citation.paragraph}`}
|
||||
</p>
|
||||
)}
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* Requirements */}
|
||||
{ctrl.requirements.length > 0 && (
|
||||
<section>
|
||||
<h4 className="text-xs font-semibold text-gray-500 uppercase tracking-wide mb-1">Anforderungen</h4>
|
||||
<ol className="list-decimal list-inside space-y-1">
|
||||
{ctrl.requirements.map((r, i) => (
|
||||
<li key={i} className="text-gray-700 text-xs leading-relaxed">{r}</li>
|
||||
))}
|
||||
</ol>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* Test Procedure */}
|
||||
{ctrl.test_procedure.length > 0 && (
|
||||
<section>
|
||||
<h4 className="text-xs font-semibold text-gray-500 uppercase tracking-wide mb-1">Pruefverfahren</h4>
|
||||
<ol className="list-decimal list-inside space-y-1">
|
||||
{ctrl.test_procedure.map((s, i) => (
|
||||
<li key={i} className="text-gray-700 text-xs leading-relaxed">{s}</li>
|
||||
))}
|
||||
</ol>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* Open Anchors */}
|
||||
{ctrl.open_anchors.length > 0 && (
|
||||
<section className="bg-green-50 border border-green-200 rounded-lg p-3">
|
||||
<div className="flex items-center gap-1.5 mb-2">
|
||||
<BookOpen className="w-3.5 h-3.5 text-green-700" />
|
||||
<span className="text-xs font-semibold text-green-900">Referenzen ({ctrl.open_anchors.length})</span>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
{ctrl.open_anchors.map((a, i) => (
|
||||
<div key={i} className="flex items-center gap-1.5 text-xs">
|
||||
<ExternalLink className="w-3 h-3 text-green-600 flex-shrink-0" />
|
||||
<span className="font-medium text-green-800">{a.framework}</span>
|
||||
<span className="text-green-700">{a.ref}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* Tags */}
|
||||
{ctrl.tags.length > 0 && (
|
||||
<div className="flex items-center gap-1 flex-wrap">
|
||||
{ctrl.tags.map(t => (
|
||||
<span key={t} className="px-2 py-0.5 bg-gray-100 text-gray-600 rounded text-xs">{t}</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// ReviewCompare — Side-by-Side Duplicate Comparison
|
||||
// =============================================================================
|
||||
|
||||
interface ReviewCompareProps {
|
||||
ctrl: CanonicalControl
|
||||
onBack: () => void
|
||||
onReview: (controlId: string, action: string) => void
|
||||
onEdit: () => void
|
||||
reviewIndex: number
|
||||
reviewTotal: number
|
||||
onReviewPrev: () => void
|
||||
onReviewNext: () => void
|
||||
}
|
||||
|
||||
export function ReviewCompare({
|
||||
ctrl,
|
||||
onBack,
|
||||
onReview,
|
||||
onEdit,
|
||||
reviewIndex,
|
||||
reviewTotal,
|
||||
onReviewPrev,
|
||||
onReviewNext,
|
||||
}: ReviewCompareProps) {
|
||||
const [suspectedDuplicate, setSuspectedDuplicate] = useState<CanonicalControl | null>(null)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [similarity, setSimilarity] = useState<number | null>(null)
|
||||
|
||||
// Load the suspected duplicate from generation_metadata.similar_controls
|
||||
useEffect(() => {
|
||||
const loadDuplicate = async () => {
|
||||
const similarControls = ctrl.generation_metadata?.similar_controls as Array<{ control_id: string; title: string; similarity: number }> | undefined
|
||||
if (!similarControls || similarControls.length === 0) {
|
||||
setSuspectedDuplicate(null)
|
||||
setSimilarity(null)
|
||||
return
|
||||
}
|
||||
|
||||
const suspect = similarControls[0]
|
||||
setSimilarity(suspect.similarity)
|
||||
setLoading(true)
|
||||
|
||||
try {
|
||||
const res = await fetch(`${BACKEND_URL}?endpoint=control&id=${encodeURIComponent(suspect.control_id)}`)
|
||||
if (res.ok) {
|
||||
const data = await res.json()
|
||||
setSuspectedDuplicate(data)
|
||||
} else {
|
||||
setSuspectedDuplicate(null)
|
||||
}
|
||||
} catch {
|
||||
setSuspectedDuplicate(null)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
loadDuplicate()
|
||||
}, [ctrl.control_id, ctrl.generation_metadata])
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
{/* Header */}
|
||||
<div className="border-b border-gray-200 bg-white px-6 py-3 flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<button onClick={onBack} className="text-gray-400 hover:text-gray-600">
|
||||
<ArrowLeft className="w-5 h-5" />
|
||||
</button>
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<AlertTriangle className="w-4 h-4 text-amber-500" />
|
||||
<span className="text-sm font-semibold text-gray-900">Duplikat-Vergleich</span>
|
||||
{similarity !== null && (
|
||||
<span className="text-xs font-medium text-amber-600 bg-amber-50 px-2 py-0.5 rounded-full">
|
||||
{(similarity * 100).toFixed(1)}% Aehnlichkeit
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
{/* Navigation */}
|
||||
<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">
|
||||
<ChevronLeft className="w-4 h-4" />
|
||||
</button>
|
||||
<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">
|
||||
<SkipForward className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<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" />Behalten
|
||||
</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" />Duplikat
|
||||
</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" />Bearbeiten
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Side-by-Side Panels */}
|
||||
<div className="flex-1 flex overflow-hidden">
|
||||
{/* Left: Control to review */}
|
||||
<div className="w-1/2 border-r border-gray-200 overflow-y-auto">
|
||||
<ControlPanel ctrl={ctrl} label="Zu pruefen" highlight />
|
||||
</div>
|
||||
|
||||
{/* Right: Suspected duplicate */}
|
||||
<div className="w-1/2 overflow-y-auto">
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center h-full">
|
||||
<div className="animate-spin rounded-full h-6 w-6 border-2 border-purple-600 border-t-transparent" />
|
||||
</div>
|
||||
) : suspectedDuplicate ? (
|
||||
<ControlPanel ctrl={suspectedDuplicate} label="Bestehendes Control (Verdacht)" />
|
||||
) : (
|
||||
<div className="flex items-center justify-center h-full text-gray-400 text-sm">
|
||||
Kein Duplikat-Kandidat gefunden
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect } from 'react'
|
||||
import {
|
||||
ArrowLeft, ChevronLeft, SkipForward, Scale,
|
||||
} from 'lucide-react'
|
||||
import { CanonicalControl, BACKEND_URL } from './helpers'
|
||||
import { ControlPanel } from './ReviewCompare'
|
||||
|
||||
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 V1CompareViewProps {
|
||||
v1Control: CanonicalControl
|
||||
matches: V1Match[]
|
||||
onBack: () => void
|
||||
onNavigateToControl?: (controlId: string) => void
|
||||
}
|
||||
|
||||
export function V1CompareView({ v1Control, matches, onBack, onNavigateToControl }: V1CompareViewProps) {
|
||||
const [currentMatchIndex, setCurrentMatchIndex] = useState(0)
|
||||
const [matchedControl, setMatchedControl] = useState<CanonicalControl | null>(null)
|
||||
const [loading, setLoading] = useState(false)
|
||||
|
||||
const currentMatch = matches[currentMatchIndex]
|
||||
|
||||
// Load the full matched control when index changes
|
||||
useEffect(() => {
|
||||
if (!currentMatch) return
|
||||
const load = async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const res = await fetch(`${BACKEND_URL}?endpoint=control&id=${encodeURIComponent(currentMatch.matched_control_id)}`)
|
||||
if (res.ok) {
|
||||
setMatchedControl(await res.json())
|
||||
} else {
|
||||
setMatchedControl(null)
|
||||
}
|
||||
} catch {
|
||||
setMatchedControl(null)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
load()
|
||||
}, [currentMatch])
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
{/* Header */}
|
||||
<div className="border-b border-gray-200 bg-white px-6 py-3 flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<button onClick={onBack} className="text-gray-400 hover:text-gray-600">
|
||||
<ArrowLeft className="w-5 h-5" />
|
||||
</button>
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Scale className="w-4 h-4 text-orange-500" />
|
||||
<span className="text-sm font-semibold text-gray-900">V1-Vergleich</span>
|
||||
{currentMatch && (
|
||||
<span className={`text-xs font-medium px-2 py-0.5 rounded-full ${
|
||||
currentMatch.similarity_score >= 0.85 ? 'bg-green-100 text-green-700' :
|
||||
currentMatch.similarity_score >= 0.80 ? 'bg-yellow-100 text-yellow-700' :
|
||||
'bg-gray-100 text-gray-600'
|
||||
}`}>
|
||||
{(currentMatch.similarity_score * 100).toFixed(1)}% Aehnlichkeit
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
{/* Navigation */}
|
||||
<div className="flex items-center gap-1">
|
||||
<button
|
||||
onClick={() => setCurrentMatchIndex(Math.max(0, currentMatchIndex - 1))}
|
||||
disabled={currentMatchIndex === 0}
|
||||
className="p-1 text-gray-400 hover:text-gray-600 disabled:opacity-30"
|
||||
>
|
||||
<ChevronLeft className="w-4 h-4" />
|
||||
</button>
|
||||
<span className="text-xs text-gray-500 font-medium">
|
||||
{currentMatchIndex + 1} / {matches.length}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => setCurrentMatchIndex(Math.min(matches.length - 1, currentMatchIndex + 1))}
|
||||
disabled={currentMatchIndex >= matches.length - 1}
|
||||
className="p-1 text-gray-400 hover:text-gray-600 disabled:opacity-30"
|
||||
>
|
||||
<SkipForward className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Navigate to matched control */}
|
||||
{onNavigateToControl && matchedControl && (
|
||||
<button
|
||||
onClick={() => { onBack(); onNavigateToControl(matchedControl.control_id) }}
|
||||
className="px-3 py-1.5 text-sm text-purple-600 border border-purple-300 rounded-lg hover:bg-purple-50"
|
||||
>
|
||||
Zum Control
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Source info bar */}
|
||||
{currentMatch && (currentMatch.matched_source || currentMatch.matched_article) && (
|
||||
<div className="px-6 py-2 bg-blue-50 border-b border-blue-200 flex items-center gap-2 text-sm">
|
||||
<Scale className="w-3.5 h-3.5 text-blue-600" />
|
||||
{currentMatch.matched_source && (
|
||||
<span className="font-semibold text-blue-900">{currentMatch.matched_source}</span>
|
||||
)}
|
||||
{currentMatch.matched_article && (
|
||||
<span className="text-blue-700">{currentMatch.matched_article}</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Side-by-Side Panels */}
|
||||
<div className="flex-1 flex overflow-hidden">
|
||||
{/* Left: V1 Eigenentwicklung */}
|
||||
<div className="w-1/2 border-r border-gray-200 overflow-y-auto">
|
||||
<ControlPanel ctrl={v1Control} label="Eigenentwicklung" highlight />
|
||||
</div>
|
||||
|
||||
{/* Right: Regulatory match */}
|
||||
<div className="w-1/2 overflow-y-auto">
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center h-full">
|
||||
<div className="animate-spin rounded-full h-6 w-6 border-2 border-purple-600 border-t-transparent" />
|
||||
</div>
|
||||
) : matchedControl ? (
|
||||
<ControlPanel ctrl={matchedControl} label="Regulatorisch gedeckt" />
|
||||
) : (
|
||||
<div className="flex items-center justify-center h-full text-gray-400 text-sm">
|
||||
Control konnte nicht geladen werden
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,408 @@
|
||||
import { AlertTriangle, CheckCircle2, Info } from 'lucide-react'
|
||||
import React from 'react'
|
||||
|
||||
// =============================================================================
|
||||
// TYPES
|
||||
// =============================================================================
|
||||
|
||||
export interface OpenAnchor {
|
||||
framework: string
|
||||
ref: string
|
||||
url: string
|
||||
}
|
||||
|
||||
export interface EvidenceItem {
|
||||
type: string
|
||||
description: string
|
||||
}
|
||||
|
||||
export interface CanonicalControl {
|
||||
id: string
|
||||
framework_id: string
|
||||
control_id: string
|
||||
title: string
|
||||
objective: string
|
||||
rationale: string
|
||||
scope: {
|
||||
platforms?: string[]
|
||||
components?: string[]
|
||||
data_classes?: string[]
|
||||
}
|
||||
requirements: string[]
|
||||
test_procedure: string[]
|
||||
evidence: (EvidenceItem | string)[]
|
||||
severity: string
|
||||
risk_score: number | null
|
||||
implementation_effort: string | null
|
||||
evidence_confidence: number | null
|
||||
open_anchors: OpenAnchor[]
|
||||
release_state: string
|
||||
tags: string[]
|
||||
license_rule?: number | null
|
||||
source_original_text?: string | null
|
||||
source_citation?: Record<string, string> | null
|
||||
customer_visible?: boolean
|
||||
verification_method: string | null
|
||||
category: string | null
|
||||
evidence_type: string | null
|
||||
target_audience: string | string[] | null
|
||||
generation_metadata?: Record<string, unknown> | null
|
||||
generation_strategy?: string | null
|
||||
parent_control_uuid?: string | null
|
||||
parent_control_id?: string | null
|
||||
parent_control_title?: string | null
|
||||
decomposition_method?: string | null
|
||||
pipeline_version?: number | string | null
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
export interface Framework {
|
||||
id: string
|
||||
framework_id: string
|
||||
name: string
|
||||
version: string
|
||||
description: string
|
||||
release_state: string
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// CONSTANTS
|
||||
// =============================================================================
|
||||
|
||||
export const BACKEND_URL = '/api/sdk/v1/canonical'
|
||||
|
||||
export const SEVERITY_CONFIG: Record<string, { bg: string; label: string; icon: React.ComponentType<{ className?: string }> }> = {
|
||||
critical: { bg: 'bg-red-100 text-red-800', label: 'Kritisch', icon: AlertTriangle },
|
||||
high: { bg: 'bg-orange-100 text-orange-800', label: 'Hoch', icon: AlertTriangle },
|
||||
medium: { bg: 'bg-yellow-100 text-yellow-800', label: 'Mittel', icon: Info },
|
||||
low: { bg: 'bg-green-100 text-green-800', label: 'Niedrig', icon: CheckCircle2 },
|
||||
}
|
||||
|
||||
export const EFFORT_LABELS: Record<string, string> = {
|
||||
s: 'Klein (S)',
|
||||
m: 'Mittel (M)',
|
||||
l: 'Gross (L)',
|
||||
xl: 'Sehr gross (XL)',
|
||||
}
|
||||
|
||||
export const EMPTY_CONTROL = {
|
||||
framework_id: 'bp_security_v1',
|
||||
control_id: '',
|
||||
title: '',
|
||||
objective: '',
|
||||
rationale: '',
|
||||
scope: { platforms: [] as string[], components: [] as string[], data_classes: [] as string[] },
|
||||
requirements: [''],
|
||||
test_procedure: [''],
|
||||
evidence: [{ type: '', description: '' }],
|
||||
severity: 'medium',
|
||||
risk_score: null as number | null,
|
||||
implementation_effort: 'm' as string | null,
|
||||
open_anchors: [{ framework: '', ref: '', url: '' }],
|
||||
release_state: 'draft',
|
||||
tags: [] as string[],
|
||||
verification_method: null as string | null,
|
||||
category: null as string | null,
|
||||
evidence_type: null as string | null,
|
||||
target_audience: null as string | null,
|
||||
}
|
||||
|
||||
export const DOMAIN_OPTIONS = [
|
||||
{ value: 'AUTH', label: 'AUTH — Authentifizierung' },
|
||||
{ value: 'CRYPT', label: 'CRYPT — Kryptographie' },
|
||||
{ value: 'NET', label: 'NET — Netzwerk' },
|
||||
{ value: 'DATA', label: 'DATA — Datenschutz' },
|
||||
{ value: 'LOG', label: 'LOG — Logging' },
|
||||
{ value: 'ACC', label: 'ACC — Zugriffskontrolle' },
|
||||
{ value: 'SEC', label: 'SEC — Sicherheit' },
|
||||
{ value: 'INC', label: 'INC — Incident Response' },
|
||||
{ value: 'AI', label: 'AI — Kuenstliche Intelligenz' },
|
||||
{ value: 'COMP', label: 'COMP — Compliance' },
|
||||
]
|
||||
|
||||
export const VERIFICATION_METHODS: Record<string, { bg: string; label: string }> = {
|
||||
code_review: { bg: 'bg-blue-100 text-blue-700', label: 'Code Review' },
|
||||
document: { bg: 'bg-amber-100 text-amber-700', label: 'Dokument' },
|
||||
tool: { bg: 'bg-teal-100 text-teal-700', label: 'Tool' },
|
||||
hybrid: { bg: 'bg-purple-100 text-purple-700', label: 'Hybrid' },
|
||||
}
|
||||
|
||||
export const CATEGORY_OPTIONS = [
|
||||
{ value: 'encryption', label: 'Verschluesselung & Kryptographie' },
|
||||
{ value: 'authentication', label: 'Authentisierung & Zugriffskontrolle' },
|
||||
{ value: 'network', label: 'Netzwerksicherheit' },
|
||||
{ value: 'data_protection', label: 'Datenschutz & Datensicherheit' },
|
||||
{ value: 'logging', label: 'Logging & Monitoring' },
|
||||
{ value: 'incident', label: 'Vorfallmanagement' },
|
||||
{ value: 'continuity', label: 'Notfall & Wiederherstellung' },
|
||||
{ value: 'compliance', label: 'Compliance & Audit' },
|
||||
{ value: 'supply_chain', label: 'Lieferkettenmanagement' },
|
||||
{ value: 'physical', label: 'Physische Sicherheit' },
|
||||
{ value: 'personnel', label: 'Personal & Schulung' },
|
||||
{ value: 'application', label: 'Anwendungssicherheit' },
|
||||
{ value: 'system', label: 'Systemhaertung & -betrieb' },
|
||||
{ value: 'risk', label: 'Risikomanagement' },
|
||||
{ value: 'governance', label: 'Sicherheitsorganisation' },
|
||||
{ value: 'hardware', label: 'Hardware & Plattformsicherheit' },
|
||||
{ value: 'identity', label: 'Identitaetsmanagement' },
|
||||
]
|
||||
|
||||
export const EVIDENCE_TYPE_CONFIG: Record<string, { bg: string; label: string }> = {
|
||||
code: { bg: 'bg-sky-100 text-sky-700', label: 'Code' },
|
||||
process: { bg: 'bg-amber-100 text-amber-700', label: 'Prozess' },
|
||||
hybrid: { bg: 'bg-violet-100 text-violet-700', label: 'Hybrid' },
|
||||
}
|
||||
|
||||
export const EVIDENCE_TYPE_OPTIONS = [
|
||||
{ value: 'code', label: 'Code — Technisch (Source Code, IaC, CI/CD)' },
|
||||
{ value: 'process', label: 'Prozess — Organisatorisch (Dokumente, Policies)' },
|
||||
{ value: 'hybrid', label: 'Hybrid — Code + Prozess' },
|
||||
]
|
||||
|
||||
export const TARGET_AUDIENCE_OPTIONS: Record<string, { bg: string; label: string }> = {
|
||||
// Legacy English keys
|
||||
enterprise: { bg: 'bg-cyan-100 text-cyan-700', label: 'Unternehmen' },
|
||||
authority: { bg: 'bg-rose-100 text-rose-700', label: 'Behoerden' },
|
||||
provider: { bg: 'bg-violet-100 text-violet-700', label: 'Anbieter' },
|
||||
all: { bg: 'bg-gray-100 text-gray-700', label: 'Alle' },
|
||||
// German keys from LLM generation
|
||||
unternehmen: { bg: 'bg-cyan-100 text-cyan-700', label: 'Unternehmen' },
|
||||
behoerden: { bg: 'bg-rose-100 text-rose-700', label: 'Behoerden' },
|
||||
entwickler: { bg: 'bg-sky-100 text-sky-700', label: 'Entwickler' },
|
||||
datenschutzbeauftragte: { bg: 'bg-purple-100 text-purple-700', label: 'DSB' },
|
||||
geschaeftsfuehrung: { bg: 'bg-amber-100 text-amber-700', label: 'GF' },
|
||||
'it-abteilung': { bg: 'bg-blue-100 text-blue-700', label: 'IT' },
|
||||
rechtsabteilung: { bg: 'bg-fuchsia-100 text-fuchsia-700', label: 'Recht' },
|
||||
'compliance-officer': { bg: 'bg-indigo-100 text-indigo-700', label: 'Compliance' },
|
||||
personalwesen: { bg: 'bg-pink-100 text-pink-700', label: 'Personal' },
|
||||
einkauf: { bg: 'bg-lime-100 text-lime-700', label: 'Einkauf' },
|
||||
produktion: { bg: 'bg-orange-100 text-orange-700', label: 'Produktion' },
|
||||
vertrieb: { bg: 'bg-teal-100 text-teal-700', label: 'Vertrieb' },
|
||||
gesundheitswesen: { bg: 'bg-red-100 text-red-700', label: 'Gesundheit' },
|
||||
finanzwesen: { bg: 'bg-emerald-100 text-emerald-700', label: 'Finanzen' },
|
||||
oeffentlicher_dienst: { bg: 'bg-rose-100 text-rose-700', label: 'Oeffentl. Dienst' },
|
||||
}
|
||||
|
||||
export const COLLECTION_OPTIONS = [
|
||||
{ value: 'bp_compliance_ce', label: 'CE (OWASP, ENISA, BSI)' },
|
||||
{ value: 'bp_compliance_gesetze', label: 'Gesetze (EU, DE, BSI)' },
|
||||
{ value: 'bp_compliance_datenschutz', label: 'Datenschutz' },
|
||||
{ value: 'bp_compliance_recht', label: 'Recht' },
|
||||
{ value: 'bp_dsfa_corpus', label: 'DSFA Corpus' },
|
||||
{ value: 'bp_legal_templates', label: 'Legal Templates' },
|
||||
]
|
||||
|
||||
// =============================================================================
|
||||
// BADGE COMPONENTS
|
||||
// =============================================================================
|
||||
|
||||
export function SeverityBadge({ severity }: { severity: string }) {
|
||||
const config = SEVERITY_CONFIG[severity] || SEVERITY_CONFIG.medium
|
||||
const Icon = config.icon
|
||||
return (
|
||||
<span className={`inline-flex items-center gap-1 px-2 py-0.5 rounded text-xs font-medium ${config.bg}`}>
|
||||
<Icon className="w-3 h-3" />
|
||||
{config.label}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
export function StateBadge({ state }: { state: string }) {
|
||||
const config: Record<string, string> = {
|
||||
draft: 'bg-gray-100 text-gray-600',
|
||||
review: 'bg-blue-100 text-blue-700',
|
||||
approved: 'bg-green-100 text-green-700',
|
||||
deprecated: 'bg-red-100 text-red-600',
|
||||
needs_review: 'bg-yellow-100 text-yellow-800',
|
||||
too_close: 'bg-red-100 text-red-700',
|
||||
duplicate: 'bg-orange-100 text-orange-700',
|
||||
}
|
||||
const labels: Record<string, string> = {
|
||||
needs_review: 'Review noetig',
|
||||
too_close: 'Zu aehnlich',
|
||||
duplicate: 'Duplikat',
|
||||
}
|
||||
return (
|
||||
<span className={`inline-flex items-center px-2 py-0.5 rounded text-xs font-medium ${config[state] || config.draft}`}>
|
||||
{labels[state] || state}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
export function LicenseRuleBadge({ rule }: { rule: number | null | undefined }) {
|
||||
if (!rule) return null
|
||||
const config: Record<number, { bg: string; label: string }> = {
|
||||
1: { bg: 'bg-green-100 text-green-700', label: 'Free Use' },
|
||||
2: { bg: 'bg-blue-100 text-blue-700', label: 'Zitation' },
|
||||
3: { bg: 'bg-amber-100 text-amber-700', label: 'Reformuliert' },
|
||||
}
|
||||
const c = config[rule]
|
||||
if (!c) return null
|
||||
return <span className={`inline-flex items-center px-2 py-0.5 rounded text-xs font-medium ${c.bg}`}>{c.label}</span>
|
||||
}
|
||||
|
||||
export function VerificationMethodBadge({ method }: { method: string | null }) {
|
||||
if (!method) return null
|
||||
const config = VERIFICATION_METHODS[method]
|
||||
if (!config) return null
|
||||
return <span className={`inline-flex items-center px-2 py-0.5 rounded text-xs font-medium ${config.bg}`}>{config.label}</span>
|
||||
}
|
||||
|
||||
export function CategoryBadge({ category }: { category: string | null }) {
|
||||
if (!category) return null
|
||||
const opt = CATEGORY_OPTIONS.find(c => c.value === category)
|
||||
return (
|
||||
<span className="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-indigo-50 text-indigo-700">
|
||||
{opt?.label || category}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
export function EvidenceTypeBadge({ type }: { type: string | null }) {
|
||||
if (!type) return null
|
||||
const config = EVIDENCE_TYPE_CONFIG[type]
|
||||
if (!config) return null
|
||||
return <span className={`inline-flex items-center px-2 py-0.5 rounded text-xs font-medium ${config.bg}`}>{config.label}</span>
|
||||
}
|
||||
|
||||
export function TargetAudienceBadge({ audience }: { audience: string | string[] | null }) {
|
||||
if (!audience) return null
|
||||
|
||||
// Parse JSON array string from DB (e.g. '["unternehmen", "einkauf"]')
|
||||
let items: string[] = []
|
||||
if (typeof audience === 'string') {
|
||||
if (audience.startsWith('[')) {
|
||||
try { items = JSON.parse(audience) } catch { items = [audience] }
|
||||
} else {
|
||||
items = [audience]
|
||||
}
|
||||
} else if (Array.isArray(audience)) {
|
||||
items = audience
|
||||
}
|
||||
|
||||
if (items.length === 0) return null
|
||||
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1 flex-wrap">
|
||||
{items.map((item, i) => {
|
||||
const config = TARGET_AUDIENCE_OPTIONS[item]
|
||||
if (!config) return <span key={i} className="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-gray-100 text-gray-600">{item}</span>
|
||||
return <span key={i} className={`inline-flex items-center px-2 py-0.5 rounded text-xs font-medium ${config.bg}`}>{config.label}</span>
|
||||
})}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
export interface CanonicalControlPipelineInfo {
|
||||
pipeline_version?: number | string | null
|
||||
source_citation?: Record<string, string> | null
|
||||
parent_control_uuid?: string | null
|
||||
}
|
||||
|
||||
export function isEigenentwicklung(ctrl: CanonicalControlPipelineInfo & { generation_strategy?: string | null }): boolean {
|
||||
return (
|
||||
(!ctrl.generation_strategy || ctrl.generation_strategy === 'ungrouped') &&
|
||||
(!ctrl.pipeline_version || String(ctrl.pipeline_version) === '1') &&
|
||||
!ctrl.source_citation &&
|
||||
!ctrl.parent_control_uuid
|
||||
)
|
||||
}
|
||||
|
||||
export function GenerationStrategyBadge({ strategy, pipelineInfo }: {
|
||||
strategy: string | null | undefined
|
||||
pipelineInfo?: CanonicalControlPipelineInfo & { generation_strategy?: string | null }
|
||||
}) {
|
||||
// Eigenentwicklung detection: v1 + no source + no parent
|
||||
if (pipelineInfo && isEigenentwicklung(pipelineInfo)) {
|
||||
return <span className="inline-flex items-center px-1.5 py-0.5 rounded text-xs font-medium bg-orange-100 text-orange-700">Eigenentwicklung</span>
|
||||
}
|
||||
if (!strategy || strategy === 'ungrouped') {
|
||||
return <span className="inline-flex items-center px-1.5 py-0.5 rounded text-xs font-medium bg-gray-100 text-gray-500">v1</span>
|
||||
}
|
||||
if (strategy === 'document_grouped') {
|
||||
return <span className="inline-flex items-center px-1.5 py-0.5 rounded text-xs font-medium bg-emerald-100 text-emerald-700">v2</span>
|
||||
}
|
||||
if (strategy === 'phase74_gap_fill') {
|
||||
return <span className="inline-flex items-center px-1.5 py-0.5 rounded text-xs font-medium bg-blue-100 text-blue-700">v5 Gap</span>
|
||||
}
|
||||
if (strategy === 'pass0b_atomic' || strategy === 'pass0b') {
|
||||
return <span className="inline-flex items-center px-1.5 py-0.5 rounded text-xs font-medium bg-violet-100 text-violet-700">Atomar</span>
|
||||
}
|
||||
return <span className="inline-flex items-center px-1.5 py-0.5 rounded text-xs font-medium bg-gray-100 text-gray-500">{strategy}</span>
|
||||
}
|
||||
|
||||
export const OBLIGATION_TYPE_CONFIG: Record<string, { bg: string; label: string }> = {
|
||||
pflicht: { bg: 'bg-red-100 text-red-700', label: 'Pflicht' },
|
||||
empfehlung: { bg: 'bg-amber-100 text-amber-700', label: 'Empfehlung' },
|
||||
kann: { bg: 'bg-green-100 text-green-700', label: 'Kann' },
|
||||
}
|
||||
|
||||
export function ObligationTypeBadge({ type }: { type: string | null | undefined }) {
|
||||
if (!type) return null
|
||||
const config = OBLIGATION_TYPE_CONFIG[type]
|
||||
if (!config) return null
|
||||
return <span className={`inline-flex items-center px-2 py-0.5 rounded text-xs font-medium ${config.bg}`}>{config.label}</span>
|
||||
}
|
||||
|
||||
export function getDomain(controlId: string): string {
|
||||
return controlId.split('-')[0] || ''
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// PROVENANCE TYPES
|
||||
// =============================================================================
|
||||
|
||||
export interface ObligationInfo {
|
||||
candidate_id: string
|
||||
obligation_text: string
|
||||
action: string | null
|
||||
object: string | null
|
||||
normative_strength: string
|
||||
release_state: string
|
||||
}
|
||||
|
||||
export interface DocumentReference {
|
||||
regulation_code: string
|
||||
article: string | null
|
||||
paragraph: string | null
|
||||
extraction_method: string
|
||||
confidence: number | null
|
||||
}
|
||||
|
||||
export interface MergedDuplicate {
|
||||
control_id: string
|
||||
title: string
|
||||
source_regulation: string | null
|
||||
}
|
||||
|
||||
export interface RegulationSummary {
|
||||
regulation_code: string
|
||||
articles: string[]
|
||||
link_types: string[]
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// PROVENANCE BADGES
|
||||
// =============================================================================
|
||||
|
||||
const EXTRACTION_METHOD_CONFIG: Record<string, { bg: string; label: string }> = {
|
||||
exact_match: { bg: 'bg-green-100 text-green-700', label: 'Exakt' },
|
||||
embedding_match: { bg: 'bg-blue-100 text-blue-700', label: 'Embedding' },
|
||||
llm_extracted: { bg: 'bg-violet-100 text-violet-700', label: 'LLM' },
|
||||
inferred: { bg: 'bg-gray-100 text-gray-600', label: 'Abgeleitet' },
|
||||
}
|
||||
|
||||
export function ExtractionMethodBadge({ method }: { method: string }) {
|
||||
const config = EXTRACTION_METHOD_CONFIG[method] || EXTRACTION_METHOD_CONFIG.inferred
|
||||
return <span className={`inline-flex items-center px-1.5 py-0.5 rounded text-xs font-medium ${config.bg}`}>{config.label}</span>
|
||||
}
|
||||
|
||||
export function RegulationCountBadge({ count }: { count: number }) {
|
||||
if (count <= 0) return null
|
||||
return (
|
||||
<span className="inline-flex items-center px-1.5 py-0.5 rounded text-xs font-medium bg-violet-100 text-violet-700">
|
||||
{count} {count === 1 ? 'Regulierung' : 'Regulierungen'}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -72,6 +72,132 @@ aus geschuetzten Quellen uebernommen.
|
||||
- **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',
|
||||
@@ -79,22 +205,41 @@ aus geschuetzten Quellen uebernommen.
|
||||
content: `## Eigenes Klassifikationssystem
|
||||
|
||||
Die Canonical Control Library verwendet ein **eigenes Domain-Schema**, das sich bewusst von
|
||||
proprietaeren Frameworks unterscheidet:
|
||||
proprietaeren Frameworks unterscheidet. Die Domains werden **automatisch** durch den
|
||||
Control Generator vergeben, basierend auf dem Inhalt der Quelldokumente.
|
||||
|
||||
| Domain | Name | Abgrenzung |
|
||||
|--------|------|------------|
|
||||
| AUTH | Identity & Access Management | Eigene Struktur, nicht BSI O.Auth_* |
|
||||
| NET | Network & Transport Security | Eigene Struktur, nicht BSI O.Netz_* |
|
||||
| SUP | Software Supply Chain | NIST SSDF / SLSA-basiert |
|
||||
| LOG | Security Operations & Logging | OWASP Logging Best Practices |
|
||||
| WEB | Web Application Security | OWASP ASVS-basiert |
|
||||
| DATA | Data Governance & Classification | NIST SP 800-60 basiert |
|
||||
| CRYP | Cryptographic Operations | NIST SP 800-57 basiert |
|
||||
| REL | Release & Change Governance | OWASP SAMM basiert |
|
||||
### 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, NET-002). Dieses Format ist
|
||||
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.`,
|
||||
},
|
||||
@@ -159,6 +304,104 @@ Kein Text, keine Struktur, keine Bezeichner aus diesen Quellen erscheinen im Pro
|
||||
| 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',
|
||||
|
||||
@@ -3,20 +3,414 @@
|
||||
import React, { useState } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { StepHeader, STEP_EXPLANATIONS } from '@/components/sdk/StepHeader'
|
||||
import {
|
||||
DisplayControl,
|
||||
DisplayControlType,
|
||||
mapControlTypeToDisplay,
|
||||
mapStatusToDisplay,
|
||||
} from './_types'
|
||||
import { ControlCard } from './_components/ControlCard'
|
||||
import { AddControlForm } from './_components/AddControlForm'
|
||||
import { LoadingSkeleton } from './_components/LoadingSkeleton'
|
||||
import { StatsCards } from './_components/StatsCards'
|
||||
import { FilterBar } from './_components/FilterBar'
|
||||
import { RAGPanel } from './_components/RAGPanel'
|
||||
import { useControlsData } from './_hooks/useControlsData'
|
||||
import { useRAGSuggestions } from './_hooks/useRAGSuggestions'
|
||||
|
||||
// =============================================================================
|
||||
// TYPES
|
||||
// =============================================================================
|
||||
|
||||
type DisplayControlType = 'preventive' | 'detective' | 'corrective'
|
||||
type DisplayCategory = 'technical' | 'organizational' | 'physical'
|
||||
type DisplayStatus = 'implemented' | 'partial' | 'planned' | 'not-implemented'
|
||||
|
||||
interface DisplayControl {
|
||||
id: string
|
||||
name: string
|
||||
description: string
|
||||
type: ControlType
|
||||
category: string
|
||||
implementationStatus: ImplementationStatus
|
||||
evidence: string[]
|
||||
owner: string | null
|
||||
dueDate: Date | null
|
||||
code: string
|
||||
displayType: DisplayControlType
|
||||
displayCategory: DisplayCategory
|
||||
displayStatus: DisplayStatus
|
||||
effectivenessPercent: number
|
||||
linkedRequirements: string[]
|
||||
linkedEvidence: { id: string; title: string; status: string }[]
|
||||
lastReview: Date
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// HELPER FUNCTIONS
|
||||
// =============================================================================
|
||||
|
||||
function mapControlTypeToDisplay(type: ControlType): DisplayCategory {
|
||||
switch (type) {
|
||||
case 'TECHNICAL': return 'technical'
|
||||
case 'ORGANIZATIONAL': return 'organizational'
|
||||
case 'PHYSICAL': return 'physical'
|
||||
default: return 'technical'
|
||||
}
|
||||
}
|
||||
|
||||
function mapStatusToDisplay(status: ImplementationStatus): DisplayStatus {
|
||||
switch (status) {
|
||||
case 'IMPLEMENTED': return 'implemented'
|
||||
case 'PARTIAL': return 'partial'
|
||||
case 'NOT_IMPLEMENTED': return 'not-implemented'
|
||||
default: return 'not-implemented'
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// =============================================================================
|
||||
// COMPONENTS
|
||||
// =============================================================================
|
||||
|
||||
function ControlCard({
|
||||
control,
|
||||
onStatusChange,
|
||||
onEffectivenessChange,
|
||||
onLinkEvidence,
|
||||
}: {
|
||||
control: DisplayControl
|
||||
onStatusChange: (status: ImplementationStatus) => void
|
||||
onEffectivenessChange: (effectivenessPercent: number) => void
|
||||
onLinkEvidence: () => void
|
||||
}) {
|
||||
const [showEffectivenessSlider, setShowEffectivenessSlider] = useState(false)
|
||||
|
||||
const typeColors = {
|
||||
preventive: 'bg-blue-100 text-blue-700',
|
||||
detective: 'bg-purple-100 text-purple-700',
|
||||
corrective: 'bg-orange-100 text-orange-700',
|
||||
}
|
||||
|
||||
const categoryColors = {
|
||||
technical: 'bg-green-100 text-green-700',
|
||||
organizational: 'bg-yellow-100 text-yellow-700',
|
||||
physical: 'bg-gray-100 text-gray-700',
|
||||
}
|
||||
|
||||
const statusColors = {
|
||||
implemented: 'border-green-200 bg-green-50',
|
||||
partial: 'border-yellow-200 bg-yellow-50',
|
||||
planned: 'border-blue-200 bg-blue-50',
|
||||
'not-implemented': 'border-red-200 bg-red-50',
|
||||
}
|
||||
|
||||
const statusLabels = {
|
||||
implemented: 'Implementiert',
|
||||
partial: 'Teilweise',
|
||||
planned: 'Geplant',
|
||||
'not-implemented': 'Nicht implementiert',
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`bg-white rounded-xl border-2 p-6 ${statusColors[control.displayStatus]}`}>
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<span className="px-2 py-1 text-xs bg-gray-100 text-gray-700 rounded font-mono">
|
||||
{control.code}
|
||||
</span>
|
||||
<span className={`px-2 py-1 text-xs rounded-full ${typeColors[control.displayType]}`}>
|
||||
{control.displayType === 'preventive' ? 'Praeventiv' :
|
||||
control.displayType === 'detective' ? 'Detektiv' : 'Korrektiv'}
|
||||
</span>
|
||||
<span className={`px-2 py-1 text-xs rounded-full ${categoryColors[control.displayCategory]}`}>
|
||||
{control.displayCategory === 'technical' ? 'Technisch' :
|
||||
control.displayCategory === 'organizational' ? 'Organisatorisch' : 'Physisch'}
|
||||
</span>
|
||||
</div>
|
||||
<h3 className="text-lg font-semibold text-gray-900">{control.name}</h3>
|
||||
<p className="text-sm text-gray-500 mt-1">{control.description}</p>
|
||||
</div>
|
||||
<select
|
||||
value={control.implementationStatus}
|
||||
onChange={(e) => onStatusChange(e.target.value as ImplementationStatus)}
|
||||
className={`px-3 py-1 text-sm rounded-full border ${statusColors[control.displayStatus]}`}
|
||||
>
|
||||
<option value="NOT_IMPLEMENTED">Nicht implementiert</option>
|
||||
<option value="PARTIAL">Teilweise</option>
|
||||
<option value="IMPLEMENTED">Implementiert</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="mt-4">
|
||||
<div
|
||||
className="flex items-center justify-between text-sm mb-1 cursor-pointer"
|
||||
onClick={() => setShowEffectivenessSlider(!showEffectivenessSlider)}
|
||||
>
|
||||
<span className="text-gray-500">Wirksamkeit</span>
|
||||
<span className="font-medium">{control.effectivenessPercent}%</span>
|
||||
</div>
|
||||
<div className="h-2 bg-gray-200 rounded-full overflow-hidden">
|
||||
<div
|
||||
className={`h-full rounded-full transition-all ${
|
||||
control.effectivenessPercent >= 80 ? 'bg-green-500' :
|
||||
control.effectivenessPercent >= 50 ? 'bg-yellow-500' : 'bg-red-500'
|
||||
}`}
|
||||
style={{ width: `${control.effectivenessPercent}%` }}
|
||||
/>
|
||||
</div>
|
||||
{showEffectivenessSlider && (
|
||||
<div className="mt-2">
|
||||
<input
|
||||
type="range"
|
||||
min={0}
|
||||
max={100}
|
||||
value={control.effectivenessPercent}
|
||||
onChange={(e) => onEffectivenessChange(Number(e.target.value))}
|
||||
className="w-full"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mt-4 pt-4 border-t border-gray-100 flex items-center justify-between text-sm">
|
||||
<div className="text-gray-500">
|
||||
<span>Verantwortlich: </span>
|
||||
<span className="font-medium text-gray-700">{control.owner || 'Nicht zugewiesen'}</span>
|
||||
</div>
|
||||
<div className="text-gray-500">
|
||||
Letzte Pruefung: {control.lastReview.toLocaleDateString('de-DE')}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-3 flex items-center justify-between">
|
||||
<div className="flex items-center gap-1 flex-wrap">
|
||||
{control.linkedRequirements.slice(0, 3).map(req => (
|
||||
<span key={req} className="px-2 py-0.5 text-xs bg-gray-100 text-gray-600 rounded">
|
||||
{req}
|
||||
</span>
|
||||
))}
|
||||
{control.linkedRequirements.length > 3 && (
|
||||
<span className="px-2 py-0.5 text-xs bg-gray-100 text-gray-600 rounded">
|
||||
+{control.linkedRequirements.length - 3}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<span className={`px-3 py-1 text-xs rounded-full ${
|
||||
control.displayStatus === 'implemented' ? 'bg-green-100 text-green-700' :
|
||||
control.displayStatus === 'partial' ? 'bg-yellow-100 text-yellow-700' :
|
||||
control.displayStatus === 'planned' ? 'bg-blue-100 text-blue-700' : 'bg-red-100 text-red-700'
|
||||
}`}>
|
||||
{statusLabels[control.displayStatus]}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Linked Evidence */}
|
||||
{control.linkedEvidence.length > 0 && (
|
||||
<div className="mt-3 pt-3 border-t border-gray-100">
|
||||
<span className="text-xs text-gray-500 mb-1 block">
|
||||
Nachweise: {control.linkedEvidence.length}
|
||||
{(() => {
|
||||
const e2plus = control.linkedEvidence.filter((ev: { confidenceLevel?: string }) =>
|
||||
ev.confidenceLevel && ['E2', 'E3', 'E4'].includes(ev.confidenceLevel)
|
||||
).length
|
||||
return e2plus > 0 ? ` (${e2plus} E2+)` : ''
|
||||
})()}
|
||||
</span>
|
||||
<div className="flex items-center gap-1 flex-wrap">
|
||||
{control.linkedEvidence.map(ev => (
|
||||
<span key={ev.id} className={`px-2 py-0.5 text-xs rounded ${
|
||||
ev.status === 'valid' ? 'bg-green-50 text-green-700' :
|
||||
ev.status === 'expired' ? 'bg-red-50 text-red-700' :
|
||||
'bg-yellow-50 text-yellow-700'
|
||||
}`}>
|
||||
{ev.title}
|
||||
{(ev as { confidenceLevel?: string }).confidenceLevel && (
|
||||
<span className="ml-1 opacity-70">({(ev as { confidenceLevel?: string }).confidenceLevel})</span>
|
||||
)}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mt-3 pt-3 border-t border-gray-100">
|
||||
<button
|
||||
onClick={onLinkEvidence}
|
||||
className="text-sm text-purple-600 hover:text-purple-700 font-medium"
|
||||
>
|
||||
Evidence verknuepfen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function AddControlForm({
|
||||
onSubmit,
|
||||
onCancel,
|
||||
}: {
|
||||
onSubmit: (data: { name: string; description: string; type: ControlType; category: string; owner: string }) => void
|
||||
onCancel: () => void
|
||||
}) {
|
||||
const [formData, setFormData] = useState({
|
||||
name: '',
|
||||
description: '',
|
||||
type: 'TECHNICAL' as ControlType,
|
||||
category: '',
|
||||
owner: '',
|
||||
})
|
||||
|
||||
return (
|
||||
<div className="bg-white rounded-xl border border-gray-200 p-6">
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-4">Neue Kontrolle</h3>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Name *</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.name}
|
||||
onChange={e => setFormData({ ...formData, name: e.target.value })}
|
||||
placeholder="z.B. Zugriffskontrolle"
|
||||
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Beschreibung</label>
|
||||
<textarea
|
||||
value={formData.description}
|
||||
onChange={e => setFormData({ ...formData, description: e.target.value })}
|
||||
placeholder="Beschreiben Sie die Kontrolle..."
|
||||
rows={2}
|
||||
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent"
|
||||
/>
|
||||
</div>
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Typ</label>
|
||||
<select
|
||||
value={formData.type}
|
||||
onChange={e => setFormData({ ...formData, type: e.target.value as ControlType })}
|
||||
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent"
|
||||
>
|
||||
<option value="TECHNICAL">Technisch</option>
|
||||
<option value="ORGANIZATIONAL">Organisatorisch</option>
|
||||
<option value="PHYSICAL">Physisch</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Kategorie</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.category}
|
||||
onChange={e => setFormData({ ...formData, category: e.target.value })}
|
||||
placeholder="z.B. Zutrittskontrolle"
|
||||
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Verantwortlich</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.owner}
|
||||
onChange={e => setFormData({ ...formData, owner: e.target.value })}
|
||||
placeholder="z.B. IT Security"
|
||||
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-6 flex items-center justify-end gap-3">
|
||||
<button onClick={onCancel} className="px-4 py-2 text-gray-600 hover:bg-gray-100 rounded-lg transition-colors">
|
||||
Abbrechen
|
||||
</button>
|
||||
<button
|
||||
onClick={() => onSubmit(formData)}
|
||||
disabled={!formData.name}
|
||||
className={`px-6 py-2 rounded-lg font-medium transition-colors ${
|
||||
formData.name ? 'bg-purple-600 text-white hover:bg-purple-700' : 'bg-gray-200 text-gray-400 cursor-not-allowed'
|
||||
}`}
|
||||
>
|
||||
Hinzufuegen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function LoadingSkeleton() {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{[1, 2, 3].map(i => (
|
||||
<div key={i} className="bg-white rounded-xl border border-gray-200 p-6 animate-pulse">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<div className="h-5 w-20 bg-gray-200 rounded" />
|
||||
<div className="h-5 w-16 bg-gray-200 rounded-full" />
|
||||
<div className="h-5 w-16 bg-gray-200 rounded-full" />
|
||||
</div>
|
||||
<div className="h-6 w-3/4 bg-gray-200 rounded mb-2" />
|
||||
<div className="h-4 w-full bg-gray-100 rounded" />
|
||||
<div className="mt-4 h-2 bg-gray-200 rounded-full" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// MAIN PAGE
|
||||
// =============================================================================
|
||||
|
||||
// =============================================================================
|
||||
// RAG SUGGESTION TYPES
|
||||
// =============================================================================
|
||||
|
||||
interface RAGControlSuggestion {
|
||||
control_id: string
|
||||
domain: string
|
||||
title: string
|
||||
description: string
|
||||
pass_criteria: string
|
||||
implementation_guidance?: string
|
||||
is_automated: boolean
|
||||
automation_tool?: string
|
||||
priority: number
|
||||
confidence_score: number
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// MAIN PAGE
|
||||
// =============================================================================
|
||||
|
||||
function TransitionErrorBanner({
|
||||
controlId,
|
||||
violations,
|
||||
onDismiss,
|
||||
}: {
|
||||
controlId: string
|
||||
violations: string[]
|
||||
onDismiss: () => void
|
||||
}) {
|
||||
return (
|
||||
<div className="p-4 bg-orange-50 border border-orange-200 rounded-lg">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex items-start gap-3">
|
||||
<svg className="w-5 h-5 text-orange-600 mt-0.5 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
|
||||
</svg>
|
||||
<div>
|
||||
<h4 className="font-medium text-orange-800">
|
||||
Status-Transition blockiert ({controlId})
|
||||
</h4>
|
||||
<ul className="mt-2 space-y-1">
|
||||
{violations.map((v, i) => (
|
||||
<li key={i} className="text-sm text-orange-700 flex items-start gap-2">
|
||||
<span className="text-orange-400 mt-0.5">•</span>
|
||||
<span>{v}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<a href="/sdk/evidence" className="mt-2 inline-block text-sm text-purple-600 hover:text-purple-700 font-medium">
|
||||
Evidence hinzufuegen →
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<button onClick={onDismiss} className="text-orange-400 hover:text-orange-600 ml-4">
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function ControlsPage() {
|
||||
const router = useRouter()
|
||||
@@ -35,16 +429,78 @@ export default function ControlsPage() {
|
||||
handleAddControl,
|
||||
} = useControlsData()
|
||||
|
||||
const {
|
||||
ragLoading,
|
||||
ragSuggestions,
|
||||
showRagPanel,
|
||||
setShowRagPanel,
|
||||
selectedRequirementId,
|
||||
setSelectedRequirementId,
|
||||
suggestControlsFromRAG,
|
||||
addSuggestedControl,
|
||||
} = useRAGSuggestions(setError)
|
||||
// Transition error from Anti-Fake-Evidence state machine (409 Conflict)
|
||||
const [transitionError, setTransitionError] = useState<{ controlId: string; violations: string[] } | null>(null)
|
||||
|
||||
// Track effectiveness locally as it's not in the SDK state type
|
||||
const [effectivenessMap, setEffectivenessMap] = useState<Record<string, number>>({})
|
||||
// Track linked evidence per control
|
||||
const [evidenceMap, setEvidenceMap] = useState<Record<string, { id: string; title: string; status: string }[]>>({})
|
||||
|
||||
const fetchEvidenceForControls = async (controlIds: string[]) => {
|
||||
try {
|
||||
const res = await fetch('/api/sdk/v1/compliance/evidence')
|
||||
if (res.ok) {
|
||||
const data = await res.json()
|
||||
const allEvidence = data.evidence || data
|
||||
if (Array.isArray(allEvidence)) {
|
||||
const map: Record<string, { id: string; title: string; status: string; confidenceLevel?: string }[]> = {}
|
||||
for (const ev of allEvidence) {
|
||||
const ctrlId = ev.control_id || ''
|
||||
if (!map[ctrlId]) map[ctrlId] = []
|
||||
map[ctrlId].push({
|
||||
id: ev.id,
|
||||
title: ev.title || ev.name || 'Nachweis',
|
||||
status: ev.status || 'pending',
|
||||
confidenceLevel: ev.confidence_level || undefined,
|
||||
})
|
||||
}
|
||||
setEvidenceMap(map)
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Silently fail
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch controls from backend on mount
|
||||
useEffect(() => {
|
||||
const fetchControls = async () => {
|
||||
try {
|
||||
setLoading(true)
|
||||
const res = await fetch('/api/sdk/v1/compliance/controls')
|
||||
if (res.ok) {
|
||||
const data = await res.json()
|
||||
const backendControls = data.controls || data
|
||||
if (Array.isArray(backendControls) && backendControls.length > 0) {
|
||||
const mapped: SDKControl[] = backendControls.map((c: Record<string, unknown>) => ({
|
||||
id: (c.control_id || c.id) as string,
|
||||
name: (c.name || c.title || '') as string,
|
||||
description: (c.description || '') as string,
|
||||
type: ((c.type || c.control_type || 'TECHNICAL') as string).toUpperCase() as ControlType,
|
||||
category: (c.category || '') as string,
|
||||
implementationStatus: ((c.implementation_status || c.status || 'NOT_IMPLEMENTED') as string).toUpperCase() as ImplementationStatus,
|
||||
effectiveness: (c.effectiveness || 'LOW') as 'LOW' | 'MEDIUM' | 'HIGH',
|
||||
evidence: (c.evidence || []) as string[],
|
||||
owner: (c.owner || null) as string | null,
|
||||
dueDate: c.due_date ? new Date(c.due_date as string) : null,
|
||||
}))
|
||||
dispatch({ type: 'SET_STATE', payload: { controls: mapped } })
|
||||
setError(null)
|
||||
// Fetch evidence for all controls
|
||||
fetchEvidenceForControls(mapped.map(c => c.id))
|
||||
return
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// API not available — show empty state
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
fetchControls()
|
||||
}, []) // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
// Convert SDK controls to display controls
|
||||
const displayControls: DisplayControl[] = state.controls.map(ctrl => {
|
||||
@@ -87,6 +543,136 @@ export default function ControlsPage() {
|
||||
: 0
|
||||
const partialCount = displayControls.filter(c => c.displayStatus === 'partial').length
|
||||
|
||||
const handleStatusChange = async (controlId: string, newStatus: ImplementationStatus) => {
|
||||
// Remember old status for rollback
|
||||
const oldControl = state.controls.find(c => c.id === controlId)
|
||||
const oldStatus = oldControl?.implementationStatus
|
||||
|
||||
// Optimistic update
|
||||
dispatch({
|
||||
type: 'UPDATE_CONTROL',
|
||||
payload: { id: controlId, data: { implementationStatus: newStatus } },
|
||||
})
|
||||
|
||||
try {
|
||||
const res = await fetch(`/api/sdk/v1/compliance/controls/${controlId}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ implementation_status: newStatus }),
|
||||
})
|
||||
|
||||
if (!res.ok) {
|
||||
// Rollback optimistic update
|
||||
if (oldStatus) {
|
||||
dispatch({
|
||||
type: 'UPDATE_CONTROL',
|
||||
payload: { id: controlId, data: { implementationStatus: oldStatus } },
|
||||
})
|
||||
}
|
||||
|
||||
const err = await res.json().catch(() => ({ detail: 'Status-Aenderung fehlgeschlagen' }))
|
||||
|
||||
if (res.status === 409 && err.detail?.violations) {
|
||||
setTransitionError({ controlId, violations: err.detail.violations })
|
||||
} else {
|
||||
const msg = typeof err.detail === 'string' ? err.detail : err.detail?.error || 'Status-Aenderung fehlgeschlagen'
|
||||
setError(msg)
|
||||
}
|
||||
} else {
|
||||
// Clear any previous transition error for this control
|
||||
if (transitionError?.controlId === controlId) {
|
||||
setTransitionError(null)
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Network error — rollback
|
||||
if (oldStatus) {
|
||||
dispatch({
|
||||
type: 'UPDATE_CONTROL',
|
||||
payload: { id: controlId, data: { implementationStatus: oldStatus } },
|
||||
})
|
||||
}
|
||||
setError('Netzwerkfehler bei Status-Aenderung')
|
||||
}
|
||||
}
|
||||
|
||||
const handleEffectivenessChange = async (controlId: string, effectiveness: number) => {
|
||||
setEffectivenessMap(prev => ({ ...prev, [controlId]: effectiveness }))
|
||||
|
||||
// Persist to backend
|
||||
try {
|
||||
await fetch(`/api/sdk/v1/compliance/controls/${controlId}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ effectiveness_score: effectiveness }),
|
||||
})
|
||||
} catch {
|
||||
// Silently fail — local state is already updated
|
||||
}
|
||||
}
|
||||
|
||||
const handleAddControl = (data: { name: string; description: string; type: ControlType; category: string; owner: string }) => {
|
||||
const newControl: SDKControl = {
|
||||
id: `ctrl-${Date.now()}`,
|
||||
name: data.name,
|
||||
description: data.description,
|
||||
type: data.type,
|
||||
category: data.category,
|
||||
implementationStatus: 'NOT_IMPLEMENTED',
|
||||
effectiveness: 'LOW',
|
||||
evidence: [],
|
||||
owner: data.owner || null,
|
||||
dueDate: null,
|
||||
}
|
||||
dispatch({ type: 'ADD_CONTROL', payload: newControl })
|
||||
setShowAddForm(false)
|
||||
}
|
||||
|
||||
const suggestControlsFromRAG = async () => {
|
||||
if (!selectedRequirementId) {
|
||||
setError('Bitte eine Anforderungs-ID eingeben.')
|
||||
return
|
||||
}
|
||||
setRagLoading(true)
|
||||
setRagSuggestions([])
|
||||
try {
|
||||
const res = await fetch('/api/sdk/v1/compliance/ai/suggest-controls', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ requirement_id: selectedRequirementId }),
|
||||
})
|
||||
if (!res.ok) {
|
||||
const msg = await res.text()
|
||||
throw new Error(msg || `HTTP ${res.status}`)
|
||||
}
|
||||
const data = await res.json()
|
||||
setRagSuggestions(data.suggestions || [])
|
||||
setShowRagPanel(true)
|
||||
} catch (e) {
|
||||
setError(`KI-Vorschläge fehlgeschlagen: ${e instanceof Error ? e.message : 'Unbekannter Fehler'}`)
|
||||
} finally {
|
||||
setRagLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const addSuggestedControl = (suggestion: RAGControlSuggestion) => {
|
||||
const newControl: import('@/lib/sdk').Control = {
|
||||
id: `rag-${suggestion.control_id}-${Date.now()}`,
|
||||
name: suggestion.title,
|
||||
description: suggestion.description,
|
||||
type: 'TECHNICAL',
|
||||
category: suggestion.domain,
|
||||
implementationStatus: 'NOT_IMPLEMENTED',
|
||||
effectiveness: 'LOW',
|
||||
evidence: [],
|
||||
owner: null,
|
||||
dueDate: null,
|
||||
}
|
||||
dispatch({ type: 'ADD_CONTROL', payload: newControl })
|
||||
// Remove from suggestions after adding
|
||||
setRagSuggestions(prev => prev.filter(s => s.control_id !== suggestion.control_id))
|
||||
}
|
||||
|
||||
const stepInfo = STEP_EXPLANATIONS['controls']
|
||||
|
||||
return (
|
||||
@@ -151,6 +737,15 @@ export default function ControlsPage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Transition Error Banner (Anti-Fake-Evidence 409 violations) */}
|
||||
{transitionError && (
|
||||
<TransitionErrorBanner
|
||||
controlId={transitionError.controlId}
|
||||
violations={transitionError.violations}
|
||||
onDismiss={() => setTransitionError(null)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Requirements Alert */}
|
||||
{state.requirements.length === 0 && !loading && (
|
||||
<div className="bg-amber-50 border border-amber-200 rounded-xl p-4">
|
||||
|
||||
@@ -9,10 +9,807 @@ import { DataPointsPreview } from './components/DataPointsPreview'
|
||||
import { DocumentValidation } from './components/DocumentValidation'
|
||||
import { generateAllPlaceholders } from '@/lib/sdk/document-generator/datapoint-helpers'
|
||||
import { loadAllTemplates } from './searchTemplates'
|
||||
import { TemplateContext, EMPTY_CONTEXT } from './contextBridge'
|
||||
import { CATEGORIES } from './_constants'
|
||||
import TemplateLibrary from './_components/TemplateLibrary'
|
||||
import GeneratorSection from './_components/GeneratorSection'
|
||||
import {
|
||||
TemplateContext, EMPTY_CONTEXT,
|
||||
contextToPlaceholders, getRelevantSections,
|
||||
getUncoveredPlaceholders, getMissingRequired,
|
||||
} from './contextBridge'
|
||||
import {
|
||||
runRuleset, getDocType, applyBlockRemoval,
|
||||
buildBoolContext, applyConditionalBlocks,
|
||||
type RuleInput, type RuleEngineResult,
|
||||
} from './ruleEngine'
|
||||
|
||||
// =============================================================================
|
||||
// CATEGORY CONFIG
|
||||
// =============================================================================
|
||||
|
||||
const CATEGORIES: { key: string; label: string; types: string[] | null }[] = [
|
||||
{ key: 'all', label: 'Alle', types: null },
|
||||
// Legal / Vertragsvorlagen
|
||||
{ key: 'privacy_policy', label: 'Datenschutz', types: ['privacy_policy'] },
|
||||
{ key: 'terms', label: 'AGB', types: ['terms_of_service', 'agb', 'clause'] },
|
||||
{ key: 'impressum', label: 'Impressum', types: ['impressum'] },
|
||||
{ key: 'dpa', label: 'AVV/DPA', types: ['dpa'] },
|
||||
{ key: 'nda', label: 'NDA', types: ['nda'] },
|
||||
{ key: 'sla', label: 'SLA', types: ['sla'] },
|
||||
{ key: 'widerruf', label: 'Widerruf', types: ['widerruf'] },
|
||||
{ key: 'cookie', label: 'Cookie', types: ['cookie_policy', 'cookie_banner'] },
|
||||
{ key: 'cloud', label: 'Cloud', types: ['cloud_service_agreement'] },
|
||||
{ key: 'misc', label: 'Weitere', types: ['community_guidelines', 'copyright_policy', 'data_usage_clause'] },
|
||||
{ key: 'dsfa', label: 'DSFA', types: ['dsfa'] },
|
||||
// Sicherheitskonzepte (Migration 051)
|
||||
{ key: 'security', label: 'Sicherheitskonzepte', types: ['it_security_concept', 'data_protection_concept', 'backup_recovery_concept', 'logging_concept', 'incident_response_plan', 'access_control_concept', 'risk_management_concept', 'cybersecurity_policy'] },
|
||||
// Policy-Bibliothek (Migration 071/072)
|
||||
{ key: 'it_security_policies', label: 'IT-Sicherheit Policies', types: ['information_security_policy', 'access_control_policy', 'password_policy', 'encryption_policy', 'logging_policy', 'backup_policy', 'incident_response_policy', 'change_management_policy', 'patch_management_policy', 'asset_management_policy', 'cloud_security_policy', 'devsecops_policy', 'secrets_management_policy', 'vulnerability_management_policy'] },
|
||||
{ key: 'data_policies', label: 'Daten-Policies', types: ['data_protection_policy', 'data_classification_policy', 'data_retention_policy', 'data_transfer_policy', 'privacy_incident_policy'] },
|
||||
{ key: 'hr_policies', label: 'Personal-Policies', types: ['employee_security_policy', 'security_awareness_policy', 'acceptable_use', 'remote_work_policy', 'offboarding_policy'] },
|
||||
{ key: 'vendor_policies', label: 'Lieferanten-Policies', types: ['vendor_risk_management_policy', 'third_party_security_policy', 'supplier_security_policy'] },
|
||||
{ key: 'bcm_policies', label: 'BCM/Notfall', types: ['business_continuity_policy', 'disaster_recovery_policy', 'crisis_management_policy'] },
|
||||
// Modul-Dokumente (Migration 073)
|
||||
{ key: 'module_docs', label: 'DSGVO-Dokumente', types: ['vvt_register', 'tom_documentation', 'loeschkonzept', 'pflichtenregister'] },
|
||||
]
|
||||
|
||||
// =============================================================================
|
||||
// CONTEXT FORM CONFIG
|
||||
// =============================================================================
|
||||
|
||||
const SECTION_LABELS: Record<keyof TemplateContext, string> = {
|
||||
PROVIDER: 'Anbieter',
|
||||
CUSTOMER: 'Kunde / Gegenpartei',
|
||||
SERVICE: 'Dienst / Produkt',
|
||||
LEGAL: 'Rechtliches',
|
||||
PRIVACY: 'Datenschutz',
|
||||
SLA: 'Service Level (SLA)',
|
||||
PAYMENTS: 'Zahlungskonditionen',
|
||||
SECURITY: 'Sicherheit & Logs',
|
||||
NDA: 'Geheimhaltung (NDA)',
|
||||
CONSENT: 'Cookie / Einwilligung',
|
||||
HOSTING: 'Hosting-Provider',
|
||||
FEATURES: 'Dokument-Features & Textbausteine',
|
||||
}
|
||||
|
||||
type FieldType = 'text' | 'email' | 'number' | 'select' | 'textarea' | 'boolean'
|
||||
interface FieldDef {
|
||||
key: string
|
||||
label: string
|
||||
type?: FieldType
|
||||
opts?: string[]
|
||||
span?: boolean
|
||||
nullable?: boolean
|
||||
}
|
||||
|
||||
const SECTION_FIELDS: Record<keyof TemplateContext, FieldDef[]> = {
|
||||
PROVIDER: [
|
||||
{ key: 'LEGAL_NAME', label: 'Firmenname' },
|
||||
{ key: 'EMAIL', label: 'Kontakt-E-Mail', type: 'email' },
|
||||
{ key: 'LEGAL_FORM', label: 'Rechtsform' },
|
||||
{ key: 'ADDRESS_LINE', label: 'Adresse' },
|
||||
{ key: 'POSTAL_CODE', label: 'PLZ' },
|
||||
{ key: 'CITY', label: 'Stadt' },
|
||||
{ key: 'WEBSITE_URL', label: 'Website-URL' },
|
||||
{ key: 'CEO_NAME', label: 'Geschäftsführer' },
|
||||
{ key: 'REGISTER_COURT', label: 'Registergericht' },
|
||||
{ key: 'REGISTER_NUMBER', label: 'HRB-Nummer' },
|
||||
{ key: 'VAT_ID', label: 'USt-ID' },
|
||||
{ key: 'PHONE', label: 'Telefon' },
|
||||
],
|
||||
CUSTOMER: [
|
||||
{ key: 'LEGAL_NAME', label: 'Name / Firma' },
|
||||
{ key: 'EMAIL', label: 'E-Mail', type: 'email' },
|
||||
{ key: 'CONTACT_NAME', label: 'Ansprechpartner' },
|
||||
{ key: 'ADDRESS_LINE', label: 'Adresse' },
|
||||
{ key: 'POSTAL_CODE', label: 'PLZ' },
|
||||
{ key: 'CITY', label: 'Stadt' },
|
||||
{ key: 'COUNTRY', label: 'Land' },
|
||||
{ key: 'IS_CONSUMER', label: 'Verbraucher (B2C)', type: 'boolean' },
|
||||
{ key: 'IS_BUSINESS', label: 'Unternehmer (B2B)', type: 'boolean' },
|
||||
],
|
||||
SERVICE: [
|
||||
{ key: 'NAME', label: 'Dienstname' },
|
||||
{ key: 'DESCRIPTION', label: 'Beschreibung', type: 'textarea', span: true },
|
||||
{ key: 'MODEL', label: 'Modell', type: 'select', opts: ['SaaS', 'PaaS', 'IaaS', 'OnPrem', 'Hybrid'] },
|
||||
{ key: 'TIER', label: 'Plan / Tier' },
|
||||
{ key: 'DATA_LOCATION', label: 'Datenspeicherort' },
|
||||
{ key: 'EXPORT_WINDOW_DAYS', label: 'Export-Frist (Tage)', type: 'number' },
|
||||
{ key: 'MIN_TERM_MONTHS', label: 'Mindestlaufzeit (Monate)', type: 'number' },
|
||||
{ key: 'TERMINATION_NOTICE_DAYS', label: 'Kündigungsfrist (Tage)', type: 'number' },
|
||||
],
|
||||
LEGAL: [
|
||||
{ key: 'GOVERNING_LAW', label: 'Anwendbares Recht' },
|
||||
{ key: 'JURISDICTION_CITY', label: 'Gerichtsstand (Stadt)' },
|
||||
{ key: 'VERSION_DATE', label: 'Versionsstand (JJJJ-MM-TT)' },
|
||||
{ key: 'EFFECTIVE_DATE', label: 'Gültig ab (JJJJ-MM-TT)' },
|
||||
],
|
||||
PRIVACY: [
|
||||
{ key: 'DPO_NAME', label: 'DSB-Name' },
|
||||
{ key: 'DPO_EMAIL', label: 'DSB-E-Mail', type: 'email' },
|
||||
{ key: 'CONTACT_EMAIL', label: 'Datenschutz-Kontakt', type: 'email' },
|
||||
{ key: 'PRIVACY_POLICY_URL', label: 'Datenschutz-URL' },
|
||||
{ key: 'COOKIE_POLICY_URL', label: 'Cookie-Policy-URL' },
|
||||
{ key: 'ANALYTICS_RETENTION_MONTHS', label: 'Analytics-Aufbewahrung (Monate)', type: 'number' },
|
||||
{ key: 'SUPERVISORY_AUTHORITY_NAME', label: 'Aufsichtsbehörde' },
|
||||
],
|
||||
SLA: [
|
||||
{ key: 'AVAILABILITY_PERCENT', label: 'Verfügbarkeit (%)', type: 'number' },
|
||||
{ key: 'MAINTENANCE_NOTICE_HOURS', label: 'Wartungsankündigung (h)', type: 'number' },
|
||||
{ key: 'SUPPORT_EMAIL', label: 'Support-E-Mail', type: 'email' },
|
||||
{ key: 'SUPPORT_HOURS', label: 'Support-Zeiten' },
|
||||
{ key: 'RESPONSE_CRITICAL_H', label: 'Reaktion Kritisch (h)', type: 'number' },
|
||||
{ key: 'RESOLUTION_CRITICAL_H', label: 'Lösung Kritisch (h)', type: 'number' },
|
||||
{ key: 'RESPONSE_HIGH_H', label: 'Reaktion Hoch (h)', type: 'number' },
|
||||
{ key: 'RESOLUTION_HIGH_H', label: 'Lösung Hoch (h)', type: 'number' },
|
||||
{ key: 'RESPONSE_MEDIUM_H', label: 'Reaktion Mittel (h)', type: 'number' },
|
||||
{ key: 'RESOLUTION_MEDIUM_H', label: 'Lösung Mittel (h)', type: 'number' },
|
||||
{ key: 'RESPONSE_LOW_H', label: 'Reaktion Niedrig (h)', type: 'number' },
|
||||
],
|
||||
PAYMENTS: [
|
||||
{ key: 'MONTHLY_FEE_EUR', label: 'Monatl. Gebühr (EUR)', type: 'number' },
|
||||
{ key: 'PAYMENT_DUE_DAY', label: 'Fälligkeitstag', type: 'number' },
|
||||
{ key: 'PAYMENT_METHOD', label: 'Zahlungsmethode' },
|
||||
{ key: 'PAYMENT_DAYS', label: 'Zahlungsziel (Tage)', type: 'number' },
|
||||
],
|
||||
SECURITY: [
|
||||
{ key: 'INCIDENT_NOTICE_HOURS', label: 'Meldepflicht Vorfälle (h)', type: 'number' },
|
||||
{ key: 'LOG_RETENTION_DAYS', label: 'Log-Aufbewahrung (Tage)', type: 'number' },
|
||||
{ key: 'SECURITY_LOG_RETENTION_DAYS', label: 'Sicherheits-Log (Tage)', type: 'number' },
|
||||
],
|
||||
NDA: [
|
||||
{ key: 'PURPOSE', label: 'Zweck', type: 'textarea', span: true },
|
||||
{ key: 'DURATION_YEARS', label: 'Laufzeit (Jahre)', type: 'number' },
|
||||
{ key: 'PENALTY_AMOUNT_EUR', label: 'Vertragsstrafe EUR (leer = keine)', type: 'number', nullable: true },
|
||||
],
|
||||
CONSENT: [
|
||||
{ key: 'WEBSITE_NAME', label: 'Website-Name' },
|
||||
{ key: 'ANALYTICS_TOOLS', label: 'Analytics-Tools (leer = kein Block)', nullable: true },
|
||||
{ key: 'MARKETING_PARTNERS', label: 'Marketing-Partner (leer = kein Block)', nullable: true },
|
||||
],
|
||||
HOSTING: [
|
||||
{ key: 'PROVIDER_NAME', label: 'Hosting-Anbieter' },
|
||||
{ key: 'COUNTRY', label: 'Hosting-Land' },
|
||||
{ key: 'CONTRACT_TYPE', label: 'Vertragstyp (z. B. AVV nach Art. 28 DSGVO)' },
|
||||
],
|
||||
FEATURES: [
|
||||
// ── DSI / Cookie ─────────────────────────────────────────────────────────
|
||||
{ key: 'CONSENT_WITHDRAWAL_PATH', label: 'Einwilligungs-Widerrufspfad' },
|
||||
{ key: 'SECURITY_MEASURES_SUMMARY', label: 'Sicherheitsmaßnahmen (kurz)' },
|
||||
{ key: 'DATA_SUBJECT_REQUEST_CHANNEL', label: 'Kanal für Betroffenenanfragen' },
|
||||
{ key: 'HAS_THIRD_COUNTRY', label: 'Drittlandübermittlung möglich', type: 'boolean' },
|
||||
{ key: 'TRANSFER_GUARDS', label: 'Garantien (z. B. SCC)' },
|
||||
// ── Cookie/Consent ───────────────────────────────────────────────────────
|
||||
{ key: 'HAS_FUNCTIONAL_COOKIES', label: 'Funktionale Cookies aktiviert', type: 'boolean' },
|
||||
{ key: 'CMP_NAME', label: 'Consent-Manager-Name (optional)' },
|
||||
{ key: 'CMP_LOGS_CONSENTS', label: 'Consent-Protokollierung aktiv', type: 'boolean' },
|
||||
{ key: 'ANALYTICS_TOOLS_DETAIL', label: 'Analyse-Tools (Detailtext)', type: 'textarea', span: true },
|
||||
{ key: 'MARKETING_TOOLS_DETAIL', label: 'Marketing-Tools (Detailtext)', type: 'textarea', span: true },
|
||||
// ── Service-Features ─────────────────────────────────────────────────────
|
||||
{ key: 'HAS_ACCOUNT', label: 'Nutzerkonten vorhanden', type: 'boolean' },
|
||||
{ key: 'HAS_PAYMENTS', label: 'Zahlungsabwicklung vorhanden', type: 'boolean' },
|
||||
{ key: 'PAYMENT_PROVIDER_DETAIL', label: 'Zahlungsanbieter (Detailtext)', type: 'textarea', span: true },
|
||||
{ key: 'HAS_SUPPORT', label: 'Support-Funktion vorhanden', type: 'boolean' },
|
||||
{ key: 'SUPPORT_CHANNELS_TEXT', label: 'Support-Kanäle / Zeiten' },
|
||||
{ key: 'HAS_NEWSLETTER', label: 'Newsletter vorhanden', type: 'boolean' },
|
||||
{ key: 'NEWSLETTER_PROVIDER_DETAIL', label: 'Newsletter-Anbieter (Detailtext)', type: 'textarea', span: true },
|
||||
{ key: 'HAS_SOCIAL_MEDIA', label: 'Social-Media-Präsenz', type: 'boolean' },
|
||||
{ key: 'SOCIAL_MEDIA_DETAIL', label: 'Social-Media-Details', type: 'textarea', span: true },
|
||||
// ── AGB ──────────────────────────────────────────────────────────────────
|
||||
{ key: 'HAS_PAID_PLANS', label: 'Kostenpflichtige Pläne', type: 'boolean' },
|
||||
{ key: 'PRICES_TEXT', label: 'Preise (Text/Link)', type: 'textarea', span: true },
|
||||
{ key: 'PAYMENT_TERMS_TEXT', label: 'Zahlungsbedingungen', type: 'textarea', span: true },
|
||||
{ key: 'CONTRACT_TERM_TEXT', label: 'Laufzeit & Kündigung', type: 'textarea', span: true },
|
||||
{ key: 'HAS_SLA', label: 'SLA vorhanden', type: 'boolean' },
|
||||
{ key: 'SLA_URL', label: 'SLA-URL' },
|
||||
{ key: 'HAS_EXPORT_POLICY', label: 'Datenexport/Löschung geregelt', type: 'boolean' },
|
||||
{ key: 'EXPORT_POLICY_TEXT', label: 'Datenexport-Regelung (Text)', type: 'textarea', span: true },
|
||||
{ key: 'HAS_WITHDRAWAL', label: 'Widerrufsrecht (B2C digital)', type: 'boolean' },
|
||||
{ key: 'CONSUMER_WITHDRAWAL_TEXT', label: 'Widerrufsbelehrung (Text)', type: 'textarea', span: true },
|
||||
{ key: 'LIMITATION_CAP_TEXT', label: 'Haftungsdeckel B2B (Text)' },
|
||||
// ── Impressum ────────────────────────────────────────────────────────────
|
||||
{ key: 'HAS_REGULATED_PROFESSION', label: 'Reglementierter Beruf', type: 'boolean' },
|
||||
{ key: 'REGULATED_PROFESSION_TEXT', label: 'Berufsrecht-Text', type: 'textarea', span: true },
|
||||
{ key: 'HAS_EDITORIAL_RESPONSIBLE', label: 'V.i.S.d.P. (redaktionell)', type: 'boolean' },
|
||||
{ key: 'EDITORIAL_RESPONSIBLE_NAME', label: 'V.i.S.d.P. Name' },
|
||||
{ key: 'EDITORIAL_RESPONSIBLE_ADDRESS', label: 'V.i.S.d.P. Adresse' },
|
||||
{ key: 'HAS_DISPUTE_RESOLUTION', label: 'Streitbeilegungshinweis', type: 'boolean' },
|
||||
{ key: 'DISPUTE_RESOLUTION_TEXT', label: 'Streitbeilegungstext', type: 'textarea', span: true },
|
||||
],
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// SMALL COMPONENTS
|
||||
// =============================================================================
|
||||
|
||||
function LicenseBadge({ licenseId, small = false }: { licenseId: LicenseType | null; small?: boolean }) {
|
||||
if (!licenseId) return null
|
||||
const colors: Partial<Record<LicenseType, string>> = {
|
||||
public_domain: 'bg-green-100 text-green-700 border-green-200',
|
||||
cc0: 'bg-green-100 text-green-700 border-green-200',
|
||||
unlicense: 'bg-green-100 text-green-700 border-green-200',
|
||||
mit: 'bg-blue-100 text-blue-700 border-blue-200',
|
||||
cc_by_4: 'bg-purple-100 text-purple-700 border-purple-200',
|
||||
reuse_notice: 'bg-orange-100 text-orange-700 border-orange-200',
|
||||
}
|
||||
return (
|
||||
<span className={`${small ? 'px-1.5 py-0.5 text-[10px]' : 'px-2 py-1 text-xs'} rounded border ${colors[licenseId] || 'bg-gray-100 text-gray-600 border-gray-200'}`}>
|
||||
{LICENSE_TYPE_LABELS[licenseId] || licenseId}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// LIBRARY CARD
|
||||
// =============================================================================
|
||||
|
||||
function LibraryCard({
|
||||
template,
|
||||
expanded,
|
||||
onTogglePreview,
|
||||
onUse,
|
||||
}: {
|
||||
template: LegalTemplateResult
|
||||
expanded: boolean
|
||||
onTogglePreview: () => void
|
||||
onUse: () => void
|
||||
}) {
|
||||
const typeLabel = template.templateType
|
||||
? (TEMPLATE_TYPE_LABELS[template.templateType as TemplateType] || template.templateType)
|
||||
: null
|
||||
const placeholderCount = template.placeholders?.length ?? 0
|
||||
|
||||
return (
|
||||
<div className="bg-white rounded-xl border border-gray-200 overflow-hidden hover:border-purple-300 transition-colors">
|
||||
<div className="p-4">
|
||||
<div className="flex items-start justify-between gap-2 mb-2">
|
||||
<h3 className="font-medium text-gray-900 text-sm leading-snug">
|
||||
{template.documentTitle || 'Vorlage'}
|
||||
</h3>
|
||||
<span className="text-xs text-gray-400 uppercase shrink-0">{template.language}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 flex-wrap mb-3">
|
||||
{typeLabel && (
|
||||
<span className="text-xs text-purple-600 bg-purple-50 px-2 py-0.5 rounded">
|
||||
{typeLabel}
|
||||
</span>
|
||||
)}
|
||||
<LicenseBadge licenseId={template.licenseId as LicenseType} small />
|
||||
{placeholderCount > 0 && (
|
||||
<span className="text-xs text-gray-500">{placeholderCount} Platzh.</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={onTogglePreview}
|
||||
className="flex-1 text-xs px-3 py-1.5 border border-gray-200 rounded-lg hover:bg-gray-50 text-gray-600 transition-colors"
|
||||
>
|
||||
{expanded ? 'Vorschau ▲' : 'Vorschau ▼'}
|
||||
</button>
|
||||
<button
|
||||
onClick={onUse}
|
||||
className="flex-1 text-xs px-3 py-1.5 bg-purple-600 text-white rounded-lg hover:bg-purple-700 transition-colors"
|
||||
>
|
||||
Verwenden
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{expanded && (
|
||||
<div className="border-t border-gray-100 bg-gray-50 p-4 max-h-[32rem] overflow-y-auto">
|
||||
<pre className="text-xs text-gray-600 whitespace-pre-wrap font-mono leading-relaxed">
|
||||
{template.text}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// CONTEXT SECTION FORM
|
||||
// =============================================================================
|
||||
|
||||
function ContextSectionForm({
|
||||
section,
|
||||
context,
|
||||
onChange,
|
||||
}: {
|
||||
section: keyof TemplateContext
|
||||
context: TemplateContext
|
||||
onChange: (section: keyof TemplateContext, key: string, value: unknown) => void
|
||||
}) {
|
||||
const fields = SECTION_FIELDS[section]
|
||||
const sectionData = context[section] as unknown as Record<string, unknown>
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||
{fields.map((field) => {
|
||||
const rawValue = sectionData[field.key]
|
||||
const inputCls = 'w-full px-3 py-2 border border-gray-200 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-purple-400'
|
||||
|
||||
if (field.type === 'boolean') {
|
||||
return (
|
||||
<div key={field.key} className="flex items-center gap-2 py-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
id={`${section}-${field.key}`}
|
||||
checked={!!rawValue}
|
||||
onChange={(e) => onChange(section, field.key, e.target.checked)}
|
||||
className="w-4 h-4 accent-purple-600"
|
||||
/>
|
||||
<label htmlFor={`${section}-${field.key}`} className="text-sm text-gray-700">
|
||||
{field.label}
|
||||
</label>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (field.type === 'select' && field.opts) {
|
||||
return (
|
||||
<div key={field.key} className={field.span ? 'md:col-span-2' : ''}>
|
||||
<label className="block text-xs text-gray-500 mb-1">{field.label}</label>
|
||||
<select
|
||||
value={String(rawValue ?? '')}
|
||||
onChange={(e) => onChange(section, field.key, e.target.value)}
|
||||
className={inputCls}
|
||||
>
|
||||
{field.opts.map((o) => <option key={o} value={o}>{o}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (field.type === 'textarea') {
|
||||
return (
|
||||
<div key={field.key} className={field.span ? 'md:col-span-2' : ''}>
|
||||
<label className="block text-xs text-gray-500 mb-1">{field.label}</label>
|
||||
<textarea
|
||||
value={String(rawValue ?? '')}
|
||||
onChange={(e) => onChange(section, field.key, field.nullable && e.target.value === '' ? null : e.target.value)}
|
||||
rows={3}
|
||||
className={`${inputCls} resize-none`}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (field.type === 'number') {
|
||||
return (
|
||||
<div key={field.key} className={field.span ? 'md:col-span-2' : ''}>
|
||||
<label className="block text-xs text-gray-500 mb-1">{field.label}</label>
|
||||
<input
|
||||
type="number"
|
||||
value={rawValue === null || rawValue === undefined ? '' : String(rawValue)}
|
||||
onChange={(e) => {
|
||||
const v = e.target.value
|
||||
onChange(section, field.key, field.nullable && v === '' ? null : v === '' ? '' : Number(v))
|
||||
}}
|
||||
className={inputCls}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// default: text / email
|
||||
return (
|
||||
<div key={field.key} className={field.span ? 'md:col-span-2' : ''}>
|
||||
<label className="block text-xs text-gray-500 mb-1">{field.label}</label>
|
||||
<input
|
||||
type={field.type === 'email' ? 'email' : 'text'}
|
||||
value={rawValue === null || rawValue === undefined ? '' : String(rawValue)}
|
||||
onChange={(e) => onChange(section, field.key, field.nullable && e.target.value === '' ? null : e.target.value)}
|
||||
className={inputCls}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// GENERATOR SECTION
|
||||
// =============================================================================
|
||||
|
||||
// Available module definitions (id → display label)
|
||||
const MODULE_LABELS: Record<string, string> = {
|
||||
CLOUD_EXPORT_DELETE_DE: 'Datenexport & Löschrecht',
|
||||
B2C_WITHDRAWAL_DE: 'Widerrufsrecht (B2C)',
|
||||
}
|
||||
|
||||
function GeneratorSection({
|
||||
template,
|
||||
context,
|
||||
onContextChange,
|
||||
extraPlaceholders,
|
||||
onExtraChange,
|
||||
onClose,
|
||||
enabledModules,
|
||||
onModuleToggle,
|
||||
}: {
|
||||
template: LegalTemplateResult
|
||||
context: TemplateContext
|
||||
onContextChange: (section: keyof TemplateContext, key: string, value: unknown) => void
|
||||
extraPlaceholders: Record<string, string>
|
||||
onExtraChange: (key: string, value: string) => void
|
||||
onClose: () => void
|
||||
enabledModules: string[]
|
||||
onModuleToggle: (mod: string, checked: boolean) => void
|
||||
}) {
|
||||
const [activeTab, setActiveTab] = useState<'placeholders' | 'preview'>('placeholders')
|
||||
const [expandedSections, setExpandedSections] = useState<Set<string>>(new Set(['PROVIDER', 'LEGAL']))
|
||||
|
||||
const placeholders = template.placeholders || []
|
||||
const relevantSections = useMemo(() => getRelevantSections(placeholders), [placeholders])
|
||||
const uncovered = useMemo(() => getUncoveredPlaceholders(placeholders, context), [placeholders, context])
|
||||
const missing = useMemo(() => getMissingRequired(placeholders, context), [placeholders, context])
|
||||
|
||||
// Rule engine evaluation
|
||||
const ruleResult = useMemo((): RuleEngineResult | null => {
|
||||
if (!template) return null
|
||||
return runRuleset({
|
||||
doc_type: getDocType(template.templateType ?? '', template.language ?? 'de'),
|
||||
render: { lang: template.language ?? 'de', variant: 'standard' },
|
||||
context,
|
||||
modules: { enabled: enabledModules },
|
||||
} satisfies RuleInput)
|
||||
}, [template, context, enabledModules])
|
||||
|
||||
const allPlaceholderValues = useMemo(() => ({
|
||||
...contextToPlaceholders(ruleResult?.contextAfterDefaults ?? context),
|
||||
...extraPlaceholders,
|
||||
}), [context, extraPlaceholders, ruleResult])
|
||||
|
||||
// Boolean context for {{#IF}} rendering
|
||||
const boolCtx = useMemo(
|
||||
() => ruleResult ? buildBoolContext(ruleResult.contextAfterDefaults, ruleResult.computedFlags) : {},
|
||||
[ruleResult]
|
||||
)
|
||||
|
||||
const renderedContent = useMemo(() => {
|
||||
// 1. Remove ruleset-driven blocks ([BLOCK:ID])
|
||||
let content = applyBlockRemoval(template.text, ruleResult?.removedBlocks ?? [])
|
||||
// 2. Evaluate {{#IF}} / {{#IF_NOT}} / {{#IF_ANY}} directives
|
||||
content = applyConditionalBlocks(content, boolCtx)
|
||||
// 3. Substitute placeholders
|
||||
for (const [key, value] of Object.entries(allPlaceholderValues)) {
|
||||
if (value) {
|
||||
content = content.replace(new RegExp(key.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'g'), value)
|
||||
}
|
||||
}
|
||||
return content
|
||||
}, [template.text, allPlaceholderValues, ruleResult, boolCtx])
|
||||
|
||||
// Compute which modules are relevant (mentioned in violations/warnings)
|
||||
const relevantModules = useMemo(() => {
|
||||
if (!ruleResult) return []
|
||||
const mentioned = new Set<string>()
|
||||
const allIssues = [...ruleResult.violations, ...ruleResult.warnings]
|
||||
for (const issue of allIssues) {
|
||||
if (issue.phase === 'module_requirements') {
|
||||
// Extract module ID from message
|
||||
for (const modId of Object.keys(MODULE_LABELS)) {
|
||||
if (issue.message.includes(modId)) mentioned.add(modId)
|
||||
}
|
||||
}
|
||||
}
|
||||
// Also show modules that are enabled but not mentioned
|
||||
for (const mod of enabledModules) {
|
||||
if (mod in MODULE_LABELS) mentioned.add(mod)
|
||||
}
|
||||
return [...mentioned]
|
||||
}, [ruleResult, enabledModules])
|
||||
|
||||
const handleCopy = () => navigator.clipboard.writeText(renderedContent)
|
||||
|
||||
const handleExportMarkdown = () => {
|
||||
const blob = new Blob([renderedContent], { type: 'text/markdown' })
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = `${(template.documentTitle || 'dokument').replace(/\s+/g, '-').toLowerCase()}.md`
|
||||
a.click()
|
||||
URL.revokeObjectURL(url)
|
||||
}
|
||||
|
||||
const toggleSection = (sec: string) => {
|
||||
setExpandedSections((prev) => {
|
||||
const next = new Set(prev)
|
||||
if (next.has(sec)) next.delete(sec)
|
||||
else next.add(sec)
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
// Auto-expand all relevant sections on first render
|
||||
useEffect(() => {
|
||||
if (relevantSections.length > 0) {
|
||||
setExpandedSections(new Set(relevantSections))
|
||||
}
|
||||
}, [template.id]) // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
// Computed flags pills config
|
||||
const flagPills: { key: string; label: string; color: string }[] = ruleResult ? [
|
||||
{ key: 'IS_B2C', label: 'B2C', color: 'bg-blue-100 text-blue-700' },
|
||||
{ key: 'SERVICE_IS_SAAS', label: 'SaaS', color: 'bg-green-100 text-green-700' },
|
||||
{ key: 'HAS_PENALTY', label: 'Vertragsstrafe', color: 'bg-orange-100 text-orange-700' },
|
||||
{ key: 'HAS_ANALYTICS', label: 'Analytics', color: 'bg-gray-100 text-gray-600' },
|
||||
] : []
|
||||
|
||||
return (
|
||||
<div className="bg-white rounded-xl border-2 border-purple-300 overflow-hidden">
|
||||
{/* Header */}
|
||||
<div className="bg-purple-50 px-6 py-4 flex items-center justify-between">
|
||||
<div className="flex items-center gap-3 flex-wrap">
|
||||
<svg className="w-5 h-5 text-purple-600 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" />
|
||||
</svg>
|
||||
<div>
|
||||
<div className="text-xs text-purple-500 font-medium uppercase tracking-wide">Generator</div>
|
||||
<div className="font-semibold text-gray-900 text-sm">{template.documentTitle}</div>
|
||||
</div>
|
||||
{/* Computed flags pills */}
|
||||
{ruleResult && (
|
||||
<div className="flex gap-1.5 flex-wrap">
|
||||
{flagPills.map(({ key, label, color }) =>
|
||||
ruleResult.computedFlags[key] ? (
|
||||
<span key={key} className={`px-2 py-0.5 text-[10px] font-medium rounded-full ${color}`}>
|
||||
{label}
|
||||
</span>
|
||||
) : null
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<button onClick={onClose} className="text-gray-400 hover:text-gray-600 transition-colors shrink-0" aria-label="Schließen">
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Tab bar */}
|
||||
<div className="flex border-b border-gray-200 px-6">
|
||||
{(['placeholders', 'preview'] as const).map((tab) => (
|
||||
<button
|
||||
key={tab}
|
||||
onClick={() => setActiveTab(tab)}
|
||||
className={`px-4 py-3 text-sm font-medium transition-colors border-b-2 -mb-px ${
|
||||
activeTab === tab
|
||||
? 'text-purple-600 border-purple-600'
|
||||
: 'text-gray-500 border-transparent hover:text-gray-700'
|
||||
}`}
|
||||
>
|
||||
{tab === 'placeholders' ? 'Kontext ausfüllen' : 'Vorschau & Export'}
|
||||
{tab === 'placeholders' && missing.length > 0 && (
|
||||
<span className="ml-2 px-1.5 py-0.5 text-[10px] bg-orange-100 text-orange-600 rounded-full">
|
||||
{missing.length}
|
||||
</span>
|
||||
)}
|
||||
{tab === 'preview' && ruleResult && ruleResult.violations.length > 0 && (
|
||||
<span className="ml-2 px-1.5 py-0.5 text-[10px] bg-red-100 text-red-600 rounded-full">
|
||||
{ruleResult.violations.length}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Tab content */}
|
||||
<div className="p-6">
|
||||
{activeTab === 'placeholders' && (
|
||||
<div className="space-y-4">
|
||||
{placeholders.length === 0 ? (
|
||||
<div className="text-sm text-gray-500 text-center py-4">
|
||||
Keine Platzhalter — Vorlage kann direkt verwendet werden.
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{/* Relevant sections */}
|
||||
{relevantSections.length === 0 ? (
|
||||
<p className="text-sm text-gray-500">Alle Platzhalter müssen manuell befüllt werden.</p>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{relevantSections.map((section) => (
|
||||
<div key={section} className="border border-gray-200 rounded-xl overflow-hidden">
|
||||
<button
|
||||
onClick={() => toggleSection(section)}
|
||||
className="w-full flex items-center justify-between px-4 py-3 bg-gray-50 hover:bg-gray-100 transition-colors text-left"
|
||||
>
|
||||
<span className="text-sm font-medium text-gray-800">{SECTION_LABELS[section]}</span>
|
||||
<svg
|
||||
className={`w-4 h-4 text-gray-400 transition-transform ${expandedSections.has(section) ? 'rotate-180' : ''}`}
|
||||
fill="none" stroke="currentColor" viewBox="0 0 24 24"
|
||||
>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
|
||||
</svg>
|
||||
</button>
|
||||
{expandedSections.has(section) && (
|
||||
<div className="p-4 border-t border-gray-100">
|
||||
<ContextSectionForm
|
||||
section={section}
|
||||
context={context}
|
||||
onChange={onContextChange}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Uncovered / manual placeholders */}
|
||||
{uncovered.length > 0 && (
|
||||
<div className="border border-dashed border-gray-300 rounded-xl p-4">
|
||||
<p className="text-xs font-medium text-gray-500 uppercase tracking-wide mb-3">
|
||||
Weitere Platzhalter (manuell ausfüllen)
|
||||
</p>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||
{uncovered.map((ph) => (
|
||||
<div key={ph}>
|
||||
<label className="block text-xs text-gray-500 mb-1 font-mono">{ph}</label>
|
||||
<input
|
||||
type="text"
|
||||
value={extraPlaceholders[ph] || ''}
|
||||
onChange={(e) => onExtraChange(ph, e.target.value)}
|
||||
placeholder={`Wert für ${ph}`}
|
||||
className="w-full px-3 py-2 border border-gray-200 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-purple-400"
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Module toggles */}
|
||||
{relevantModules.length > 0 && (
|
||||
<div className="border border-gray-200 rounded-xl p-4">
|
||||
<p className="text-xs font-medium text-gray-500 uppercase tracking-wide mb-3">Module</p>
|
||||
<div className="space-y-2">
|
||||
{relevantModules.map((modId) => (
|
||||
<label key={modId} className="flex items-center gap-3 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={enabledModules.includes(modId)}
|
||||
onChange={(e) => onModuleToggle(modId, e.target.checked)}
|
||||
className="w-4 h-4 accent-purple-600"
|
||||
/>
|
||||
<span className="text-xs font-mono text-gray-600">{modId}</span>
|
||||
{MODULE_LABELS[modId] && (
|
||||
<span className="text-xs text-gray-500">{MODULE_LABELS[modId]}</span>
|
||||
)}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Validation summary + CTA */}
|
||||
<div className="flex items-center justify-between pt-2 flex-wrap gap-3">
|
||||
<div>
|
||||
{missing.length > 0 ? (
|
||||
<span className="text-sm text-orange-600">
|
||||
⚠ {missing.length} Pflichtfeld{missing.length > 1 ? 'er' : ''} fehlt{missing.length === 1 ? '' : 'en'}
|
||||
<span className="ml-1 text-xs text-orange-400">
|
||||
({missing.map((m) => m.replace(/\{\{|\}\}/g, '')).slice(0, 3).join(', ')}{missing.length > 3 ? ` +${missing.length - 3}` : ''})
|
||||
</span>
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-sm text-green-600">Alle Pflichtfelder ausgefüllt</span>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setActiveTab('preview')}
|
||||
className="px-5 py-2 bg-purple-600 text-white text-sm rounded-lg hover:bg-purple-700 transition-colors"
|
||||
>
|
||||
Zur Vorschau →
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'preview' && (
|
||||
<div className="space-y-4">
|
||||
{/* Rule engine banners */}
|
||||
{ruleResult && ruleResult.violations.length > 0 && (
|
||||
<div className="bg-red-50 border border-red-200 rounded-xl p-4">
|
||||
<p className="text-sm font-semibold text-red-700 mb-2">
|
||||
🔴 {ruleResult.violations.length} Fehler
|
||||
</p>
|
||||
<ul className="space-y-1">
|
||||
{ruleResult.violations.map((v) => (
|
||||
<li key={v.id} className="text-xs text-red-600">
|
||||
<span className="font-mono font-medium">[{v.id}]</span> {v.message}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
{ruleResult && ruleResult.warnings.filter((w) => w.id !== 'WARN_LEGAL_REVIEW').length > 0 && (
|
||||
<div className="bg-yellow-50 border border-yellow-200 rounded-xl p-4">
|
||||
<ul className="space-y-1">
|
||||
{ruleResult.warnings
|
||||
.filter((w) => w.id !== 'WARN_LEGAL_REVIEW')
|
||||
.map((w) => (
|
||||
<li key={w.id} className="text-xs text-yellow-700">
|
||||
🟡 <span className="font-mono font-medium">[{w.id}]</span> {w.message}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
{ruleResult && (
|
||||
<div className="bg-blue-50 border border-blue-200 rounded-xl p-3">
|
||||
<p className="text-xs text-blue-700">
|
||||
ℹ️ Rechtlicher Hinweis: Diese Vorlage ist MIT-lizenziert. Vor Produktionseinsatz
|
||||
wird eine rechtliche Überprüfung dringend empfohlen.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
{ruleResult && ruleResult.appliedDefaults.length > 0 && (
|
||||
<p className="text-xs text-gray-400">
|
||||
Defaults angewendet: {ruleResult.appliedDefaults.join(', ')}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="flex items-center justify-between flex-wrap gap-2">
|
||||
<span className="text-sm text-gray-600">
|
||||
{missing.length > 0 && (
|
||||
<span className="text-orange-600">
|
||||
⚠ {missing.length} Platzhalter noch nicht ausgefüllt
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={handleCopy}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 text-xs border border-gray-200 rounded-lg hover:bg-gray-50 text-gray-600 transition-colors"
|
||||
>
|
||||
<svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z" />
|
||||
</svg>
|
||||
Kopieren
|
||||
</button>
|
||||
<button
|
||||
onClick={handleExportMarkdown}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 text-xs border border-gray-200 rounded-lg hover:bg-gray-50 text-gray-600 transition-colors"
|
||||
>
|
||||
<svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" />
|
||||
</svg>
|
||||
Markdown
|
||||
</button>
|
||||
<button
|
||||
onClick={() => window.print()}
|
||||
className="flex items-center gap-1.5 px-4 py-1.5 text-xs bg-purple-600 text-white rounded-lg hover:bg-purple-700 transition-colors"
|
||||
>
|
||||
<svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M17 17h2a2 2 0 002-2v-4a2 2 0 00-2-2H5a2 2 0 00-2 2v4a2 2 0 002 2h2m2 4h6a2 2 0 002-2v-4a2 2 0 00-2-2H9a2 2 0 00-2 2v4a2 2 0 002 2zm8-12V5a2 2 0 00-2-2H9a2 2 0 00-2 2v4h10z" />
|
||||
</svg>
|
||||
PDF drucken
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-gray-50 rounded-xl border border-gray-200 p-6 max-h-[600px] overflow-y-auto">
|
||||
<pre className="text-sm text-gray-800 whitespace-pre-wrap leading-relaxed font-sans">
|
||||
{renderedContent}
|
||||
</pre>
|
||||
</div>
|
||||
{template.attributionRequired && template.attributionText && (
|
||||
<div className="text-xs text-orange-600 bg-orange-50 p-3 rounded-lg border border-orange-200">
|
||||
<strong>Attribution erforderlich:</strong> {template.attributionText}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// MAIN PAGE
|
||||
// =============================================================================
|
||||
|
||||
function DocumentGeneratorPageInner() {
|
||||
const { state } = useSDK()
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
"use client"
|
||||
|
||||
import React from "react"
|
||||
|
||||
const badgeBase = "inline-flex items-center px-2 py-0.5 rounded text-xs font-medium"
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Confidence Level Badge (E0–E4)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const confidenceColors: Record<string, string> = {
|
||||
E0: "bg-red-100 text-red-800",
|
||||
E1: "bg-yellow-100 text-yellow-800",
|
||||
E2: "bg-blue-100 text-blue-800",
|
||||
E3: "bg-green-100 text-green-800",
|
||||
E4: "bg-emerald-100 text-emerald-800",
|
||||
}
|
||||
|
||||
const confidenceLabels: Record<string, string> = {
|
||||
E0: "E0 — Generiert",
|
||||
E1: "E1 — Manuell",
|
||||
E2: "E2 — Intern validiert",
|
||||
E3: "E3 — System-beobachtet",
|
||||
E4: "E4 — Extern auditiert",
|
||||
}
|
||||
|
||||
export function ConfidenceLevelBadge({ level }: { level?: string | null }) {
|
||||
if (!level) return null
|
||||
const color = confidenceColors[level] || "bg-gray-100 text-gray-800"
|
||||
const label = confidenceLabels[level] || level
|
||||
return <span className={`${badgeBase} ${color}`}>{label}</span>
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Truth Status Badge
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const truthColors: Record<string, string> = {
|
||||
generated: "bg-violet-100 text-violet-800",
|
||||
uploaded: "bg-gray-100 text-gray-800",
|
||||
observed: "bg-blue-100 text-blue-800",
|
||||
validated: "bg-green-100 text-green-800",
|
||||
rejected: "bg-red-100 text-red-800",
|
||||
audited: "bg-emerald-100 text-emerald-800",
|
||||
}
|
||||
|
||||
const truthLabels: Record<string, string> = {
|
||||
generated: "Generiert",
|
||||
uploaded: "Hochgeladen",
|
||||
observed: "Beobachtet",
|
||||
validated: "Validiert",
|
||||
rejected: "Abgelehnt",
|
||||
audited: "Auditiert",
|
||||
}
|
||||
|
||||
export function TruthStatusBadge({ status }: { status?: string | null }) {
|
||||
if (!status) return null
|
||||
const color = truthColors[status] || "bg-gray-100 text-gray-800"
|
||||
const label = truthLabels[status] || status
|
||||
return <span className={`${badgeBase} ${color}`}>{label}</span>
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Generation Mode Badge (sparkles icon)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function GenerationModeBadge({ mode }: { mode?: string | null }) {
|
||||
if (!mode) return null
|
||||
return (
|
||||
<span className={`${badgeBase} bg-violet-100 text-violet-800`}>
|
||||
<svg className="w-3 h-3 mr-1" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path d="M5 2a1 1 0 011 1v1h1a1 1 0 010 2H6v1a1 1 0 01-2 0V6H3a1 1 0 010-2h1V3a1 1 0 011-1zm0 10a1 1 0 011 1v1h1a1 1 0 010 2H6v1a1 1 0 01-2 0v-1H3a1 1 0 010-2h1v-1a1 1 0 011-1zm7-10a1 1 0 01.967.744L14.146 7.2 17.5 7.512a1 1 0 010 1.976l-3.354.313-1.18 4.456a1 1 0 01-1.932 0l-1.18-4.456-3.354-.313a1 1 0 010-1.976l3.354-.313 1.18-4.456A1 1 0 0112 2z" />
|
||||
</svg>
|
||||
KI-generiert
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Approval Status Badge (Four-Eyes)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const approvalColors: Record<string, string> = {
|
||||
none: "bg-gray-100 text-gray-600",
|
||||
pending_first: "bg-yellow-100 text-yellow-800",
|
||||
first_approved: "bg-blue-100 text-blue-800",
|
||||
approved: "bg-green-100 text-green-800",
|
||||
rejected: "bg-red-100 text-red-800",
|
||||
}
|
||||
|
||||
const approvalLabels: Record<string, string> = {
|
||||
none: "Kein Review",
|
||||
pending_first: "Warte auf 1. Review",
|
||||
first_approved: "1. Review OK",
|
||||
approved: "Genehmigt (4-Augen)",
|
||||
rejected: "Abgelehnt",
|
||||
}
|
||||
|
||||
export function ApprovalStatusBadge({
|
||||
status,
|
||||
requiresFourEyes,
|
||||
}: {
|
||||
status?: string | null
|
||||
requiresFourEyes?: boolean | null
|
||||
}) {
|
||||
if (!requiresFourEyes) return null
|
||||
const s = status || "none"
|
||||
const color = approvalColors[s] || "bg-gray-100 text-gray-600"
|
||||
const label = approvalLabels[s] || s
|
||||
return <span className={`${badgeBase} ${color}`}>{label}</span>
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -12,6 +12,46 @@ interface Component {
|
||||
safety_relevant: boolean
|
||||
parent_id: string | null
|
||||
children: Component[]
|
||||
library_component_id?: string
|
||||
energy_source_ids?: string[]
|
||||
}
|
||||
|
||||
interface LibraryComponent {
|
||||
id: string
|
||||
name_de: string
|
||||
name_en: string
|
||||
category: string
|
||||
description_de: string
|
||||
typical_hazard_categories: string[]
|
||||
typical_energy_sources: string[]
|
||||
maps_to_component_type: string
|
||||
tags: string[]
|
||||
sort_order: number
|
||||
}
|
||||
|
||||
interface EnergySource {
|
||||
id: string
|
||||
name_de: string
|
||||
name_en: string
|
||||
description_de: string
|
||||
typical_components: string[]
|
||||
typical_hazard_categories: string[]
|
||||
tags: string[]
|
||||
sort_order: number
|
||||
}
|
||||
|
||||
const LIBRARY_CATEGORIES: Record<string, string> = {
|
||||
mechanical: 'Mechanik',
|
||||
structural: 'Struktur',
|
||||
drive: 'Antrieb',
|
||||
hydraulic: 'Hydraulik',
|
||||
pneumatic: 'Pneumatik',
|
||||
electrical: 'Elektrik',
|
||||
control: 'Steuerung',
|
||||
sensor: 'Sensorik',
|
||||
actuator: 'Aktorik',
|
||||
safety: 'Sicherheit',
|
||||
it_network: 'IT/Netzwerk',
|
||||
}
|
||||
|
||||
const COMPONENT_TYPES = [
|
||||
@@ -98,6 +138,11 @@ function ComponentTreeNode({
|
||||
Sicherheitsrelevant
|
||||
</span>
|
||||
)}
|
||||
{component.library_component_id && (
|
||||
<span className="ml-2 inline-flex items-center px-1.5 py-0.5 rounded text-xs font-medium bg-purple-100 text-purple-700">
|
||||
Bibliothek
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{component.description && (
|
||||
@@ -289,6 +334,289 @@ function buildTree(components: Component[]): Component[] {
|
||||
return roots
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Component Library Modal (Phase 5)
|
||||
// ============================================================================
|
||||
|
||||
function ComponentLibraryModal({
|
||||
onAdd,
|
||||
onClose,
|
||||
}: {
|
||||
onAdd: (components: LibraryComponent[], energySources: EnergySource[]) => void
|
||||
onClose: () => void
|
||||
}) {
|
||||
const [libraryComponents, setLibraryComponents] = useState<LibraryComponent[]>([])
|
||||
const [energySources, setEnergySources] = useState<EnergySource[]>([])
|
||||
const [selectedComponents, setSelectedComponents] = useState<Set<string>>(new Set())
|
||||
const [selectedEnergySources, setSelectedEnergySources] = useState<Set<string>>(new Set())
|
||||
const [search, setSearch] = useState('')
|
||||
const [filterCategory, setFilterCategory] = useState('')
|
||||
const [activeTab, setActiveTab] = useState<'components' | 'energy'>('components')
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
useEffect(() => {
|
||||
async function fetchData() {
|
||||
try {
|
||||
const [compRes, enRes] = await Promise.all([
|
||||
fetch('/api/sdk/v1/iace/component-library'),
|
||||
fetch('/api/sdk/v1/iace/energy-sources'),
|
||||
])
|
||||
if (compRes.ok) {
|
||||
const json = await compRes.json()
|
||||
setLibraryComponents(json.components || [])
|
||||
}
|
||||
if (enRes.ok) {
|
||||
const json = await enRes.json()
|
||||
setEnergySources(json.energy_sources || [])
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch library:', err)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
fetchData()
|
||||
}, [])
|
||||
|
||||
function toggleComponent(id: string) {
|
||||
setSelectedComponents(prev => {
|
||||
const next = new Set(prev)
|
||||
if (next.has(id)) next.delete(id)
|
||||
else next.add(id)
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
function toggleEnergySource(id: string) {
|
||||
setSelectedEnergySources(prev => {
|
||||
const next = new Set(prev)
|
||||
if (next.has(id)) next.delete(id)
|
||||
else next.add(id)
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
function toggleAllInCategory(category: string) {
|
||||
const items = libraryComponents.filter(c => c.category === category)
|
||||
const allIds = items.map(i => i.id)
|
||||
const allSelected = allIds.every(id => selectedComponents.has(id))
|
||||
setSelectedComponents(prev => {
|
||||
const next = new Set(prev)
|
||||
allIds.forEach(id => allSelected ? next.delete(id) : next.add(id))
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
function handleAdd() {
|
||||
const selComps = libraryComponents.filter(c => selectedComponents.has(c.id))
|
||||
const selEnergy = energySources.filter(e => selectedEnergySources.has(e.id))
|
||||
onAdd(selComps, selEnergy)
|
||||
}
|
||||
|
||||
const filtered = libraryComponents.filter(c => {
|
||||
if (filterCategory && c.category !== filterCategory) return false
|
||||
if (search) {
|
||||
const q = search.toLowerCase()
|
||||
return c.name_de.toLowerCase().includes(q) || c.name_en.toLowerCase().includes(q) || c.description_de.toLowerCase().includes(q)
|
||||
}
|
||||
return true
|
||||
})
|
||||
|
||||
const grouped = filtered.reduce<Record<string, LibraryComponent[]>>((acc, c) => {
|
||||
if (!acc[c.category]) acc[c.category] = []
|
||||
acc[c.category].push(c)
|
||||
return acc
|
||||
}, {})
|
||||
|
||||
const categories = Object.keys(LIBRARY_CATEGORIES)
|
||||
const totalSelected = selectedComponents.size + selectedEnergySources.size
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
|
||||
<div className="bg-white dark:bg-gray-800 rounded-xl p-8 text-center">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-purple-600 mx-auto" />
|
||||
<p className="mt-3 text-sm text-gray-500">Bibliothek wird geladen...</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
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-4xl max-h-[85vh] flex flex-col">
|
||||
{/* Header */}
|
||||
<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">Komponentenbibliothek</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>
|
||||
|
||||
{/* Tabs */}
|
||||
<div className="flex gap-2 mb-4">
|
||||
<button
|
||||
onClick={() => setActiveTab('components')}
|
||||
className={`px-4 py-2 text-sm font-medium rounded-lg transition-colors ${
|
||||
activeTab === 'components' ? 'bg-purple-100 text-purple-700' : 'text-gray-500 hover:bg-gray-100'
|
||||
}`}
|
||||
>
|
||||
Komponenten ({libraryComponents.length})
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab('energy')}
|
||||
className={`px-4 py-2 text-sm font-medium rounded-lg transition-colors ${
|
||||
activeTab === 'energy' ? 'bg-purple-100 text-purple-700' : 'text-gray-500 hover:bg-gray-100'
|
||||
}`}
|
||||
>
|
||||
Energiequellen ({energySources.length})
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{activeTab === 'components' && (
|
||||
<div className="flex gap-3">
|
||||
<input
|
||||
type="text"
|
||||
value={search}
|
||||
onChange={e => setSearch(e.target.value)}
|
||||
placeholder="Suchen..."
|
||||
className="flex-1 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"
|
||||
/>
|
||||
<select
|
||||
value={filterCategory}
|
||||
onChange={e => setFilterCategory(e.target.value)}
|
||||
className="px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 dark:bg-gray-700 dark:border-gray-600 dark:text-white"
|
||||
>
|
||||
<option value="">Alle Kategorien</option>
|
||||
{categories.map(cat => (
|
||||
<option key={cat} value={cat}>{LIBRARY_CATEGORIES[cat]}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Body */}
|
||||
<div className="flex-1 overflow-auto p-4">
|
||||
{activeTab === 'components' ? (
|
||||
<div className="space-y-4">
|
||||
{Object.entries(grouped)
|
||||
.sort(([a], [b]) => categories.indexOf(a) - categories.indexOf(b))
|
||||
.map(([category, items]) => (
|
||||
<div key={category}>
|
||||
<div className="flex items-center gap-2 mb-2 sticky top-0 bg-white dark:bg-gray-800 py-1 z-10">
|
||||
<h4 className="text-sm font-semibold text-gray-700 dark:text-gray-300">
|
||||
{LIBRARY_CATEGORIES[category] || category}
|
||||
</h4>
|
||||
<span className="text-xs text-gray-400">({items.length})</span>
|
||||
<button
|
||||
onClick={() => toggleAllInCategory(category)}
|
||||
className="text-xs text-purple-600 hover:text-purple-700 ml-auto"
|
||||
>
|
||||
{items.every(i => selectedComponents.has(i.id)) ? 'Alle abwaehlen' : 'Alle waehlen'}
|
||||
</button>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-2">
|
||||
{items.map(comp => (
|
||||
<label
|
||||
key={comp.id}
|
||||
className={`flex items-start gap-3 p-3 rounded-lg border cursor-pointer transition-colors ${
|
||||
selectedComponents.has(comp.id)
|
||||
? 'border-purple-400 bg-purple-50 dark:bg-purple-900/20'
|
||||
: 'border-gray-200 hover:bg-gray-50 dark:border-gray-700 dark:hover:bg-gray-750'
|
||||
}`}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedComponents.has(comp.id)}
|
||||
onChange={() => toggleComponent(comp.id)}
|
||||
className="mt-0.5 accent-purple-600"
|
||||
/>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs font-mono text-gray-400">{comp.id}</span>
|
||||
<ComponentTypeIcon type={comp.maps_to_component_type} />
|
||||
</div>
|
||||
<div className="text-sm font-medium text-gray-900 dark:text-white">{comp.name_de}</div>
|
||||
{comp.description_de && (
|
||||
<div className="text-xs text-gray-500 mt-0.5 line-clamp-2">{comp.description_de}</div>
|
||||
)}
|
||||
</div>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{filtered.length === 0 && (
|
||||
<div className="text-center py-8 text-gray-500">Keine Komponenten gefunden</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-2">
|
||||
{energySources.map(es => (
|
||||
<label
|
||||
key={es.id}
|
||||
className={`flex items-start gap-3 p-3 rounded-lg border cursor-pointer transition-colors ${
|
||||
selectedEnergySources.has(es.id)
|
||||
? 'border-purple-400 bg-purple-50 dark:bg-purple-900/20'
|
||||
: 'border-gray-200 hover:bg-gray-50 dark:border-gray-700 dark:hover:bg-gray-750'
|
||||
}`}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedEnergySources.has(es.id)}
|
||||
onChange={() => toggleEnergySource(es.id)}
|
||||
className="mt-0.5 accent-purple-600"
|
||||
/>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs font-mono text-gray-400">{es.id}</span>
|
||||
</div>
|
||||
<div className="text-sm font-medium text-gray-900 dark:text-white">{es.name_de}</div>
|
||||
{es.description_de && (
|
||||
<div className="text-xs text-gray-500 mt-0.5 line-clamp-2">{es.description_de}</div>
|
||||
)}
|
||||
</div>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="p-4 border-t border-gray-200 dark:border-gray-700 flex items-center justify-between">
|
||||
<span className="text-sm text-gray-500">
|
||||
{selectedComponents.size} Komponenten, {selectedEnergySources.size} Energiequellen ausgewaehlt
|
||||
</span>
|
||||
<div className="flex gap-3">
|
||||
<button onClick={onClose} className="px-4 py-2 text-gray-600 hover:bg-gray-100 rounded-lg transition-colors">
|
||||
Abbrechen
|
||||
</button>
|
||||
<button
|
||||
onClick={handleAdd}
|
||||
disabled={totalSelected === 0}
|
||||
className={`px-6 py-2 rounded-lg font-medium transition-colors ${
|
||||
totalSelected > 0
|
||||
? 'bg-purple-600 text-white hover:bg-purple-700'
|
||||
: 'bg-gray-200 text-gray-400 cursor-not-allowed'
|
||||
}`}
|
||||
>
|
||||
{totalSelected > 0 ? `${totalSelected} hinzufuegen` : 'Auswaehlen'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Main Page
|
||||
// ============================================================================
|
||||
|
||||
export default function ComponentsPage() {
|
||||
const params = useParams()
|
||||
const projectId = params.projectId as string
|
||||
@@ -297,6 +625,7 @@ export default function ComponentsPage() {
|
||||
const [showForm, setShowForm] = useState(false)
|
||||
const [editingComponent, setEditingComponent] = useState<Component | null>(null)
|
||||
const [addingParentId, setAddingParentId] = useState<string | null>(null)
|
||||
const [showLibrary, setShowLibrary] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
fetchComponents()
|
||||
@@ -365,6 +694,32 @@ export default function ComponentsPage() {
|
||||
setShowForm(true)
|
||||
}
|
||||
|
||||
async function handleAddFromLibrary(libraryComps: LibraryComponent[], energySrcs: EnergySource[]) {
|
||||
setShowLibrary(false)
|
||||
const energySourceIds = energySrcs.map(e => e.id)
|
||||
|
||||
for (const comp of libraryComps) {
|
||||
try {
|
||||
await fetch(`/api/sdk/v1/iace/projects/${projectId}/components`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
name: comp.name_de,
|
||||
type: comp.maps_to_component_type,
|
||||
description: comp.description_de,
|
||||
safety_relevant: false,
|
||||
library_component_id: comp.id,
|
||||
energy_source_ids: energySourceIds,
|
||||
tags: comp.tags,
|
||||
}),
|
||||
})
|
||||
} catch (err) {
|
||||
console.error(`Failed to add component ${comp.id}:`, err)
|
||||
}
|
||||
}
|
||||
await fetchComponents()
|
||||
}
|
||||
|
||||
const tree = buildTree(components)
|
||||
|
||||
if (loading) {
|
||||
@@ -386,22 +741,41 @@ export default function ComponentsPage() {
|
||||
</p>
|
||||
</div>
|
||||
{!showForm && (
|
||||
<button
|
||||
onClick={() => {
|
||||
setShowForm(true)
|
||||
setEditingComponent(null)
|
||||
setAddingParentId(null)
|
||||
}}
|
||||
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">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 6v6m0 0v6m0-6h6m-6 0H6" />
|
||||
</svg>
|
||||
Komponente hinzufuegen
|
||||
</button>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => setShowLibrary(true)}
|
||||
className="flex items-center gap-2 px-3 py-2 border border-purple-300 text-purple-700 rounded-lg hover:bg-purple-50 transition-colors text-sm"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.247 18 16.5 18c-1.746 0-3.332.477-4.5 1.253" />
|
||||
</svg>
|
||||
Aus Bibliothek waehlen
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
setShowForm(true)
|
||||
setEditingComponent(null)
|
||||
setAddingParentId(null)
|
||||
}}
|
||||
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">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 6v6m0 0v6m0-6h6m-6 0H6" />
|
||||
</svg>
|
||||
Komponente hinzufuegen
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Library Modal */}
|
||||
{showLibrary && (
|
||||
<ComponentLibraryModal
|
||||
onAdd={handleAddFromLibrary}
|
||||
onClose={() => setShowLibrary(false)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Form */}
|
||||
{showForm && (
|
||||
<ComponentForm
|
||||
@@ -454,12 +828,20 @@ export default function ComponentsPage() {
|
||||
Beginnen Sie mit der Erfassung aller relevanten Komponenten Ihrer Maschine.
|
||||
Erstellen Sie eine hierarchische Struktur aus Software, Firmware, KI-Modulen und Hardware.
|
||||
</p>
|
||||
<button
|
||||
onClick={() => setShowForm(true)}
|
||||
className="mt-6 px-6 py-3 bg-purple-600 text-white rounded-lg hover:bg-purple-700 transition-colors"
|
||||
>
|
||||
Erste Komponente hinzufuegen
|
||||
</button>
|
||||
<div className="mt-6 flex items-center justify-center gap-3">
|
||||
<button
|
||||
onClick={() => setShowLibrary(true)}
|
||||
className="px-6 py-3 border border-purple-300 text-purple-700 rounded-lg hover:bg-purple-50 transition-colors"
|
||||
>
|
||||
Aus Bibliothek waehlen
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setShowForm(true)}
|
||||
className="px-6 py-3 bg-purple-600 text-white rounded-lg hover:bg-purple-700 transition-colors"
|
||||
>
|
||||
Manuell hinzufuegen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -14,20 +14,51 @@ interface Mitigation {
|
||||
created_at: string
|
||||
verified_at: string | null
|
||||
verified_by: string | null
|
||||
source?: string
|
||||
}
|
||||
|
||||
interface Hazard {
|
||||
id: string
|
||||
name: string
|
||||
risk_level: string
|
||||
category?: string
|
||||
}
|
||||
|
||||
interface ProtectiveMeasure {
|
||||
id: string
|
||||
reduction_type: string
|
||||
sub_type: string
|
||||
name: string
|
||||
description: string
|
||||
hazard_category: string
|
||||
examples: string[]
|
||||
}
|
||||
|
||||
interface SuggestedMeasure {
|
||||
id: string
|
||||
reduction_type: string
|
||||
sub_type: string
|
||||
name: string
|
||||
description: string
|
||||
hazard_category: string
|
||||
examples: string[]
|
||||
tags?: string[]
|
||||
}
|
||||
|
||||
const REDUCTION_TYPES = {
|
||||
design: {
|
||||
label: 'Design',
|
||||
label: 'Stufe 1: Design',
|
||||
description: 'Inhaerent sichere Konstruktion',
|
||||
color: 'border-blue-200 bg-blue-50',
|
||||
headerColor: 'bg-blue-100 text-blue-800',
|
||||
subTypes: [
|
||||
{ value: 'geometry', label: 'Geometrie & Anordnung' },
|
||||
{ value: 'force_energy', label: 'Kraft & Energie' },
|
||||
{ value: 'material', label: 'Material & Stabilitaet' },
|
||||
{ value: 'ergonomics', label: 'Ergonomie' },
|
||||
{ value: 'control_design', label: 'Steuerungstechnik' },
|
||||
{ value: 'fluid_design', label: 'Pneumatik / Hydraulik' },
|
||||
],
|
||||
icon: (
|
||||
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M11 4a2 2 0 114 0v1a1 1 0 001 1h3a1 1 0 011 1v3a1 1 0 01-1 1h-1a2 2 0 100 4h1a1 1 0 011 1v3a1 1 0 01-1 1h-3a1 1 0 01-1-1v-1a2 2 0 10-4 0v1a1 1 0 01-1 1H7a1 1 0 01-1-1v-3a1 1 0 00-1-1H4a2 2 0 110-4h1a1 1 0 001-1V7a1 1 0 011-1h3a1 1 0 001-1V4z" />
|
||||
@@ -35,10 +66,21 @@ const REDUCTION_TYPES = {
|
||||
),
|
||||
},
|
||||
protection: {
|
||||
label: 'Schutz',
|
||||
label: 'Stufe 2: Schutz',
|
||||
description: 'Technische Schutzmassnahmen',
|
||||
color: 'border-green-200 bg-green-50',
|
||||
headerColor: 'bg-green-100 text-green-800',
|
||||
subTypes: [
|
||||
{ value: 'fixed_guard', label: 'Feststehende Schutzeinrichtung' },
|
||||
{ value: 'movable_guard', label: 'Bewegliche Schutzeinrichtung' },
|
||||
{ value: 'electro_sensitive', label: 'Optoelektronisch' },
|
||||
{ value: 'pressure_sensitive', label: 'Druckempfindlich' },
|
||||
{ value: 'emergency_stop', label: 'Not-Halt' },
|
||||
{ value: 'electrical_protection', label: 'Elektrischer Schutz' },
|
||||
{ value: 'thermal_protection', label: 'Thermischer Schutz' },
|
||||
{ value: 'fluid_protection', label: 'Hydraulik/Pneumatik-Schutz' },
|
||||
{ value: 'extraction', label: 'Absaugung / Kapselung' },
|
||||
],
|
||||
icon: (
|
||||
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z" />
|
||||
@@ -46,10 +88,18 @@ const REDUCTION_TYPES = {
|
||||
),
|
||||
},
|
||||
information: {
|
||||
label: 'Information',
|
||||
label: 'Stufe 3: Information',
|
||||
description: 'Hinweise und Schulungen',
|
||||
color: 'border-yellow-200 bg-yellow-50',
|
||||
headerColor: 'bg-yellow-100 text-yellow-800',
|
||||
subTypes: [
|
||||
{ value: 'signage', label: 'Beschilderung & Kennzeichnung' },
|
||||
{ value: 'manual', label: 'Betriebsanleitung' },
|
||||
{ value: 'training', label: 'Schulung & Unterweisung' },
|
||||
{ value: 'ppe', label: 'PSA (Schutzausruestung)' },
|
||||
{ value: 'organizational', label: 'Organisatorisch' },
|
||||
{ value: 'marking', label: 'Markierung & Codierung' },
|
||||
],
|
||||
icon: (
|
||||
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
@@ -76,6 +126,281 @@ function StatusBadge({ status }: { status: string }) {
|
||||
)
|
||||
}
|
||||
|
||||
function HierarchyWarning({ onDismiss }: { onDismiss: () => void }) {
|
||||
return (
|
||||
<div className="bg-amber-50 border border-amber-300 rounded-xl p-4 flex items-start gap-3">
|
||||
<svg className="w-6 h-6 text-amber-600 flex-shrink-0 mt-0.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
|
||||
</svg>
|
||||
<div className="flex-1">
|
||||
<h4 className="text-sm font-semibold text-amber-800">Hierarchie-Warnung: Massnahmen vom Typ "Information"</h4>
|
||||
<p className="text-sm text-amber-700 mt-1">
|
||||
Hinweismassnahmen (Stufe 3) duerfen <strong>nicht als Primaermassnahme</strong> akzeptiert werden, wenn konstruktive
|
||||
(Stufe 1) oder technische (Stufe 2) Massnahmen moeglich und zumutbar sind. Pruefen Sie, ob hoeherwertige
|
||||
Massnahmen ergaenzt werden koennen.
|
||||
</p>
|
||||
</div>
|
||||
<button onClick={onDismiss} className="text-amber-400 hover:text-amber-600 transition-colors">
|
||||
<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>
|
||||
)
|
||||
}
|
||||
|
||||
function MeasuresLibraryModal({
|
||||
measures,
|
||||
onSelect,
|
||||
onClose,
|
||||
filterType,
|
||||
}: {
|
||||
measures: ProtectiveMeasure[]
|
||||
onSelect: (measure: ProtectiveMeasure) => void
|
||||
onClose: () => void
|
||||
filterType?: string
|
||||
}) {
|
||||
const [search, setSearch] = useState('')
|
||||
const [selectedSubType, setSelectedSubType] = useState('')
|
||||
|
||||
const filtered = measures.filter((m) => {
|
||||
if (filterType && m.reduction_type !== filterType) return false
|
||||
if (selectedSubType && m.sub_type !== selectedSubType) return false
|
||||
if (search) {
|
||||
const q = search.toLowerCase()
|
||||
return m.name.toLowerCase().includes(q) || m.description.toLowerCase().includes(q)
|
||||
}
|
||||
return true
|
||||
})
|
||||
|
||||
const subTypes = [...new Set(measures.filter((m) => !filterType || m.reduction_type === filterType).map((m) => m.sub_type))].filter(Boolean)
|
||||
|
||||
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-[80vh] 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">Massnahmen-Bibliothek</h3>
|
||||
<button onClick={onClose} className="p-1 text-gray-400 hover:text-gray-600 rounded transition-colors">
|
||||
<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>
|
||||
<div className="flex gap-3">
|
||||
<input
|
||||
type="text"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
placeholder="Massnahme suchen..."
|
||||
className="flex-1 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"
|
||||
/>
|
||||
{subTypes.length > 1 && (
|
||||
<select
|
||||
value={selectedSubType}
|
||||
onChange={(e) => setSelectedSubType(e.target.value)}
|
||||
className="px-3 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 text-sm"
|
||||
>
|
||||
<option value="">Alle Sub-Typen</option>
|
||||
{subTypes.map((st) => (
|
||||
<option key={st} value={st}>{st}</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-2 text-xs text-gray-500">{filtered.length} Massnahmen</div>
|
||||
</div>
|
||||
<div className="flex-1 overflow-y-auto p-6 space-y-3">
|
||||
{filtered.map((m) => (
|
||||
<div
|
||||
key={m.id}
|
||||
className="border border-gray-200 dark:border-gray-700 rounded-lg p-4 hover:border-purple-300 hover:bg-purple-50/30 transition-colors cursor-pointer"
|
||||
onClick={() => onSelect(m)}
|
||||
>
|
||||
<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">{m.id}</span>
|
||||
{m.sub_type && (
|
||||
<span className="text-xs px-1.5 py-0.5 rounded bg-gray-100 text-gray-600">{m.sub_type}</span>
|
||||
)}
|
||||
</div>
|
||||
<h4 className="text-sm font-medium text-gray-900 dark:text-white">{m.name}</h4>
|
||||
<p className="text-xs text-gray-500 mt-1">{m.description}</p>
|
||||
{m.examples && m.examples.length > 0 && (
|
||||
<div className="mt-2 flex flex-wrap gap-1">
|
||||
{m.examples.map((ex, i) => (
|
||||
<span key={i} className="text-xs px-1.5 py-0.5 rounded bg-purple-50 text-purple-600">
|
||||
{ex}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<button className="ml-3 px-3 py-1.5 text-xs bg-purple-100 text-purple-700 rounded-lg hover:bg-purple-200 transition-colors flex-shrink-0">
|
||||
Uebernehmen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{filtered.length === 0 && (
|
||||
<div className="text-center py-8 text-gray-500">Keine Massnahmen gefunden</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Suggest Measures Modal (Phase 5)
|
||||
// ============================================================================
|
||||
|
||||
function SuggestMeasuresModal({
|
||||
hazards,
|
||||
projectId,
|
||||
onAddMeasure,
|
||||
onClose,
|
||||
}: {
|
||||
hazards: Hazard[]
|
||||
projectId: string
|
||||
onAddMeasure: (title: string, description: string, reductionType: string, hazardId: string) => void
|
||||
onClose: () => void
|
||||
}) {
|
||||
const [selectedHazard, setSelectedHazard] = useState<string>('')
|
||||
const [suggested, setSuggested] = useState<SuggestedMeasure[]>([])
|
||||
const [loadingSuggestions, setLoadingSuggestions] = useState(false)
|
||||
|
||||
const riskColors: Record<string, string> = {
|
||||
not_acceptable: 'border-red-400 bg-red-50',
|
||||
very_high: 'border-red-300 bg-red-50',
|
||||
critical: 'border-red-300 bg-red-50',
|
||||
high: 'border-orange-300 bg-orange-50',
|
||||
medium: 'border-yellow-300 bg-yellow-50',
|
||||
low: 'border-green-300 bg-green-50',
|
||||
}
|
||||
|
||||
async function handleSelectHazard(hazardId: string) {
|
||||
setSelectedHazard(hazardId)
|
||||
setSuggested([])
|
||||
if (!hazardId) return
|
||||
|
||||
setLoadingSuggestions(true)
|
||||
try {
|
||||
const res = await fetch(`/api/sdk/v1/iace/projects/${projectId}/hazards/${hazardId}/suggest-measures`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
})
|
||||
if (res.ok) {
|
||||
const json = await res.json()
|
||||
setSuggested(json.suggested_measures || [])
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to suggest measures:', err)
|
||||
} finally {
|
||||
setLoadingSuggestions(false)
|
||||
}
|
||||
}
|
||||
|
||||
const groupedByType = {
|
||||
design: suggested.filter(m => m.reduction_type === 'design'),
|
||||
protection: suggested.filter(m => m.reduction_type === 'protection'),
|
||||
information: suggested.filter(m => m.reduction_type === 'information'),
|
||||
}
|
||||
|
||||
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">Massnahmen-Vorschlaege</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 Gefaehrdung, um passende Massnahmen vorgeschlagen zu bekommen.
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{hazards.map(h => (
|
||||
<button
|
||||
key={h.id}
|
||||
onClick={() => handleSelectHazard(h.id)}
|
||||
className={`px-3 py-1.5 text-xs rounded-lg border transition-colors ${
|
||||
selectedHazard === h.id
|
||||
? 'border-purple-400 bg-purple-50 text-purple-700 font-medium'
|
||||
: `${riskColors[h.risk_level] || 'border-gray-200 bg-white'} text-gray-700 hover:border-purple-300`
|
||||
}`}
|
||||
>
|
||||
{h.name}
|
||||
</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-6">
|
||||
{(['design', 'protection', 'information'] as const).map(type => {
|
||||
const items = groupedByType[type]
|
||||
if (items.length === 0) return null
|
||||
const config = REDUCTION_TYPES[type]
|
||||
return (
|
||||
<div key={type}>
|
||||
<div className={`flex items-center gap-2 px-3 py-2 rounded-lg ${config.headerColor} mb-3`}>
|
||||
{config.icon}
|
||||
<span className="text-sm font-semibold">{config.label}</span>
|
||||
<span className="ml-auto text-sm font-bold">{items.length}</span>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{items.map(m => (
|
||||
<div key={m.id} className="border border-gray-200 rounded-lg p-3 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">{m.id}</span>
|
||||
{m.sub_type && (
|
||||
<span className="text-xs px-1.5 py-0.5 rounded bg-gray-100 text-gray-600">{m.sub_type}</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-sm font-medium text-gray-900 dark:text-white">{m.name}</div>
|
||||
<div className="text-xs text-gray-500 mt-0.5">{m.description}</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => onAddMeasure(m.name, m.description, m.reduction_type, selectedHazard)}
|
||||
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>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
) : selectedHazard ? (
|
||||
<div className="text-center py-12 text-gray-500">
|
||||
Keine Vorschlaege fuer diese Gefaehrdung gefunden.
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center py-12 text-gray-500">
|
||||
Waehlen Sie eine Gefaehrdung aus, um Vorschlaege zu erhalten.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
interface MitigationFormData {
|
||||
title: string
|
||||
description: string
|
||||
@@ -88,11 +413,13 @@ function MitigationForm({
|
||||
onCancel,
|
||||
hazards,
|
||||
preselectedType,
|
||||
onOpenLibrary,
|
||||
}: {
|
||||
onSubmit: (data: MitigationFormData) => void
|
||||
onCancel: () => void
|
||||
hazards: Hazard[]
|
||||
preselectedType?: 'design' | 'protection' | 'information'
|
||||
onOpenLibrary: (type?: string) => void
|
||||
}) {
|
||||
const [formData, setFormData] = useState<MitigationFormData>({
|
||||
title: '',
|
||||
@@ -112,7 +439,15 @@ function MitigationForm({
|
||||
|
||||
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">Neue Massnahme</h3>
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">Neue Massnahme</h3>
|
||||
<button
|
||||
onClick={() => onOpenLibrary(formData.reduction_type)}
|
||||
className="text-sm px-3 py-1.5 bg-purple-50 text-purple-700 border border-purple-200 rounded-lg hover:bg-purple-100 transition-colors"
|
||||
>
|
||||
Aus Bibliothek waehlen
|
||||
</button>
|
||||
</div>
|
||||
<div className="space-y-4">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
@@ -132,9 +467,9 @@ function MitigationForm({
|
||||
onChange={(e) => setFormData({ ...formData, reduction_type: e.target.value as MitigationFormData['reduction_type'] })}
|
||||
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="design">Design - Inhaerent sichere Konstruktion</option>
|
||||
<option value="protection">Schutz - Technische Schutzmassnahmen</option>
|
||||
<option value="information">Information - Hinweise und Schulungen</option>
|
||||
<option value="design">Stufe 1: Design - Inhaerent sichere Konstruktion</option>
|
||||
<option value="protection">Stufe 2: Schutz - Technische Schutzmassnahmen</option>
|
||||
<option value="information">Stufe 3: Information - Hinweise und Schulungen</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
@@ -201,7 +536,14 @@ function MitigationCard({
|
||||
return (
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 p-4">
|
||||
<div className="flex items-start justify-between mb-2">
|
||||
<h4 className="text-sm font-medium text-gray-900 dark:text-white">{mitigation.title}</h4>
|
||||
<div className="flex items-center gap-2">
|
||||
<h4 className="text-sm font-medium text-gray-900 dark:text-white">{mitigation.title}</h4>
|
||||
{mitigation.title.startsWith('Auto:') && (
|
||||
<span className="inline-flex items-center px-1.5 py-0.5 rounded text-xs font-medium bg-green-100 text-green-700">
|
||||
Auto
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<StatusBadge status={mitigation.status} />
|
||||
</div>
|
||||
{mitigation.description && (
|
||||
@@ -246,6 +588,12 @@ export default function MitigationsPage() {
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [showForm, setShowForm] = useState(false)
|
||||
const [preselectedType, setPreselectedType] = useState<'design' | 'protection' | 'information' | undefined>()
|
||||
const [hierarchyWarning, setHierarchyWarning] = useState<boolean>(false)
|
||||
const [showLibrary, setShowLibrary] = useState(false)
|
||||
const [libraryFilter, setLibraryFilter] = useState<string | undefined>()
|
||||
const [measures, setMeasures] = useState<ProtectiveMeasure[]>([])
|
||||
// Phase 5: Suggest measures
|
||||
const [showSuggest, setShowSuggest] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
fetchData()
|
||||
@@ -259,11 +607,14 @@ export default function MitigationsPage() {
|
||||
])
|
||||
if (mitRes.ok) {
|
||||
const json = await mitRes.json()
|
||||
setMitigations(json.mitigations || json || [])
|
||||
const mits = json.mitigations || json || []
|
||||
setMitigations(mits)
|
||||
// Check hierarchy: if information-only measures exist without design/protection
|
||||
validateHierarchy(mits)
|
||||
}
|
||||
if (hazRes.ok) {
|
||||
const json = await hazRes.json()
|
||||
setHazards((json.hazards || json || []).map((h: Hazard) => ({ id: h.id, name: h.name, risk_level: h.risk_level })))
|
||||
setHazards((json.hazards || json || []).map((h: Hazard) => ({ id: h.id, name: h.name, risk_level: h.risk_level, category: h.category })))
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch data:', err)
|
||||
@@ -272,6 +623,55 @@ export default function MitigationsPage() {
|
||||
}
|
||||
}
|
||||
|
||||
async function validateHierarchy(mits: Mitigation[]) {
|
||||
if (mits.length === 0) return
|
||||
try {
|
||||
const res = await fetch(`/api/sdk/v1/iace/projects/${projectId}/validate-mitigation-hierarchy`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
mitigations: mits.map((m) => ({
|
||||
reduction_type: m.reduction_type,
|
||||
linked_hazard_ids: m.linked_hazard_ids,
|
||||
})),
|
||||
}),
|
||||
})
|
||||
if (res.ok) {
|
||||
const json = await res.json()
|
||||
setHierarchyWarning(json.has_warning === true)
|
||||
}
|
||||
} catch {
|
||||
// Non-critical, ignore
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchMeasuresLibrary(type?: string) {
|
||||
try {
|
||||
const url = type
|
||||
? `/api/sdk/v1/iace/protective-measures-library?reduction_type=${type}`
|
||||
: '/api/sdk/v1/iace/protective-measures-library'
|
||||
const res = await fetch(url)
|
||||
if (res.ok) {
|
||||
const json = await res.json()
|
||||
setMeasures(json.protective_measures || [])
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch measures library:', err)
|
||||
}
|
||||
}
|
||||
|
||||
function handleOpenLibrary(type?: string) {
|
||||
setLibraryFilter(type)
|
||||
fetchMeasuresLibrary(type)
|
||||
setShowLibrary(true)
|
||||
}
|
||||
|
||||
function handleSelectMeasure(measure: ProtectiveMeasure) {
|
||||
setShowLibrary(false)
|
||||
setShowForm(true)
|
||||
setPreselectedType(measure.reduction_type as 'design' | 'protection' | 'information')
|
||||
}
|
||||
|
||||
async function handleSubmit(data: MitigationFormData) {
|
||||
try {
|
||||
const res = await fetch(`/api/sdk/v1/iace/projects/${projectId}/mitigations`, {
|
||||
@@ -289,6 +689,26 @@ export default function MitigationsPage() {
|
||||
}
|
||||
}
|
||||
|
||||
async function handleAddSuggestedMeasure(title: string, description: string, reductionType: string, hazardId: string) {
|
||||
try {
|
||||
const res = await fetch(`/api/sdk/v1/iace/projects/${projectId}/mitigations`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
title,
|
||||
description,
|
||||
reduction_type: reductionType,
|
||||
linked_hazard_ids: [hazardId],
|
||||
}),
|
||||
})
|
||||
if (res.ok) {
|
||||
await fetchData()
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to add suggested measure:', err)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleVerify(id: string) {
|
||||
try {
|
||||
const res = await fetch(`/api/sdk/v1/iace/projects/${projectId}/mitigations/${id}/verify`, {
|
||||
@@ -341,23 +761,50 @@ export default function MitigationsPage() {
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900 dark:text-white">Massnahmen</h1>
|
||||
<p className="mt-1 text-sm text-gray-500 dark:text-gray-400">
|
||||
Risikominderung nach dem 3-Stufen-Verfahren: Design, Schutz, Information.
|
||||
Risikominderung nach dem 3-Stufen-Verfahren: Design → Schutz → Information.
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => {
|
||||
setPreselectedType(undefined)
|
||||
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">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 6v6m0 0v6m0-6h6m-6 0H6" />
|
||||
</svg>
|
||||
Massnahme hinzufuegen
|
||||
</button>
|
||||
<div className="flex items-center gap-3">
|
||||
{hazards.length > 0 && (
|
||||
<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"
|
||||
>
|
||||
<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" />
|
||||
</svg>
|
||||
Vorschlaege
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={() => handleOpenLibrary()}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-white border border-purple-300 text-purple-700 rounded-lg hover:bg-purple-50 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="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.247 18 16.5 18c-1.746 0-3.332.477-4.5 1.253" />
|
||||
</svg>
|
||||
Bibliothek
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
setPreselectedType(undefined)
|
||||
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">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 6v6m0 0v6m0-6h6m-6 0H6" />
|
||||
</svg>
|
||||
Massnahme hinzufuegen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Hierarchy Warning */}
|
||||
{hierarchyWarning && (
|
||||
<HierarchyWarning onDismiss={() => setHierarchyWarning(false)} />
|
||||
)}
|
||||
|
||||
{/* Form */}
|
||||
{showForm && (
|
||||
<MitigationForm
|
||||
@@ -368,6 +815,27 @@ export default function MitigationsPage() {
|
||||
}}
|
||||
hazards={hazards}
|
||||
preselectedType={preselectedType}
|
||||
onOpenLibrary={handleOpenLibrary}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Measures Library Modal */}
|
||||
{showLibrary && (
|
||||
<MeasuresLibraryModal
|
||||
measures={measures}
|
||||
onSelect={handleSelectMeasure}
|
||||
onClose={() => setShowLibrary(false)}
|
||||
filterType={libraryFilter}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Suggest Measures Modal (Phase 5) */}
|
||||
{showSuggest && (
|
||||
<SuggestMeasuresModal
|
||||
hazards={hazards}
|
||||
projectId={projectId}
|
||||
onAddMeasure={handleAddSuggestedMeasure}
|
||||
onClose={() => setShowSuggest(false)}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -378,7 +846,7 @@ export default function MitigationsPage() {
|
||||
const items = byType[type]
|
||||
return (
|
||||
<div key={type} className={`rounded-xl border ${config.color} p-4`}>
|
||||
<div className={`flex items-center gap-2 px-3 py-2 rounded-lg ${config.headerColor} mb-4`}>
|
||||
<div className={`flex items-center gap-2 px-3 py-2 rounded-lg ${config.headerColor} mb-3`}>
|
||||
{config.icon}
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold">{config.label}</h3>
|
||||
@@ -387,6 +855,15 @@ export default function MitigationsPage() {
|
||||
<span className="ml-auto text-sm font-bold">{items.length}</span>
|
||||
</div>
|
||||
|
||||
{/* Sub-types overview */}
|
||||
<div className="mb-3 flex flex-wrap gap-1">
|
||||
{config.subTypes.map((st) => (
|
||||
<span key={st.value} className="text-xs px-1.5 py-0.5 rounded bg-white/60 text-gray-500 border border-gray-200/50">
|
||||
{st.label}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
{items.map((m) => (
|
||||
<MitigationCard
|
||||
@@ -398,12 +875,23 @@ export default function MitigationsPage() {
|
||||
))}
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={() => handleAddForType(type)}
|
||||
className="mt-3 w-full py-2 text-sm text-gray-500 hover:text-purple-600 hover:bg-white rounded-lg border border-dashed border-gray-300 hover:border-purple-300 transition-colors"
|
||||
>
|
||||
+ Massnahme hinzufuegen
|
||||
</button>
|
||||
<div className="mt-3 flex gap-2">
|
||||
<button
|
||||
onClick={() => handleAddForType(type)}
|
||||
className="flex-1 py-2 text-sm text-gray-500 hover:text-purple-600 hover:bg-white rounded-lg border border-dashed border-gray-300 hover:border-purple-300 transition-colors"
|
||||
>
|
||||
+ Hinzufuegen
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleOpenLibrary(type)}
|
||||
className="py-2 px-3 text-sm text-gray-400 hover:text-purple-600 hover:bg-white rounded-lg border border-dashed border-gray-300 hover:border-purple-300 transition-colors"
|
||||
title="Aus Bibliothek waehlen"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.247 18 16.5 18c-1.746 0-3.332.477-4.5 1.253" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
'use client'
|
||||
|
||||
import React, { useState, useEffect } from 'react'
|
||||
import React, { useState, useEffect, useRef } from 'react'
|
||||
import { useParams } from 'next/navigation'
|
||||
import { TechFileEditor } from '@/components/sdk/iace/TechFileEditor'
|
||||
|
||||
interface TechFileSection {
|
||||
id: string
|
||||
@@ -67,6 +68,14 @@ const STATUS_CONFIG: Record<string, { label: string; color: string; bgColor: str
|
||||
approved: { label: 'Freigegeben', color: 'text-green-700', bgColor: 'bg-green-100' },
|
||||
}
|
||||
|
||||
const EXPORT_FORMATS: { value: string; label: string; extension: string }[] = [
|
||||
{ value: 'pdf', label: 'PDF', extension: '.pdf' },
|
||||
{ value: 'xlsx', label: 'Excel', extension: '.xlsx' },
|
||||
{ value: 'docx', label: 'Word', extension: '.docx' },
|
||||
{ value: 'md', label: 'Markdown', extension: '.md' },
|
||||
{ value: 'json', label: 'JSON', extension: '.json' },
|
||||
]
|
||||
|
||||
function StatusBadge({ status }: { status: string }) {
|
||||
const config = STATUS_CONFIG[status] || STATUS_CONFIG.empty
|
||||
return (
|
||||
@@ -87,7 +96,6 @@ function SectionViewer({
|
||||
onApprove: (id: string) => void
|
||||
onSave: (id: string, content: string) => void
|
||||
}) {
|
||||
const [editedContent, setEditedContent] = useState(section.content || '')
|
||||
const [editing, setEditing] = useState(false)
|
||||
|
||||
return (
|
||||
@@ -111,13 +119,10 @@ function SectionViewer({
|
||||
)}
|
||||
{editing && (
|
||||
<button
|
||||
onClick={() => {
|
||||
onSave(section.id, editedContent)
|
||||
setEditing(false)
|
||||
}}
|
||||
className="text-sm px-3 py-1.5 bg-purple-600 text-white rounded-lg hover:bg-purple-700 transition-colors"
|
||||
onClick={() => setEditing(false)}
|
||||
className="text-sm px-3 py-1.5 border border-gray-300 text-gray-700 rounded-lg hover:bg-gray-50 transition-colors"
|
||||
>
|
||||
Speichern
|
||||
Fertig
|
||||
</button>
|
||||
)}
|
||||
{section.status !== 'approved' && section.content && !editing && (
|
||||
@@ -136,19 +141,19 @@ function SectionViewer({
|
||||
</div>
|
||||
</div>
|
||||
<div className="p-6">
|
||||
{editing ? (
|
||||
<textarea
|
||||
value={editedContent}
|
||||
onChange={(e) => setEditedContent(e.target.value)}
|
||||
rows={20}
|
||||
className="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent font-mono text-sm dark:bg-gray-700 dark:border-gray-600 dark:text-white"
|
||||
/>
|
||||
) : section.content ? (
|
||||
<div className="prose prose-sm max-w-none dark:prose-invert">
|
||||
<pre className="whitespace-pre-wrap text-sm text-gray-700 dark:text-gray-300 bg-gray-50 dark:bg-gray-750 p-4 rounded-lg">
|
||||
{section.content}
|
||||
</pre>
|
||||
</div>
|
||||
{section.content ? (
|
||||
editing ? (
|
||||
<TechFileEditor
|
||||
content={section.content}
|
||||
onSave={(html) => onSave(section.id, html)}
|
||||
/>
|
||||
) : (
|
||||
<TechFileEditor
|
||||
content={section.content}
|
||||
onSave={() => {}}
|
||||
readOnly
|
||||
/>
|
||||
)
|
||||
) : (
|
||||
<div className="text-center py-8 text-gray-500">
|
||||
Kein Inhalt vorhanden. Klicken Sie "Generieren" um den Abschnitt zu erstellen.
|
||||
@@ -167,6 +172,21 @@ export default function TechFilePage() {
|
||||
const [generatingSection, setGeneratingSection] = useState<string | null>(null)
|
||||
const [viewingSection, setViewingSection] = useState<TechFileSection | null>(null)
|
||||
const [exporting, setExporting] = useState(false)
|
||||
const [showExportMenu, setShowExportMenu] = useState(false)
|
||||
const exportMenuRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
// Close export menu when clicking outside
|
||||
useEffect(() => {
|
||||
function handleClickOutside(event: MouseEvent) {
|
||||
if (exportMenuRef.current && !exportMenuRef.current.contains(event.target as Node)) {
|
||||
setShowExportMenu(false)
|
||||
}
|
||||
}
|
||||
if (showExportMenu) {
|
||||
document.addEventListener('mousedown', handleClickOutside)
|
||||
return () => document.removeEventListener('mousedown', handleClickOutside)
|
||||
}
|
||||
}, [showExportMenu])
|
||||
|
||||
useEffect(() => {
|
||||
fetchSections()
|
||||
@@ -236,18 +256,22 @@ export default function TechFilePage() {
|
||||
}
|
||||
}
|
||||
|
||||
async function handleExportZip() {
|
||||
async function handleExport(format: string) {
|
||||
setExporting(true)
|
||||
setShowExportMenu(false)
|
||||
try {
|
||||
const res = await fetch(`/api/sdk/v1/iace/projects/${projectId}/tech-file/export`, {
|
||||
method: 'POST',
|
||||
})
|
||||
const res = await fetch(
|
||||
`/api/sdk/v1/iace/projects/${projectId}/tech-file/export?format=${format}`,
|
||||
{ method: 'GET' }
|
||||
)
|
||||
if (res.ok) {
|
||||
const blob = await res.blob()
|
||||
const url = window.URL.createObjectURL(blob)
|
||||
const formatConfig = EXPORT_FORMATS.find((f) => f.value === format)
|
||||
const extension = formatConfig?.extension || `.${format}`
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = `CE-Akte-${projectId}.zip`
|
||||
a.download = `CE-Akte-${projectId}${extension}`
|
||||
document.body.appendChild(a)
|
||||
a.click()
|
||||
document.body.removeChild(a)
|
||||
@@ -284,25 +308,45 @@ export default function TechFilePage() {
|
||||
Sie alle erforderlichen Abschnitte.
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleExportZip}
|
||||
disabled={!allRequiredApproved || exporting}
|
||||
title={!allRequiredApproved ? 'Alle Pflichtabschnitte muessen freigegeben sein' : 'CE-Akte als ZIP exportieren'}
|
||||
className={`flex items-center gap-2 px-4 py-2 rounded-lg font-medium transition-colors ${
|
||||
allRequiredApproved && !exporting
|
||||
? 'bg-purple-600 text-white hover:bg-purple-700'
|
||||
: 'bg-gray-200 text-gray-400 cursor-not-allowed'
|
||||
}`}
|
||||
>
|
||||
{exporting ? (
|
||||
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white" />
|
||||
) : (
|
||||
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 10v6m0 0l-3-3m3 3l3-3m2 8H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
|
||||
{/* Export Dropdown */}
|
||||
<div className="relative" ref={exportMenuRef}>
|
||||
<button
|
||||
onClick={() => setShowExportMenu((prev) => !prev)}
|
||||
disabled={!allRequiredApproved || exporting}
|
||||
title={!allRequiredApproved ? 'Alle Pflichtabschnitte muessen freigegeben sein' : 'CE-Akte exportieren'}
|
||||
className={`flex items-center gap-2 px-4 py-2 rounded-lg font-medium transition-colors ${
|
||||
allRequiredApproved && !exporting
|
||||
? 'bg-purple-600 text-white hover:bg-purple-700'
|
||||
: 'bg-gray-200 text-gray-400 cursor-not-allowed'
|
||||
}`}
|
||||
>
|
||||
{exporting ? (
|
||||
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white" />
|
||||
) : (
|
||||
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 10v6m0 0l-3-3m3 3l3-3m2 8H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
|
||||
</svg>
|
||||
)}
|
||||
Exportieren
|
||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
|
||||
</svg>
|
||||
</button>
|
||||
{showExportMenu && allRequiredApproved && !exporting && (
|
||||
<div className="absolute right-0 mt-2 w-48 bg-white dark:bg-gray-800 rounded-lg shadow-lg border border-gray-200 dark:border-gray-700 py-1 z-50">
|
||||
{EXPORT_FORMATS.map((fmt) => (
|
||||
<button
|
||||
key={fmt.value}
|
||||
onClick={() => handleExport(fmt.value)}
|
||||
className="w-full text-left px-4 py-2 text-sm text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors flex items-center gap-3"
|
||||
>
|
||||
<span className="text-xs font-mono uppercase w-10 text-gray-400">{fmt.extension}</span>
|
||||
<span>{fmt.label}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
ZIP exportieren
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Progress */}
|
||||
|
||||
@@ -19,14 +19,25 @@ interface VerificationItem {
|
||||
created_at: string
|
||||
}
|
||||
|
||||
interface SuggestedEvidence {
|
||||
id: string
|
||||
name: string
|
||||
description: string
|
||||
method: string
|
||||
tags?: string[]
|
||||
}
|
||||
|
||||
const VERIFICATION_METHODS = [
|
||||
{ value: 'test', label: 'Test' },
|
||||
{ value: 'analysis', label: 'Analyse' },
|
||||
{ value: 'inspection', label: 'Inspektion' },
|
||||
{ value: 'simulation', label: 'Simulation' },
|
||||
{ value: 'review', label: 'Review' },
|
||||
{ value: 'demonstration', label: 'Demonstration' },
|
||||
{ value: 'certification', label: 'Zertifizierung' },
|
||||
{ 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 }> = {
|
||||
@@ -238,6 +249,130 @@ function CompleteModal({
|
||||
)
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// 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() {
|
||||
const params = useParams()
|
||||
const projectId = params.projectId as string
|
||||
@@ -247,6 +382,8 @@ export default function VerificationPage() {
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [showForm, setShowForm] = useState(false)
|
||||
const [completingItem, setCompletingItem] = useState<VerificationItem | null>(null)
|
||||
// Phase 5: Suggest evidence
|
||||
const [showSuggest, setShowSuggest] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
fetchData()
|
||||
@@ -294,6 +431,26 @@ export default function VerificationPage() {
|
||||
}
|
||||
}
|
||||
|
||||
async function handleAddSuggestedEvidence(title: string, description: string, method: string, mitigationId: string) {
|
||||
try {
|
||||
const res = await fetch(`/api/sdk/v1/iace/projects/${projectId}/verifications`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
title,
|
||||
description,
|
||||
method,
|
||||
linked_mitigation_id: mitigationId,
|
||||
}),
|
||||
})
|
||||
if (res.ok) {
|
||||
await fetchData()
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to add suggested evidence:', err)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleComplete(id: string, result: string, passed: boolean) {
|
||||
try {
|
||||
const res = await fetch(`/api/sdk/v1/iace/projects/${projectId}/verifications/${id}/complete`, {
|
||||
@@ -344,15 +501,28 @@ export default function VerificationPage() {
|
||||
Nachweisfuehrung fuer alle Schutzmassnahmen und Sicherheitsanforderungen.
|
||||
</p>
|
||||
</div>
|
||||
<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"
|
||||
>
|
||||
<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" />
|
||||
</svg>
|
||||
Verifikation hinzufuegen
|
||||
</button>
|
||||
<div className="flex items-center gap-2">
|
||||
{mitigations.length > 0 && (
|
||||
<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"
|
||||
>
|
||||
<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" />
|
||||
</svg>
|
||||
Nachweise vorschlagen
|
||||
</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"
|
||||
>
|
||||
<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" />
|
||||
</svg>
|
||||
Verifikation hinzufuegen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Stats */}
|
||||
@@ -396,6 +566,16 @@ export default function VerificationPage() {
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Suggest Evidence Modal (Phase 5) */}
|
||||
{showSuggest && (
|
||||
<SuggestEvidenceModal
|
||||
mitigations={mitigations}
|
||||
projectId={projectId}
|
||||
onAddEvidence={handleAddSuggestedEvidence}
|
||||
onClose={() => setShowSuggest(false)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Table */}
|
||||
{items.length > 0 ? (
|
||||
<div className="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 overflow-hidden">
|
||||
@@ -469,12 +649,22 @@ export default function VerificationPage() {
|
||||
Definieren Sie Verifikationsschritte fuer Ihre Schutzmassnahmen.
|
||||
Jede Massnahme sollte durch mindestens eine Verifikation abgedeckt sein.
|
||||
</p>
|
||||
<button
|
||||
onClick={() => setShowForm(true)}
|
||||
className="mt-6 px-6 py-3 bg-purple-600 text-white rounded-lg hover:bg-purple-700 transition-colors"
|
||||
>
|
||||
Erste Verifikation anlegen
|
||||
</button>
|
||||
<div className="mt-6 flex items-center justify-center gap-3">
|
||||
{mitigations.length > 0 && (
|
||||
<button
|
||||
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
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={() => setShowForm(true)}
|
||||
className="px-6 py-3 bg-purple-600 text-white rounded-lg hover:bg-purple-700 transition-colors"
|
||||
>
|
||||
Erste Verifikation anlegen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -672,19 +672,19 @@ export const SDK_FLOW_STEPS: SDKFlowStep[] = [
|
||||
id: 'vendor-compliance',
|
||||
name: 'Vendor Compliance',
|
||||
nameShort: 'Vendor',
|
||||
package: 'betrieb',
|
||||
seq: 4200,
|
||||
package: 'dokumentation',
|
||||
seq: 2500,
|
||||
checkpointId: 'CP-VEND',
|
||||
checkpointType: 'REQUIRED',
|
||||
checkpointReviewer: 'NONE',
|
||||
description: 'Pruefung und Verwaltung aller Auftragsverarbeiter und Drittanbieter.',
|
||||
descriptionLong: 'Vendor Compliance verwaltet alle externen Dienstleister, die im Auftrag personenbezogene Daten verarbeiten (Auftragsverarbeiter nach Art. 28 DSGVO). Fuer jeden Vendor wird geprueft: Gibt es einen AVV? Wo werden Daten gespeichert (EU/Drittland)? Welche TOMs hat der Vendor? Gibt es Subunternehmer? Die Pruefung umfasst auch regelmässige Re-Assessments und die Verwaltung von Standardvertragsklauseln (SCCs) fuer Drittlandtransfers.',
|
||||
description: 'Pruefung und Verwaltung aller Auftragsverarbeiter und Drittanbieter — Cross-Modul-Integration mit VVT, Obligations, TOM und Loeschfristen.',
|
||||
descriptionLong: 'Vendor Compliance verwaltet alle externen Dienstleister, die im Auftrag personenbezogene Daten verarbeiten (Auftragsverarbeiter nach Art. 28 DSGVO). Fuer jeden Vendor wird geprueft: Gibt es einen AVV? Wo werden Daten gespeichert (EU/Drittland)? Welche TOMs hat der Vendor? Gibt es Subunternehmer? Cross-Modul-Integration: VVT-Processor-Tab liest Vendors mit role=PROCESSOR direkt aus der Vendor-API, Obligations und Loeschfristen verknuepfen Vendors ueber linked_vendor_ids (JSONB), TOM zeigt Vendor-Controls als Querverweis.',
|
||||
legalBasis: 'Art. 28 DSGVO (Auftragsverarbeiter), Art. 44-49 (Drittlandtransfer)',
|
||||
inputs: ['modules', 'vvt'],
|
||||
outputs: ['vendorAssessments'],
|
||||
prerequisiteSteps: ['escalations'],
|
||||
dbTables: [],
|
||||
dbMode: 'none',
|
||||
outputs: ['vendorAssessments', 'vendorControlInstances'],
|
||||
prerequisiteSteps: ['vvt'],
|
||||
dbTables: ['vendor_vendors', 'vendor_contracts', 'vendor_findings', 'vendor_control_instances', 'compliance_templates'],
|
||||
dbMode: 'read/write',
|
||||
ragCollections: ['bp_compliance_recht'],
|
||||
ragPurpose: 'AVV-Vorlagen und Pruefkataloge',
|
||||
isOptional: false,
|
||||
|
||||
@@ -1,18 +1,19 @@
|
||||
'use client'
|
||||
|
||||
import React, { useState, useCallback, useMemo } from 'react'
|
||||
import React, { useState, useCallback, useMemo, useEffect } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { useSDK } from '@/lib/sdk'
|
||||
import { StepHeader, STEP_EXPLANATIONS } from '@/components/sdk/StepHeader'
|
||||
import { useTOMGenerator } from '@/lib/sdk/tom-generator/context'
|
||||
import { DerivedTOM } from '@/lib/sdk/tom-generator/types'
|
||||
import { TOMOverviewTab, TOMEditorTab, TOMGapExportTab } from '@/components/sdk/tom-dashboard'
|
||||
import { TOMOverviewTab, TOMEditorTab, TOMGapExportTab, TOMDocumentTab } from '@/components/sdk/tom-dashboard'
|
||||
import { runTOMComplianceCheck, type TOMComplianceCheckResult } from '@/lib/sdk/tom-compliance'
|
||||
|
||||
// =============================================================================
|
||||
// TYPES
|
||||
// =============================================================================
|
||||
|
||||
type Tab = 'uebersicht' | 'editor' | 'generator' | 'gap-export'
|
||||
type Tab = 'uebersicht' | 'editor' | 'generator' | 'gap-export' | 'tom-dokument'
|
||||
|
||||
interface TabDefinition {
|
||||
key: Tab
|
||||
@@ -24,6 +25,7 @@ const TABS: TabDefinition[] = [
|
||||
{ key: 'editor', label: 'Detail-Editor' },
|
||||
{ key: 'generator', label: 'Generator' },
|
||||
{ key: 'gap-export', label: 'Gap-Analyse & Export' },
|
||||
{ key: 'tom-dokument', label: 'TOM-Dokument' },
|
||||
]
|
||||
|
||||
// =============================================================================
|
||||
@@ -33,7 +35,7 @@ const TABS: TabDefinition[] = [
|
||||
export default function TOMPage() {
|
||||
const router = useRouter()
|
||||
const sdk = useSDK()
|
||||
const { state, dispatch, bulkUpdateTOMs, runGapAnalysis } = useTOMGenerator()
|
||||
const { state, dispatch, runGapAnalysis } = useTOMGenerator()
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Local state
|
||||
@@ -41,6 +43,58 @@ export default function TOMPage() {
|
||||
|
||||
const [tab, setTab] = useState<Tab>('uebersicht')
|
||||
const [selectedTOMId, setSelectedTOMId] = useState<string | null>(null)
|
||||
const [complianceResult, setComplianceResult] = useState<TOMComplianceCheckResult | null>(null)
|
||||
const [vendorControls, setVendorControls] = useState<Array<{
|
||||
vendorId: string
|
||||
vendorName: string
|
||||
controlId: string
|
||||
controlName: string
|
||||
domain: string
|
||||
status: string
|
||||
lastTestedAt?: string
|
||||
}>>([])
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Compliance check (auto-run when derivedTOMs change)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
useEffect(() => {
|
||||
if (state?.derivedTOMs && Array.isArray(state.derivedTOMs) && state.derivedTOMs.length > 0) {
|
||||
setComplianceResult(runTOMComplianceCheck(state))
|
||||
}
|
||||
}, [state?.derivedTOMs])
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Vendor controls cross-reference (fetch when overview tab is active)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
useEffect(() => {
|
||||
if (tab !== 'uebersicht') return
|
||||
Promise.all([
|
||||
fetch('/api/sdk/v1/vendor-compliance/control-instances?limit=500').then(r => r.ok ? r.json() : null),
|
||||
fetch('/api/sdk/v1/vendor-compliance/vendors?limit=500').then(r => r.ok ? r.json() : null),
|
||||
]).then(([ciData, vendorData]) => {
|
||||
const instances = ciData?.data?.items || []
|
||||
const vendors = vendorData?.data?.items || []
|
||||
const vendorMap = new Map<string, string>()
|
||||
for (const v of vendors) {
|
||||
vendorMap.set(v.id, v.name)
|
||||
}
|
||||
// Filter for TOM-domain controls
|
||||
const tomControls = instances
|
||||
.filter((ci: any) => ci.domain === 'TOM' || ci.controlId?.startsWith('VND-TOM'))
|
||||
.map((ci: any) => ({
|
||||
vendorId: ci.vendorId || ci.vendor_id,
|
||||
vendorName: vendorMap.get(ci.vendorId || ci.vendor_id) || 'Unbekannt',
|
||||
controlId: ci.controlId || ci.control_id,
|
||||
controlName: ci.controlName || ci.control_name || ci.controlId || ci.control_id,
|
||||
domain: ci.domain || 'TOM',
|
||||
status: ci.status || 'UNKNOWN',
|
||||
lastTestedAt: ci.lastTestedAt || ci.last_tested_at,
|
||||
}))
|
||||
setVendorControls(tomControls)
|
||||
}).catch(() => {})
|
||||
}, [tab])
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Computed / memoised values
|
||||
@@ -316,6 +370,17 @@ export default function TOMPage() {
|
||||
/>
|
||||
)
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tab 5 – TOM-Dokument
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const renderTOMDokument = () => (
|
||||
<TOMDocumentTab
|
||||
state={state}
|
||||
complianceResult={complianceResult}
|
||||
/>
|
||||
)
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tab content router
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -330,6 +395,8 @@ export default function TOMPage() {
|
||||
return renderGenerator()
|
||||
case 'gap-export':
|
||||
return renderGapExport()
|
||||
case 'tom-dokument':
|
||||
return renderTOMDokument()
|
||||
default:
|
||||
return renderUebersicht()
|
||||
}
|
||||
@@ -351,6 +418,60 @@ export default function TOMPage() {
|
||||
|
||||
{/* Active tab content */}
|
||||
<div>{renderActiveTab()}</div>
|
||||
|
||||
{/* Vendor-Controls cross-reference (only on overview tab) */}
|
||||
{tab === 'uebersicht' && vendorControls.length > 0 && (
|
||||
<div className="mt-6 bg-white rounded-xl border border-gray-200 p-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div>
|
||||
<h3 className="text-base font-semibold text-gray-900">Auftragsverarbeiter-Controls (Art. 28)</h3>
|
||||
<p className="text-sm text-gray-500 mt-0.5">TOM-relevante Controls aus dem Vendor Register</p>
|
||||
</div>
|
||||
<a href="/sdk/vendor-compliance" className="text-sm text-purple-600 hover:text-purple-700 font-medium">
|
||||
Zum Vendor Register →
|
||||
</a>
|
||||
</div>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-gray-200">
|
||||
<th className="text-left py-2 px-3 text-xs font-medium text-gray-500 uppercase">Vendor</th>
|
||||
<th className="text-left py-2 px-3 text-xs font-medium text-gray-500 uppercase">Control</th>
|
||||
<th className="text-left py-2 px-3 text-xs font-medium text-gray-500 uppercase">Status</th>
|
||||
<th className="text-left py-2 px-3 text-xs font-medium text-gray-500 uppercase">Letzte Pruefung</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-100">
|
||||
{vendorControls.map((vc, i) => (
|
||||
<tr key={`${vc.vendorId}-${vc.controlId}-${i}`} className="hover:bg-gray-50">
|
||||
<td className="py-2.5 px-3 font-medium text-gray-900">{vc.vendorName}</td>
|
||||
<td className="py-2.5 px-3">
|
||||
<span className="font-mono text-xs text-gray-500">{vc.controlId}</span>
|
||||
<span className="ml-2 text-gray-700">{vc.controlName !== vc.controlId ? vc.controlName : ''}</span>
|
||||
</td>
|
||||
<td className="py-2.5 px-3">
|
||||
<span className={`px-2 py-0.5 text-xs rounded-full ${
|
||||
vc.status === 'PASS' ? 'bg-green-100 text-green-700' :
|
||||
vc.status === 'PARTIAL' ? 'bg-yellow-100 text-yellow-700' :
|
||||
vc.status === 'FAIL' ? 'bg-red-100 text-red-700' :
|
||||
'bg-gray-100 text-gray-600'
|
||||
}`}>
|
||||
{vc.status === 'PASS' ? 'Bestanden' :
|
||||
vc.status === 'PARTIAL' ? 'Teilweise' :
|
||||
vc.status === 'FAIL' ? 'Nicht bestanden' :
|
||||
vc.status}
|
||||
</span>
|
||||
</td>
|
||||
<td className="py-2.5 px-3 text-gray-500">
|
||||
{vc.lastTestedAt ? new Date(vc.lastTestedAt).toLocaleDateString('de-DE') : '\u2014'}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,560 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState, useCallback } from 'react'
|
||||
import {
|
||||
getAssignments, getContent, getModuleMedia, getQuiz, submitQuiz,
|
||||
startAssignment, generateCertificate, listCertificates, downloadCertificatePDF,
|
||||
getMediaStreamURL, getInteractiveManifest, completeAssignment,
|
||||
} from '@/lib/sdk/training/api'
|
||||
import type {
|
||||
TrainingAssignment, ModuleContent, TrainingMedia, QuizSubmitResponse,
|
||||
InteractiveVideoManifest,
|
||||
} from '@/lib/sdk/training/types'
|
||||
import {
|
||||
STATUS_LABELS, STATUS_COLORS, REGULATION_LABELS,
|
||||
} from '@/lib/sdk/training/types'
|
||||
import InteractiveVideoPlayer from '@/components/training/InteractiveVideoPlayer'
|
||||
|
||||
type Tab = 'assignments' | 'content' | 'quiz' | 'certificates'
|
||||
|
||||
interface QuizQuestionItem {
|
||||
id: string
|
||||
question: string
|
||||
options: string[]
|
||||
difficulty: string
|
||||
}
|
||||
|
||||
export default function LearnerPage() {
|
||||
const [activeTab, setActiveTab] = useState<Tab>('assignments')
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
// Assignments
|
||||
const [assignments, setAssignments] = useState<TrainingAssignment[]>([])
|
||||
|
||||
// Content
|
||||
const [selectedAssignment, setSelectedAssignment] = useState<TrainingAssignment | null>(null)
|
||||
const [content, setContent] = useState<ModuleContent | null>(null)
|
||||
const [media, setMedia] = useState<TrainingMedia[]>([])
|
||||
|
||||
// Quiz
|
||||
const [questions, setQuestions] = useState<QuizQuestionItem[]>([])
|
||||
const [answers, setAnswers] = useState<Record<string, number>>({})
|
||||
const [quizResult, setQuizResult] = useState<QuizSubmitResponse | null>(null)
|
||||
const [quizSubmitting, setQuizSubmitting] = useState(false)
|
||||
const [quizTimer, setQuizTimer] = useState(0)
|
||||
const [quizActive, setQuizActive] = useState(false)
|
||||
|
||||
// Certificates
|
||||
const [certificates, setCertificates] = useState<TrainingAssignment[]>([])
|
||||
const [certGenerating, setCertGenerating] = useState(false)
|
||||
|
||||
// Interactive Video
|
||||
const [interactiveManifest, setInteractiveManifest] = useState<InteractiveVideoManifest | null>(null)
|
||||
|
||||
// User simulation
|
||||
const [userId] = useState('00000000-0000-0000-0000-000000000001')
|
||||
|
||||
const loadAssignments = useCallback(async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const data = await getAssignments({ user_id: userId, limit: 100 })
|
||||
setAssignments(data.assignments || [])
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : 'Fehler beim Laden')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [userId])
|
||||
|
||||
const loadCertificates = useCallback(async () => {
|
||||
try {
|
||||
const data = await listCertificates()
|
||||
setCertificates(data.certificates || [])
|
||||
} catch {
|
||||
// Certificates may not exist yet
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
loadAssignments()
|
||||
loadCertificates()
|
||||
}, [loadAssignments, loadCertificates])
|
||||
|
||||
// Quiz timer
|
||||
useEffect(() => {
|
||||
if (!quizActive) return
|
||||
const interval = setInterval(() => setQuizTimer(t => t + 1), 1000)
|
||||
return () => clearInterval(interval)
|
||||
}, [quizActive])
|
||||
|
||||
async function loadInteractiveManifest(moduleId: string, assignmentId: string) {
|
||||
try {
|
||||
const manifest = await getInteractiveManifest(moduleId, assignmentId)
|
||||
if (manifest && manifest.checkpoints && manifest.checkpoints.length > 0) {
|
||||
setInteractiveManifest(manifest)
|
||||
} else {
|
||||
setInteractiveManifest(null)
|
||||
}
|
||||
} catch {
|
||||
setInteractiveManifest(null)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleStartAssignment(assignment: TrainingAssignment) {
|
||||
try {
|
||||
await startAssignment(assignment.id)
|
||||
setSelectedAssignment({ ...assignment, status: 'in_progress' })
|
||||
// Load content
|
||||
const [contentData, mediaData] = await Promise.all([
|
||||
getContent(assignment.module_id).catch(() => null),
|
||||
getModuleMedia(assignment.module_id).catch(() => ({ media: [] })),
|
||||
])
|
||||
setContent(contentData)
|
||||
setMedia(mediaData.media || [])
|
||||
await loadInteractiveManifest(assignment.module_id, assignment.id)
|
||||
setActiveTab('content')
|
||||
loadAssignments()
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : 'Fehler beim Starten')
|
||||
}
|
||||
}
|
||||
|
||||
async function handleResumeContent(assignment: TrainingAssignment) {
|
||||
setSelectedAssignment(assignment)
|
||||
try {
|
||||
const [contentData, mediaData] = await Promise.all([
|
||||
getContent(assignment.module_id).catch(() => null),
|
||||
getModuleMedia(assignment.module_id).catch(() => ({ media: [] })),
|
||||
])
|
||||
setContent(contentData)
|
||||
setMedia(mediaData.media || [])
|
||||
await loadInteractiveManifest(assignment.module_id, assignment.id)
|
||||
setActiveTab('content')
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : 'Fehler beim Laden')
|
||||
}
|
||||
}
|
||||
|
||||
async function handleAllCheckpointsPassed() {
|
||||
if (!selectedAssignment) return
|
||||
try {
|
||||
await completeAssignment(selectedAssignment.id)
|
||||
setSelectedAssignment({ ...selectedAssignment, status: 'completed' })
|
||||
loadAssignments()
|
||||
} catch {
|
||||
// Assignment completion may already be handled
|
||||
}
|
||||
}
|
||||
|
||||
async function handleStartQuiz() {
|
||||
if (!selectedAssignment) return
|
||||
try {
|
||||
const data = await getQuiz(selectedAssignment.module_id)
|
||||
setQuestions(data.questions || [])
|
||||
setAnswers({})
|
||||
setQuizResult(null)
|
||||
setQuizTimer(0)
|
||||
setQuizActive(true)
|
||||
setActiveTab('quiz')
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : 'Fehler beim Quiz-Laden')
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSubmitQuiz() {
|
||||
if (!selectedAssignment || questions.length === 0) return
|
||||
setQuizSubmitting(true)
|
||||
setQuizActive(false)
|
||||
try {
|
||||
const answerList = questions.map(q => ({
|
||||
question_id: q.id,
|
||||
selected_index: answers[q.id] ?? -1,
|
||||
}))
|
||||
const result = await submitQuiz(selectedAssignment.module_id, {
|
||||
assignment_id: selectedAssignment.id,
|
||||
answers: answerList,
|
||||
duration_seconds: quizTimer,
|
||||
})
|
||||
setQuizResult(result)
|
||||
loadAssignments()
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : 'Quiz-Abgabe fehlgeschlagen')
|
||||
} finally {
|
||||
setQuizSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleGenerateCertificate(assignmentId: string) {
|
||||
setCertGenerating(true)
|
||||
try {
|
||||
const data = await generateCertificate(assignmentId)
|
||||
if (data.certificate_id) {
|
||||
const blob = await downloadCertificatePDF(data.certificate_id)
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = `zertifikat-${data.certificate_id.substring(0, 8)}.pdf`
|
||||
a.click()
|
||||
URL.revokeObjectURL(url)
|
||||
}
|
||||
loadAssignments()
|
||||
loadCertificates()
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : 'Zertifikat-Erstellung fehlgeschlagen')
|
||||
} finally {
|
||||
setCertGenerating(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDownloadPDF(certId: string) {
|
||||
try {
|
||||
const blob = await downloadCertificatePDF(certId)
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = `zertifikat-${certId.substring(0, 8)}.pdf`
|
||||
a.click()
|
||||
URL.revokeObjectURL(url)
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : 'PDF-Download fehlgeschlagen')
|
||||
}
|
||||
}
|
||||
|
||||
function simpleMarkdownToHtml(md: string): string {
|
||||
return md
|
||||
.replace(/^### (.+)$/gm, '<h3 class="text-lg font-semibold mt-4 mb-2">$1</h3>')
|
||||
.replace(/^## (.+)$/gm, '<h2 class="text-xl font-bold mt-6 mb-3">$1</h2>')
|
||||
.replace(/^# (.+)$/gm, '<h1 class="text-2xl font-bold mt-6 mb-3">$1</h1>')
|
||||
.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>')
|
||||
.replace(/\*(.+?)\*/g, '<em>$1</em>')
|
||||
.replace(/^- (.+)$/gm, '<li class="ml-4 list-disc">$1</li>')
|
||||
.replace(/^(\d+)\. (.+)$/gm, '<li class="ml-4 list-decimal">$2</li>')
|
||||
.replace(/\n\n/g, '<br/><br/>')
|
||||
}
|
||||
|
||||
function formatTimer(seconds: number): string {
|
||||
const m = Math.floor(seconds / 60)
|
||||
const s = seconds % 60
|
||||
return `${m}:${s.toString().padStart(2, '0')}`
|
||||
}
|
||||
|
||||
const tabs: { key: Tab; label: string }[] = [
|
||||
{ key: 'assignments', label: 'Meine Schulungen' },
|
||||
{ key: 'content', label: 'Schulungsinhalt' },
|
||||
{ key: 'quiz', label: 'Quiz' },
|
||||
{ key: 'certificates', label: 'Zertifikate' },
|
||||
]
|
||||
|
||||
return (
|
||||
<div className="max-w-7xl mx-auto p-6">
|
||||
<div className="mb-6">
|
||||
<h1 className="text-2xl font-bold text-gray-900">Learner Portal</h1>
|
||||
<p className="text-gray-500 mt-1">Absolvieren Sie Ihre Compliance-Schulungen</p>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="mb-4 p-3 bg-red-50 border border-red-200 rounded-lg text-red-700 text-sm">
|
||||
{error}
|
||||
<button onClick={() => setError(null)} className="ml-2 text-red-500 hover:text-red-700">x</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Tabs */}
|
||||
<div className="border-b border-gray-200 mb-6">
|
||||
<div className="flex gap-6">
|
||||
{tabs.map(tab => (
|
||||
<button
|
||||
key={tab.key}
|
||||
onClick={() => setActiveTab(tab.key)}
|
||||
className={`pb-3 text-sm font-medium border-b-2 transition-colors ${
|
||||
activeTab === tab.key
|
||||
? 'border-indigo-500 text-indigo-600'
|
||||
: 'border-transparent text-gray-500 hover:text-gray-700'
|
||||
}`}
|
||||
>
|
||||
{tab.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tab: Meine Schulungen */}
|
||||
{activeTab === 'assignments' && (
|
||||
<div>
|
||||
{loading ? (
|
||||
<div className="text-center py-12 text-gray-400">Lade Schulungen...</div>
|
||||
) : assignments.length === 0 ? (
|
||||
<div className="text-center py-12 text-gray-400">Keine Schulungen zugewiesen</div>
|
||||
) : (
|
||||
<div className="grid gap-4">
|
||||
{assignments.map(a => (
|
||||
<div key={a.id} className="bg-white border border-gray-200 rounded-lg p-5 hover:shadow-sm transition-shadow">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-3">
|
||||
<h3 className="font-semibold text-gray-900">{a.module_title || a.module_code}</h3>
|
||||
<span className={`px-2 py-0.5 text-xs rounded-full ${STATUS_COLORS[a.status]?.bg || 'bg-gray-100'} ${STATUS_COLORS[a.status]?.text || 'text-gray-700'}`}>
|
||||
{STATUS_LABELS[a.status] || a.status}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-sm text-gray-500 mt-1">
|
||||
Code: {a.module_code} | Deadline: {new Date(a.deadline).toLocaleDateString('de-DE')}
|
||||
{a.quiz_score != null && ` | Quiz: ${Math.round(a.quiz_score)}%`}
|
||||
</p>
|
||||
{/* Progress bar */}
|
||||
<div className="mt-3 w-full bg-gray-200 rounded-full h-2">
|
||||
<div
|
||||
className={`h-2 rounded-full transition-all ${a.status === 'completed' ? 'bg-green-500' : 'bg-indigo-500'}`}
|
||||
style={{ width: `${a.progress_percent}%` }}
|
||||
/>
|
||||
</div>
|
||||
<p className="text-xs text-gray-400 mt-1">{a.progress_percent}% abgeschlossen</p>
|
||||
</div>
|
||||
<div className="flex gap-2 ml-4">
|
||||
{a.status === 'pending' && (
|
||||
<button
|
||||
onClick={() => handleStartAssignment(a)}
|
||||
className="px-3 py-1.5 bg-indigo-600 text-white text-sm rounded-lg hover:bg-indigo-700"
|
||||
>
|
||||
Starten
|
||||
</button>
|
||||
)}
|
||||
{a.status === 'in_progress' && (
|
||||
<button
|
||||
onClick={() => handleResumeContent(a)}
|
||||
className="px-3 py-1.5 bg-indigo-600 text-white text-sm rounded-lg hover:bg-indigo-700"
|
||||
>
|
||||
Fortsetzen
|
||||
</button>
|
||||
)}
|
||||
{a.status === 'completed' && a.quiz_passed && !a.certificate_id && (
|
||||
<button
|
||||
onClick={() => handleGenerateCertificate(a.id)}
|
||||
disabled={certGenerating}
|
||||
className="px-3 py-1.5 bg-green-600 text-white text-sm rounded-lg hover:bg-green-700 disabled:opacity-50"
|
||||
>
|
||||
{certGenerating ? 'Erstelle...' : 'Zertifikat'}
|
||||
</button>
|
||||
)}
|
||||
{a.certificate_id && (
|
||||
<button
|
||||
onClick={() => handleDownloadPDF(a.certificate_id!)}
|
||||
className="px-3 py-1.5 bg-green-100 text-green-700 text-sm rounded-lg hover:bg-green-200"
|
||||
>
|
||||
PDF
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Tab: Schulungsinhalt */}
|
||||
{activeTab === 'content' && (
|
||||
<div>
|
||||
{!selectedAssignment ? (
|
||||
<div className="text-center py-12 text-gray-400">
|
||||
Waehlen Sie eine Schulung aus dem Tab "Meine Schulungen"
|
||||
</div>
|
||||
) : (
|
||||
<div>
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<h2 className="text-lg font-semibold">{selectedAssignment.module_title}</h2>
|
||||
<button
|
||||
onClick={handleStartQuiz}
|
||||
className="px-4 py-2 bg-indigo-600 text-white text-sm rounded-lg hover:bg-indigo-700"
|
||||
>
|
||||
Quiz starten
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Interactive Video Player */}
|
||||
{interactiveManifest && selectedAssignment && (
|
||||
<div className="mb-6">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<p className="text-sm font-medium text-gray-700">Interaktive Video-Schulung</p>
|
||||
<span className="px-2 py-0.5 text-xs bg-purple-100 text-purple-700 rounded-full">Interaktiv</span>
|
||||
</div>
|
||||
<InteractiveVideoPlayer
|
||||
manifest={interactiveManifest}
|
||||
assignmentId={selectedAssignment.id}
|
||||
onAllCheckpointsPassed={handleAllCheckpointsPassed}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Media players (standard audio/video) */}
|
||||
{media.length > 0 && (
|
||||
<div className="mb-6 grid gap-4 md:grid-cols-2">
|
||||
{media.filter(m => m.media_type === 'audio' && m.status === 'completed').map(m => (
|
||||
<div key={m.id} className="bg-gray-50 p-4 rounded-lg">
|
||||
<p className="text-sm font-medium text-gray-700 mb-2">Audio-Schulung</p>
|
||||
<audio controls className="w-full" src={getMediaStreamURL(m.id)}>
|
||||
Ihr Browser unterstuetzt kein Audio.
|
||||
</audio>
|
||||
</div>
|
||||
))}
|
||||
{media.filter(m => m.media_type === 'video' && m.status === 'completed' && m.generated_by !== 'tts_ffmpeg_interactive').map(m => (
|
||||
<div key={m.id} className="bg-gray-50 p-4 rounded-lg">
|
||||
<p className="text-sm font-medium text-gray-700 mb-2">Video-Schulung</p>
|
||||
<video controls className="w-full rounded" src={getMediaStreamURL(m.id)}>
|
||||
Ihr Browser unterstuetzt kein Video.
|
||||
</video>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Content body */}
|
||||
{content ? (
|
||||
<div className="bg-white border border-gray-200 rounded-lg p-6">
|
||||
<div
|
||||
className="prose max-w-none text-gray-800"
|
||||
dangerouslySetInnerHTML={{ __html: simpleMarkdownToHtml(content.content_body) }}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center py-8 text-gray-400">Kein Schulungsinhalt verfuegbar</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Tab: Quiz */}
|
||||
{activeTab === 'quiz' && (
|
||||
<div>
|
||||
{questions.length === 0 ? (
|
||||
<div className="text-center py-12 text-gray-400">
|
||||
Starten Sie ein Quiz aus dem Schulungsinhalt-Tab
|
||||
</div>
|
||||
) : quizResult ? (
|
||||
/* Quiz Results */
|
||||
<div className="max-w-lg mx-auto">
|
||||
<div className={`text-center p-8 rounded-lg border-2 ${quizResult.passed ? 'border-green-300 bg-green-50' : 'border-red-300 bg-red-50'}`}>
|
||||
<div className="text-4xl mb-3">{quizResult.passed ? '\u2705' : '\u274C'}</div>
|
||||
<h2 className="text-2xl font-bold mb-2">
|
||||
{quizResult.passed ? 'Bestanden!' : 'Nicht bestanden'}
|
||||
</h2>
|
||||
<p className="text-lg text-gray-700">
|
||||
{quizResult.correct_count} von {quizResult.total_count} richtig ({Math.round(quizResult.score)}%)
|
||||
</p>
|
||||
<p className="text-sm text-gray-500 mt-1">
|
||||
Bestehensgrenze: {quizResult.threshold}% | Zeit: {formatTimer(quizTimer)}
|
||||
</p>
|
||||
{quizResult.passed && selectedAssignment && !selectedAssignment.certificate_id && (
|
||||
<button
|
||||
onClick={() => handleGenerateCertificate(selectedAssignment.id)}
|
||||
disabled={certGenerating}
|
||||
className="mt-4 px-6 py-2 bg-green-600 text-white rounded-lg hover:bg-green-700 disabled:opacity-50"
|
||||
>
|
||||
{certGenerating ? 'Erstelle Zertifikat...' : 'Zertifikat generieren & herunterladen'}
|
||||
</button>
|
||||
)}
|
||||
{!quizResult.passed && (
|
||||
<button
|
||||
onClick={handleStartQuiz}
|
||||
className="mt-4 px-6 py-2 bg-indigo-600 text-white rounded-lg hover:bg-indigo-700"
|
||||
>
|
||||
Quiz erneut versuchen
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
/* Quiz Questions */
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h2 className="text-lg font-semibold">Quiz — {selectedAssignment?.module_title}</h2>
|
||||
<span className="text-sm text-gray-500 font-mono bg-gray-100 px-3 py-1 rounded">
|
||||
{formatTimer(quizTimer)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="space-y-6">
|
||||
{questions.map((q, idx) => (
|
||||
<div key={q.id} className="bg-white border border-gray-200 rounded-lg p-5">
|
||||
<p className="font-medium text-gray-900 mb-3">
|
||||
<span className="text-indigo-600 mr-2">Frage {idx + 1}.</span>
|
||||
{q.question}
|
||||
</p>
|
||||
<div className="space-y-2">
|
||||
{q.options.map((opt, oi) => (
|
||||
<label
|
||||
key={oi}
|
||||
className={`flex items-center gap-3 p-3 rounded-lg border cursor-pointer transition-colors ${
|
||||
answers[q.id] === oi
|
||||
? 'border-indigo-500 bg-indigo-50'
|
||||
: 'border-gray-200 hover:bg-gray-50'
|
||||
}`}
|
||||
>
|
||||
<input
|
||||
type="radio"
|
||||
name={q.id}
|
||||
checked={answers[q.id] === oi}
|
||||
onChange={() => setAnswers(prev => ({ ...prev, [q.id]: oi }))}
|
||||
className="text-indigo-600"
|
||||
/>
|
||||
<span className="text-sm text-gray-700">{opt}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="mt-6 flex justify-end">
|
||||
<button
|
||||
onClick={handleSubmitQuiz}
|
||||
disabled={quizSubmitting || Object.keys(answers).length < questions.length}
|
||||
className="px-6 py-2 bg-indigo-600 text-white rounded-lg hover:bg-indigo-700 disabled:opacity-50"
|
||||
>
|
||||
{quizSubmitting ? 'Wird ausgewertet...' : `Quiz abgeben (${Object.keys(answers).length}/${questions.length})`}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Tab: Zertifikate */}
|
||||
{activeTab === 'certificates' && (
|
||||
<div>
|
||||
{certificates.length === 0 ? (
|
||||
<div className="text-center py-12 text-gray-400">
|
||||
Noch keine Zertifikate vorhanden. Schliessen Sie eine Schulung mit Quiz ab.
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||
{certificates.map(cert => (
|
||||
<div key={cert.id} className="bg-white border border-gray-200 rounded-lg p-5">
|
||||
<div className="flex items-start justify-between mb-3">
|
||||
<h3 className="font-semibold text-gray-900 text-sm">{cert.module_title}</h3>
|
||||
<span className="text-xs bg-green-100 text-green-700 px-2 py-0.5 rounded-full">Bestanden</span>
|
||||
</div>
|
||||
<div className="text-xs text-gray-500 space-y-1">
|
||||
<p>Mitarbeiter: {cert.user_name}</p>
|
||||
<p>Abschluss: {cert.completed_at ? new Date(cert.completed_at).toLocaleDateString('de-DE') : '-'}</p>
|
||||
{cert.quiz_score != null && <p>Ergebnis: {Math.round(cert.quiz_score)}%</p>}
|
||||
<p className="font-mono text-[10px] text-gray-400">ID: {cert.certificate_id?.substring(0, 12)}</p>
|
||||
</div>
|
||||
{cert.certificate_id && (
|
||||
<button
|
||||
onClick={() => handleDownloadPDF(cert.certificate_id!)}
|
||||
className="mt-3 w-full px-3 py-1.5 bg-indigo-600 text-white text-sm rounded-lg hover:bg-indigo-700"
|
||||
>
|
||||
PDF herunterladen
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -6,23 +6,25 @@ import {
|
||||
getAuditLog, generateContent, generateQuiz,
|
||||
publishContent, checkEscalation, getContent,
|
||||
generateAllContent, generateAllQuizzes,
|
||||
deleteMatrixEntry,
|
||||
createModule, updateModule, deleteModule,
|
||||
deleteMatrixEntry, setMatrixEntry,
|
||||
startAssignment, completeAssignment, updateAssignment,
|
||||
listBlockConfigs, createBlockConfig, deleteBlockConfig,
|
||||
previewBlock, generateBlock, getCanonicalMeta,
|
||||
generateInteractiveVideo,
|
||||
} from '@/lib/sdk/training/api'
|
||||
import type {
|
||||
TrainingModule, TrainingAssignment,
|
||||
MatrixResponse, TrainingStats, DeadlineInfo, AuditLogEntry, ModuleContent, TrainingMedia,
|
||||
TrainingBlockConfig, CanonicalControlMeta, BlockPreview, BlockGenerateResult,
|
||||
} from '@/lib/sdk/training/types'
|
||||
import { OverviewTab } from './_components/OverviewTab'
|
||||
import { ModulesTab } from './_components/ModulesTab'
|
||||
import { MatrixTab } from './_components/MatrixTab'
|
||||
import { AssignmentsTab } from './_components/AssignmentsTab'
|
||||
import { ContentTab } from './_components/ContentTab'
|
||||
import { AuditTab } from './_components/AuditTab'
|
||||
import { ModuleCreateModal } from './_components/ModuleCreateModal'
|
||||
import { ModuleEditDrawer } from './_components/ModuleEditDrawer'
|
||||
import { MatrixAddModal } from './_components/MatrixAddModal'
|
||||
import { AssignmentDetailDrawer } from './_components/AssignmentDetailDrawer'
|
||||
|
||||
import {
|
||||
REGULATION_LABELS, REGULATION_COLORS, FREQUENCY_LABELS,
|
||||
STATUS_LABELS, STATUS_COLORS, ROLE_LABELS, ALL_ROLES, TARGET_AUDIENCE_LABELS,
|
||||
} from '@/lib/sdk/training/types'
|
||||
import AudioPlayer from '@/components/training/AudioPlayer'
|
||||
import VideoPlayer from '@/components/training/VideoPlayer'
|
||||
import ScriptPreview from '@/components/training/ScriptPreview'
|
||||
type Tab = 'overview' | 'modules' | 'matrix' | 'assignments' | 'content' | 'audit'
|
||||
|
||||
export default function TrainingPage() {
|
||||
@@ -43,6 +45,7 @@ export default function TrainingPage() {
|
||||
const [bulkGenerating, setBulkGenerating] = useState(false)
|
||||
const [bulkResult, setBulkResult] = useState<{ generated: number; skipped: number; errors: string[] } | null>(null)
|
||||
const [moduleMedia, setModuleMedia] = useState<TrainingMedia[]>([])
|
||||
const [interactiveGenerating, setInteractiveGenerating] = useState(false)
|
||||
|
||||
const [statusFilter, setStatusFilter] = useState<string>('')
|
||||
const [regulationFilter, setRegulationFilter] = useState<string>('')
|
||||
@@ -54,6 +57,15 @@ export default function TrainingPage() {
|
||||
const [selectedAssignment, setSelectedAssignment] = useState<TrainingAssignment | null>(null)
|
||||
const [escalationResult, setEscalationResult] = useState<{ total_checked: number; escalated: number } | null>(null)
|
||||
|
||||
// Block (Controls → Module) state
|
||||
const [blocks, setBlocks] = useState<TrainingBlockConfig[]>([])
|
||||
const [canonicalMeta, setCanonicalMeta] = useState<CanonicalControlMeta | null>(null)
|
||||
const [showBlockCreate, setShowBlockCreate] = useState(false)
|
||||
const [blockPreview, setBlockPreview] = useState<BlockPreview | null>(null)
|
||||
const [blockPreviewId, setBlockPreviewId] = useState<string>('')
|
||||
const [blockGenerating, setBlockGenerating] = useState(false)
|
||||
const [blockResult, setBlockResult] = useState<BlockGenerateResult | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
loadData()
|
||||
}, [])
|
||||
@@ -68,13 +80,15 @@ export default function TrainingPage() {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
try {
|
||||
const [statsRes, modulesRes, matrixRes, assignmentsRes, deadlinesRes, auditRes] = await Promise.allSettled([
|
||||
const [statsRes, modulesRes, matrixRes, assignmentsRes, deadlinesRes, auditRes, blocksRes, metaRes] = await Promise.allSettled([
|
||||
getStats(),
|
||||
getModules(),
|
||||
getMatrix(),
|
||||
getAssignments({ limit: 50 }),
|
||||
getDeadlines(10),
|
||||
getAuditLog({ limit: 30 }),
|
||||
listBlockConfigs(),
|
||||
getCanonicalMeta(),
|
||||
])
|
||||
|
||||
if (statsRes.status === 'fulfilled') setStats(statsRes.value)
|
||||
@@ -83,6 +97,8 @@ export default function TrainingPage() {
|
||||
if (assignmentsRes.status === 'fulfilled') setAssignments(assignmentsRes.value.assignments)
|
||||
if (deadlinesRes.status === 'fulfilled') setDeadlines(deadlinesRes.value.deadlines)
|
||||
if (auditRes.status === 'fulfilled') setAuditLog(auditRes.value.entries)
|
||||
if (blocksRes.status === 'fulfilled') setBlocks(blocksRes.value.blocks)
|
||||
if (metaRes.status === 'fulfilled') setCanonicalMeta(metaRes.value)
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : 'Fehler beim Laden')
|
||||
} finally {
|
||||
@@ -116,6 +132,19 @@ export default function TrainingPage() {
|
||||
}
|
||||
}
|
||||
|
||||
async function handleGenerateInteractiveVideo() {
|
||||
if (!selectedModuleId) return
|
||||
setInteractiveGenerating(true)
|
||||
try {
|
||||
await generateInteractiveVideo(selectedModuleId)
|
||||
await loadModuleMedia(selectedModuleId)
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : 'Fehler bei der interaktiven Video-Generierung')
|
||||
} finally {
|
||||
setInteractiveGenerating(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function handlePublishContent(contentId: string) {
|
||||
try {
|
||||
await publishContent(contentId)
|
||||
@@ -192,6 +221,59 @@ export default function TrainingPage() {
|
||||
}
|
||||
}
|
||||
|
||||
// Block handlers
|
||||
async function handleCreateBlock(data: {
|
||||
name: string; description?: string; domain_filter?: string; category_filter?: string;
|
||||
severity_filter?: string; target_audience_filter?: string; regulation_area: string;
|
||||
module_code_prefix: string; max_controls_per_module?: number;
|
||||
}) {
|
||||
try {
|
||||
await createBlockConfig(data)
|
||||
setShowBlockCreate(false)
|
||||
const res = await listBlockConfigs()
|
||||
setBlocks(res.blocks)
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : 'Fehler beim Erstellen')
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDeleteBlock(id: string) {
|
||||
if (!confirm('Block-Konfiguration wirklich loeschen?')) return
|
||||
try {
|
||||
await deleteBlockConfig(id)
|
||||
const res = await listBlockConfigs()
|
||||
setBlocks(res.blocks)
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : 'Fehler beim Loeschen')
|
||||
}
|
||||
}
|
||||
|
||||
async function handlePreviewBlock(id: string) {
|
||||
setBlockPreviewId(id)
|
||||
setBlockPreview(null)
|
||||
setBlockResult(null)
|
||||
try {
|
||||
const preview = await previewBlock(id)
|
||||
setBlockPreview(preview)
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : 'Fehler beim Preview')
|
||||
}
|
||||
}
|
||||
|
||||
async function handleGenerateBlock(id: string) {
|
||||
setBlockGenerating(true)
|
||||
setBlockResult(null)
|
||||
try {
|
||||
const result = await generateBlock(id, { language: 'de', auto_matrix: true })
|
||||
setBlockResult(result)
|
||||
await loadData()
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : 'Fehler bei der Block-Generierung')
|
||||
} finally {
|
||||
setBlockGenerating(false)
|
||||
}
|
||||
}
|
||||
|
||||
const tabs: { id: Tab; label: string }[] = [
|
||||
{ id: 'overview', label: 'Uebersicht' },
|
||||
{ id: 'modules', label: 'Modulkatalog' },
|
||||
@@ -304,30 +386,362 @@ export default function TrainingPage() {
|
||||
)}
|
||||
|
||||
{activeTab === 'content' && (
|
||||
<ContentTab
|
||||
modules={modules}
|
||||
selectedModuleId={selectedModuleId}
|
||||
onSelectModule={id => {
|
||||
setSelectedModuleId(id)
|
||||
setGeneratedContent(null)
|
||||
setModuleMedia([])
|
||||
if (id) {
|
||||
handleLoadContent(id)
|
||||
loadModuleMedia(id)
|
||||
}
|
||||
}}
|
||||
generatedContent={generatedContent}
|
||||
generating={generating}
|
||||
bulkGenerating={bulkGenerating}
|
||||
bulkResult={bulkResult}
|
||||
moduleMedia={moduleMedia}
|
||||
onGenerateContent={handleGenerateContent}
|
||||
onGenerateQuiz={handleGenerateQuiz}
|
||||
onBulkContent={handleBulkContent}
|
||||
onBulkQuiz={handleBulkQuiz}
|
||||
onPublishContent={handlePublishContent}
|
||||
onReloadMedia={() => loadModuleMedia(selectedModuleId)}
|
||||
/>
|
||||
<div className="space-y-6">
|
||||
|
||||
{/* Training Blocks — Controls → Schulungsmodule */}
|
||||
<div className="bg-white border rounded-lg p-4">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<div>
|
||||
<h3 className="text-sm font-medium text-gray-700">Schulungsbloecke aus Controls</h3>
|
||||
<p className="text-xs text-gray-500">
|
||||
Canonical Controls nach Kriterien filtern und automatisch Schulungsmodule generieren
|
||||
{canonicalMeta && <span className="ml-2 text-gray-400">({canonicalMeta.total} Controls verfuegbar)</span>}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setShowBlockCreate(true)}
|
||||
className="px-3 py-1.5 text-xs bg-blue-600 text-white rounded-lg hover:bg-blue-700"
|
||||
>
|
||||
+ Neuen Block erstellen
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Block list */}
|
||||
{blocks.length > 0 ? (
|
||||
<div className="border rounded-lg overflow-hidden">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-gray-50">
|
||||
<tr>
|
||||
<th className="px-3 py-2 text-left font-medium text-gray-600">Name</th>
|
||||
<th className="px-3 py-2 text-left font-medium text-gray-600">Domain</th>
|
||||
<th className="px-3 py-2 text-left font-medium text-gray-600">Zielgruppe</th>
|
||||
<th className="px-3 py-2 text-left font-medium text-gray-600">Severity</th>
|
||||
<th className="px-3 py-2 text-left font-medium text-gray-600">Prefix</th>
|
||||
<th className="px-3 py-2 text-left font-medium text-gray-600">Letzte Generierung</th>
|
||||
<th className="px-3 py-2 text-right font-medium text-gray-600">Aktionen</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y">
|
||||
{blocks.map(block => (
|
||||
<tr key={block.id} className="hover:bg-gray-50">
|
||||
<td className="px-3 py-2">
|
||||
<div className="font-medium text-gray-900">{block.name}</div>
|
||||
{block.description && <div className="text-xs text-gray-500">{block.description}</div>}
|
||||
</td>
|
||||
<td className="px-3 py-2 text-gray-600">{block.domain_filter || 'Alle'}</td>
|
||||
<td className="px-3 py-2 text-gray-600">{block.target_audience_filter ? (TARGET_AUDIENCE_LABELS[block.target_audience_filter] || block.target_audience_filter) : 'Alle'}</td>
|
||||
<td className="px-3 py-2 text-gray-600">{block.severity_filter || 'Alle'}</td>
|
||||
<td className="px-3 py-2"><code className="text-xs bg-gray-100 px-1.5 py-0.5 rounded">{block.module_code_prefix}</code></td>
|
||||
<td className="px-3 py-2 text-gray-500 text-xs">{block.last_generated_at ? new Date(block.last_generated_at).toLocaleString('de-DE') : 'Noch nie'}</td>
|
||||
<td className="px-3 py-2 text-right">
|
||||
<div className="flex gap-1 justify-end">
|
||||
<button
|
||||
onClick={() => handlePreviewBlock(block.id)}
|
||||
className="px-2 py-1 text-xs bg-gray-100 text-gray-700 rounded hover:bg-gray-200"
|
||||
>
|
||||
Preview
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleGenerateBlock(block.id)}
|
||||
disabled={blockGenerating}
|
||||
className="px-2 py-1 text-xs bg-green-600 text-white rounded hover:bg-green-700 disabled:opacity-50"
|
||||
>
|
||||
{blockGenerating ? 'Generiert...' : 'Generieren'}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleDeleteBlock(block.id)}
|
||||
className="px-2 py-1 text-xs bg-red-100 text-red-700 rounded hover:bg-red-200"
|
||||
>
|
||||
Loeschen
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center py-8 text-gray-500 text-sm">
|
||||
Noch keine Schulungsbloecke konfiguriert. Erstelle einen Block, um Controls automatisch in Module umzuwandeln.
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Preview result */}
|
||||
{blockPreview && blockPreviewId && (
|
||||
<div className="mt-4 p-4 bg-blue-50 border border-blue-200 rounded-lg">
|
||||
<h4 className="text-sm font-medium text-blue-800 mb-2">Preview: {blocks.find(b => b.id === blockPreviewId)?.name}</h4>
|
||||
<div className="flex gap-6 text-sm mb-3">
|
||||
<span className="text-blue-700">Controls: <strong>{blockPreview.control_count}</strong></span>
|
||||
<span className="text-blue-700">Module: <strong>{blockPreview.module_count}</strong></span>
|
||||
<span className="text-blue-700">Rollen: <strong>{blockPreview.proposed_roles.map(r => ROLE_LABELS[r] || r).join(', ')}</strong></span>
|
||||
</div>
|
||||
{blockPreview.controls.length > 0 && (
|
||||
<details className="text-xs">
|
||||
<summary className="cursor-pointer text-blue-600 hover:text-blue-800">Passende Controls anzeigen ({blockPreview.control_count})</summary>
|
||||
<div className="mt-2 max-h-48 overflow-y-auto">
|
||||
{blockPreview.controls.slice(0, 50).map(ctrl => (
|
||||
<div key={ctrl.control_id} className="flex gap-2 py-1 border-b border-blue-100">
|
||||
<code className="text-xs bg-blue-100 px-1 rounded shrink-0">{ctrl.control_id}</code>
|
||||
<span className="text-gray-700 truncate">{ctrl.title}</span>
|
||||
<span className={`text-xs px-1.5 rounded shrink-0 ${ctrl.severity === 'critical' ? 'bg-red-100 text-red-700' : ctrl.severity === 'high' ? 'bg-orange-100 text-orange-700' : 'bg-gray-100 text-gray-600'}`}>{ctrl.severity}</span>
|
||||
</div>
|
||||
))}
|
||||
{blockPreview.control_count > 50 && <div className="text-gray-500 py-1">... und {blockPreview.control_count - 50} weitere</div>}
|
||||
</div>
|
||||
</details>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Generate result */}
|
||||
{blockResult && (
|
||||
<div className="mt-4 p-4 bg-green-50 border border-green-200 rounded-lg">
|
||||
<h4 className="text-sm font-medium text-green-800 mb-2">Generierung abgeschlossen</h4>
|
||||
<div className="flex gap-6 text-sm">
|
||||
<span className="text-green-700">Module erstellt: <strong>{blockResult.modules_created}</strong></span>
|
||||
<span className="text-green-700">Controls verknuepft: <strong>{blockResult.controls_linked}</strong></span>
|
||||
<span className="text-green-700">Matrix-Eintraege: <strong>{blockResult.matrix_entries_created}</strong></span>
|
||||
<span className="text-green-700">Content generiert: <strong>{blockResult.content_generated}</strong></span>
|
||||
</div>
|
||||
{blockResult.errors && blockResult.errors.length > 0 && (
|
||||
<div className="mt-2 text-xs text-red-600">
|
||||
{blockResult.errors.map((err, i) => <div key={i}>{err}</div>)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Block Create Modal */}
|
||||
{showBlockCreate && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40">
|
||||
<div className="bg-white rounded-xl shadow-xl w-full max-w-lg p-6">
|
||||
<h3 className="text-lg font-semibold mb-4">Neuen Schulungsblock erstellen</h3>
|
||||
<form onSubmit={e => {
|
||||
e.preventDefault()
|
||||
const fd = new FormData(e.currentTarget)
|
||||
handleCreateBlock({
|
||||
name: fd.get('name') as string,
|
||||
description: fd.get('description') as string || undefined,
|
||||
domain_filter: fd.get('domain_filter') as string || undefined,
|
||||
category_filter: fd.get('category_filter') as string || undefined,
|
||||
severity_filter: fd.get('severity_filter') as string || undefined,
|
||||
target_audience_filter: fd.get('target_audience_filter') as string || undefined,
|
||||
regulation_area: fd.get('regulation_area') as string,
|
||||
module_code_prefix: fd.get('module_code_prefix') as string,
|
||||
max_controls_per_module: parseInt(fd.get('max_controls_per_module') as string) || 20,
|
||||
})
|
||||
}} className="space-y-3">
|
||||
<div>
|
||||
<label className="text-xs text-gray-600 block mb-1">Name *</label>
|
||||
<input name="name" required className="w-full px-3 py-2 text-sm border rounded-lg" placeholder="z.B. Authentifizierung fuer Geschaeftsfuehrung" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs text-gray-600 block mb-1">Beschreibung</label>
|
||||
<textarea name="description" className="w-full px-3 py-2 text-sm border rounded-lg" rows={2} />
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="text-xs text-gray-600 block mb-1">Domain-Filter</label>
|
||||
<select name="domain_filter" className="w-full px-3 py-2 text-sm border rounded-lg bg-white">
|
||||
<option value="">Alle Domains</option>
|
||||
{canonicalMeta?.domains.map(d => (
|
||||
<option key={d.domain} value={d.domain}>{d.domain} ({d.count})</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs text-gray-600 block mb-1">Kategorie-Filter</label>
|
||||
<select name="category_filter" className="w-full px-3 py-2 text-sm border rounded-lg bg-white">
|
||||
<option value="">Alle Kategorien</option>
|
||||
{canonicalMeta?.categories.filter(c => c.category !== 'uncategorized').map(c => (
|
||||
<option key={c.category} value={c.category}>{c.category} ({c.count})</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="text-xs text-gray-600 block mb-1">Zielgruppe</label>
|
||||
<select name="target_audience_filter" className="w-full px-3 py-2 text-sm border rounded-lg bg-white">
|
||||
<option value="">Alle Zielgruppen</option>
|
||||
{canonicalMeta?.audiences.filter(a => a.audience !== 'unset').map(a => (
|
||||
<option key={a.audience} value={a.audience}>{TARGET_AUDIENCE_LABELS[a.audience] || a.audience} ({a.count})</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs text-gray-600 block mb-1">Severity</label>
|
||||
<select name="severity_filter" className="w-full px-3 py-2 text-sm border rounded-lg bg-white">
|
||||
<option value="">Alle</option>
|
||||
<option value="critical">Critical</option>
|
||||
<option value="high">High</option>
|
||||
<option value="medium">Medium</option>
|
||||
<option value="low">Low</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="text-xs text-gray-600 block mb-1">Regulierungsbereich *</label>
|
||||
<select name="regulation_area" required className="w-full px-3 py-2 text-sm border rounded-lg bg-white">
|
||||
{Object.entries(REGULATION_LABELS).map(([k, v]) => (
|
||||
<option key={k} value={k}>{v}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs text-gray-600 block mb-1">Modul-Code-Prefix *</label>
|
||||
<input name="module_code_prefix" required className="w-full px-3 py-2 text-sm border rounded-lg" placeholder="z.B. CB-AUTH" />
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs text-gray-600 block mb-1">Max. Controls pro Modul</label>
|
||||
<input name="max_controls_per_module" type="number" defaultValue={20} min={1} max={50} className="w-full px-3 py-2 text-sm border rounded-lg" />
|
||||
</div>
|
||||
<div className="flex gap-3 pt-2">
|
||||
<button type="submit" className="px-4 py-2 text-sm bg-blue-600 text-white rounded-lg hover:bg-blue-700">Erstellen</button>
|
||||
<button type="button" onClick={() => setShowBlockCreate(false)} className="px-4 py-2 text-sm bg-gray-100 text-gray-700 rounded-lg hover:bg-gray-200">Abbrechen</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Bulk Generation */}
|
||||
<div className="bg-white border rounded-lg p-4">
|
||||
<h3 className="text-sm font-medium text-gray-700 mb-3">Bulk-Generierung</h3>
|
||||
<p className="text-xs text-gray-500 mb-4">Generiere Inhalte und Quiz-Fragen fuer alle Module auf einmal</p>
|
||||
<div className="flex gap-3">
|
||||
<button
|
||||
onClick={handleBulkContent}
|
||||
disabled={bulkGenerating}
|
||||
className="px-4 py-2 text-sm bg-green-600 text-white rounded-lg hover:bg-green-700 disabled:opacity-50"
|
||||
>
|
||||
{bulkGenerating ? 'Generiere...' : 'Alle Inhalte generieren'}
|
||||
</button>
|
||||
<button
|
||||
onClick={handleBulkQuiz}
|
||||
disabled={bulkGenerating}
|
||||
className="px-4 py-2 text-sm bg-indigo-600 text-white rounded-lg hover:bg-indigo-700 disabled:opacity-50"
|
||||
>
|
||||
{bulkGenerating ? 'Generiere...' : 'Alle Quizfragen generieren'}
|
||||
</button>
|
||||
</div>
|
||||
{bulkResult && (
|
||||
<div className="mt-4 p-3 bg-gray-50 rounded-lg text-sm">
|
||||
<div className="flex gap-6">
|
||||
<span className="text-green-700">Generiert: {bulkResult.generated}</span>
|
||||
<span className="text-gray-500">Uebersprungen: {bulkResult.skipped}</span>
|
||||
{bulkResult.errors?.length > 0 && (
|
||||
<span className="text-red-600">Fehler: {bulkResult.errors.length}</span>
|
||||
)}
|
||||
</div>
|
||||
{bulkResult.errors?.length > 0 && (
|
||||
<div className="mt-2 text-xs text-red-600">
|
||||
{bulkResult.errors.map((err, i) => <div key={i}>{err}</div>)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="bg-white border rounded-lg p-4">
|
||||
<h3 className="text-sm font-medium text-gray-700 mb-3">LLM-Content-Generator</h3>
|
||||
<p className="text-xs text-gray-500 mb-4">Generiere Schulungsinhalte und Quiz-Fragen automatisch via KI</p>
|
||||
<div className="flex gap-3 items-end">
|
||||
<div className="flex-1">
|
||||
<label className="text-xs text-gray-600 block mb-1">Modul auswaehlen</label>
|
||||
<select
|
||||
value={selectedModuleId}
|
||||
onChange={e => { setSelectedModuleId(e.target.value); setGeneratedContent(null); setModuleMedia([]); if (e.target.value) { handleLoadContent(e.target.value); loadModuleMedia(e.target.value); } }}
|
||||
className="w-full px-3 py-2 text-sm border rounded-lg bg-white"
|
||||
>
|
||||
<option value="">Modul waehlen...</option>
|
||||
{modules.map(m => <option key={m.id} value={m.id}>{m.module_code} - {m.title}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<button onClick={handleGenerateContent} disabled={!selectedModuleId || generating} className="px-4 py-2 text-sm bg-blue-600 text-white rounded-lg hover:bg-blue-700 disabled:opacity-50">
|
||||
{generating ? 'Generiere...' : 'Inhalt generieren'}
|
||||
</button>
|
||||
<button onClick={handleGenerateQuiz} disabled={!selectedModuleId || generating} className="px-4 py-2 text-sm bg-purple-600 text-white rounded-lg hover:bg-purple-700 disabled:opacity-50">
|
||||
{generating ? 'Generiere...' : 'Quiz generieren'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{generatedContent && (
|
||||
<div className="bg-white border rounded-lg p-4">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<div>
|
||||
<h3 className="text-sm font-medium text-gray-700">Generierter Inhalt (v{generatedContent.version})</h3>
|
||||
<p className="text-xs text-gray-500">Generiert von: {generatedContent.generated_by} ({generatedContent.llm_model})</p>
|
||||
</div>
|
||||
{!generatedContent.is_published ? (
|
||||
<button onClick={() => handlePublishContent(generatedContent.id)} className="px-3 py-1.5 text-xs bg-green-600 text-white rounded hover:bg-green-700">Veroeffentlichen</button>
|
||||
) : (
|
||||
<span className="px-3 py-1.5 text-xs bg-green-100 text-green-700 rounded">Veroeffentlicht</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="prose prose-sm max-w-none border rounded p-4 bg-gray-50 max-h-96 overflow-y-auto">
|
||||
<pre className="whitespace-pre-wrap text-sm text-gray-800">{generatedContent.content_body}</pre>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Audio Player */}
|
||||
{selectedModuleId && generatedContent?.is_published && (
|
||||
<AudioPlayer
|
||||
moduleId={selectedModuleId}
|
||||
audio={moduleMedia.find(m => m.media_type === 'audio') || null}
|
||||
onMediaUpdate={() => loadModuleMedia(selectedModuleId)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Video Player */}
|
||||
{selectedModuleId && generatedContent?.is_published && (
|
||||
<VideoPlayer
|
||||
moduleId={selectedModuleId}
|
||||
video={moduleMedia.find(m => m.media_type === 'video') || null}
|
||||
onMediaUpdate={() => loadModuleMedia(selectedModuleId)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Interactive Video */}
|
||||
{selectedModuleId && generatedContent?.is_published && (
|
||||
<div className="bg-white border rounded-lg p-4">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<div>
|
||||
<h3 className="text-sm font-medium text-gray-700">Interaktives Video</h3>
|
||||
<p className="text-xs text-gray-500">Video mit Narrator-Persona und Checkpoint-Quizzes</p>
|
||||
</div>
|
||||
{moduleMedia.some(m => m.media_type === 'interactive_video' && m.status === 'completed') ? (
|
||||
<span className="px-3 py-1.5 text-xs bg-purple-100 text-purple-700 rounded-full">Interaktiv erstellt</span>
|
||||
) : (
|
||||
<button
|
||||
onClick={handleGenerateInteractiveVideo}
|
||||
disabled={interactiveGenerating}
|
||||
className="px-4 py-2 text-sm bg-purple-600 text-white rounded-lg hover:bg-purple-700 disabled:opacity-50"
|
||||
>
|
||||
{interactiveGenerating ? 'Generiere interaktives Video...' : 'Interaktives Video generieren'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{moduleMedia.filter(m => m.media_type === 'interactive_video' && m.status === 'completed').map(m => (
|
||||
<div key={m.id} className="text-xs text-gray-500 space-y-1 bg-gray-50 rounded p-3">
|
||||
<p>Dauer: {Math.round(m.duration_seconds / 60)} Min | Groesse: {(m.file_size_bytes / 1024 / 1024).toFixed(1)} MB</p>
|
||||
<p>Generiert: {new Date(m.created_at).toLocaleString('de-DE')}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Script Preview */}
|
||||
{selectedModuleId && generatedContent?.is_published && (
|
||||
<ScriptPreview moduleId={selectedModuleId} />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'audit' && <AuditTab auditLog={auditLog} />}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -257,24 +257,88 @@ export default function WhistleblowerPage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Info Box about HinSchG Deadlines (Overview Tab) */}
|
||||
{/* Info Box about HinSchG (Overview Tab) */}
|
||||
{activeTab === 'overview' && (
|
||||
<div className="bg-blue-50 border border-blue-200 rounded-xl p-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<svg className="w-5 h-5 text-blue-600 mt-0.5 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
<div>
|
||||
<h4 className="font-medium text-blue-800">HinSchG-Fristen</h4>
|
||||
<p className="text-sm text-blue-600 mt-1">
|
||||
Nach dem Hinweisgeberschutzgesetz (HinSchG) gelten folgende Fristen:
|
||||
Die Eingangsbestaetigung muss innerhalb von <strong>7 Tagen</strong> an den
|
||||
Hinweisgeber versendet werden (ss 17 Abs. 1 S. 2).
|
||||
Eine Rueckmeldung ueber ergriffene Massnahmen muss innerhalb von <strong>3 Monaten</strong> nach
|
||||
Eingangsbestaetigung erfolgen (ss 17 Abs. 2).
|
||||
Der Schutz des Hinweisgebers vor Repressalien ist zwingend sicherzustellen (ss 36).
|
||||
<div className="space-y-4">
|
||||
{/* Gesetzliche Grundlage */}
|
||||
<div className="bg-blue-50 border border-blue-200 rounded-xl p-5">
|
||||
<div className="flex items-start gap-3">
|
||||
<svg className="w-5 h-5 text-blue-600 mt-0.5 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
<div>
|
||||
<h4 className="font-medium text-blue-800">Gesetzliche Grundlage: Hinweisgeberschutzgesetz (HinSchG)</h4>
|
||||
<p className="text-sm text-blue-600 mt-1">
|
||||
Das HinSchG setzt die <strong>EU-Whistleblowing-Richtlinie (2019/1937)</strong> in deutsches Recht um
|
||||
und ist seit dem <strong>2. Juli 2023</strong> in Kraft. Seit dem <strong>17. Dezember 2023</strong> gilt
|
||||
die Pflicht zur Einrichtung einer internen Meldestelle auch fuer Unternehmen ab 50 Beschaeftigten (ss 12 HinSchG).
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Fristen & Pflichten */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<div className="bg-white border border-gray-200 rounded-xl p-4">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<svg className="w-4 h-4 text-orange-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
<h5 className="text-sm font-semibold text-gray-900">7-Tage-Frist</h5>
|
||||
</div>
|
||||
<p className="text-xs text-gray-600">
|
||||
Eingangsbestaetigung an den Hinweisgeber innerhalb von 7 Tagen nach Meldungseingang (ss 17 Abs. 1 S. 2 HinSchG).
|
||||
</p>
|
||||
</div>
|
||||
<div className="bg-white border border-gray-200 rounded-xl p-4">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<svg className="w-4 h-4 text-purple-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" />
|
||||
</svg>
|
||||
<h5 className="text-sm font-semibold text-gray-900">3-Monate-Frist</h5>
|
||||
</div>
|
||||
<p className="text-xs text-gray-600">
|
||||
Rueckmeldung ueber ergriffene Folgemaßnahmen innerhalb von 3 Monaten nach Eingangsbestaetigung (ss 17 Abs. 2 HinSchG).
|
||||
</p>
|
||||
</div>
|
||||
<div className="bg-white border border-gray-200 rounded-xl p-4">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<svg className="w-4 h-4 text-red-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
|
||||
</svg>
|
||||
<h5 className="text-sm font-semibold text-gray-900">3 Jahre Aufbewahrung</h5>
|
||||
</div>
|
||||
<p className="text-xs text-gray-600">
|
||||
Dokumentation der Meldungen und Folgemaßnahmen ist 3 Jahre nach Abschluss aufzubewahren (ss 11 Abs. 5 HinSchG).
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Sachlicher Anwendungsbereich & Schutz */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div className="bg-amber-50 border border-amber-200 rounded-xl p-4">
|
||||
<h5 className="text-sm font-semibold text-amber-800 mb-2">Sachlicher Anwendungsbereich (ss 2 HinSchG)</h5>
|
||||
<ul className="text-xs text-amber-700 space-y-1">
|
||||
<li>Verstoesse gegen Strafvorschriften (StGB, Nebenstrafrecht)</li>
|
||||
<li>Verstoesse gegen Datenschutzrecht (DSGVO, BDSG)</li>
|
||||
<li>Geldwaesche und Terrorismusfinanzierung (GwG)</li>
|
||||
<li>Produktsicherheit und Verbraucherschutz</li>
|
||||
<li>Umweltschutz und Lebensmittelsicherheit</li>
|
||||
<li>Arbeitsschutz und Arbeitnehmerrechte</li>
|
||||
<li>Wettbewerbs- und Kartellrecht</li>
|
||||
<li>Steuer- und Abgabenrecht (bei Unternehmen)</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div className="bg-green-50 border border-green-200 rounded-xl p-4">
|
||||
<h5 className="text-sm font-semibold text-green-800 mb-2">Schutz des Hinweisgebers (ss 36–37 HinSchG)</h5>
|
||||
<ul className="text-xs text-green-700 space-y-1">
|
||||
<li><strong>Repressalienverbot:</strong> Jede Benachteiligung ist untersagt (ss 36)</li>
|
||||
<li><strong>Beweislastumkehr:</strong> Arbeitgeber muss beweisen, dass Maßnahmen nicht mit Meldung zusammenhaengen</li>
|
||||
<li><strong>Schadensersatz:</strong> Bei Verstoessen gegen Repressalienverbot (ss 37)</li>
|
||||
<li><strong>Vertraulichkeit:</strong> Identitaet darf nur bei Zustimmung oder gesetzlicher Pflicht offengelegt werden (ss 8)</li>
|
||||
<li><strong>Bussgelder:</strong> Bis zu 50.000 EUR bei Verstoessen gegen die Einrichtungspflicht (ss 40)</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
Reference in New Issue
Block a user