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:
Sharang Parnerkar
2026-04-16 16:26:48 +02:00
352 changed files with 181673 additions and 2188 deletions

View File

@@ -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)
})
})

View File

@@ -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()
})
})
})

View File

@@ -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>
)
}

View File

@@ -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>
)
}

View File

@@ -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>
)
}

View File

@@ -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>
)
}

View File

@@ -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>
)
}

View File

@@ -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