diff --git a/admin-compliance/app/api/sdk/v1/canonical/route.ts b/admin-compliance/app/api/sdk/v1/canonical/route.ts index ab998b1..ebaf895 100644 --- a/admin-compliance/app/api/sdk/v1/canonical/route.ts +++ b/admin-compliance/app/api/sdk/v1/canonical/route.ts @@ -27,7 +27,7 @@ export async function GET(request: NextRequest) { case 'controls': { const controlParams = new URLSearchParams() const passthrough = ['severity', 'domain', 'release_state', 'verification_method', 'category', - 'target_audience', 'source', 'search', 'sort', 'order', 'limit', 'offset'] + 'target_audience', 'source', 'search', 'control_type', 'sort', 'order', 'limit', 'offset'] for (const key of passthrough) { const val = searchParams.get(key) if (val) controlParams.set(key, val) @@ -40,7 +40,7 @@ export async function GET(request: NextRequest) { case 'controls-count': { const countParams = new URLSearchParams() const countPassthrough = ['severity', 'domain', 'release_state', 'verification_method', 'category', - 'target_audience', 'source', 'search'] + 'target_audience', 'source', 'search', 'control_type'] for (const key of countPassthrough) { const val = searchParams.get(key) if (val) countParams.set(key, val) @@ -99,6 +99,15 @@ export async function GET(request: NextRequest) { backendPath = '/api/compliance/v1/canonical/categories' break + case 'traceability': { + const traceId = searchParams.get('id') + if (!traceId) { + return NextResponse.json({ error: 'Missing control id' }, { status: 400 }) + } + backendPath = `/api/compliance/v1/canonical/controls/${encodeURIComponent(traceId)}/traceability` + break + } + case 'similar': { const simControlId = searchParams.get('id') if (!simControlId) { diff --git a/admin-compliance/app/sdk/control-library/components/ControlDetail.tsx b/admin-compliance/app/sdk/control-library/components/ControlDetail.tsx index dbc96d1..4ae4a60 100644 --- a/admin-compliance/app/sdk/control-library/components/ControlDetail.tsx +++ b/admin-compliance/app/sdk/control-library/components/ControlDetail.tsx @@ -1,10 +1,10 @@ 'use client' -import { useState, useEffect } from 'react' +import { useState, useEffect, useCallback } from 'react' import { ArrowLeft, ExternalLink, BookOpen, Scale, FileText, Eye, CheckCircle2, Trash2, Pencil, Clock, - ChevronLeft, SkipForward, GitMerge, Search, + ChevronLeft, SkipForward, GitMerge, Search, Landmark, } from 'lucide-react' import { CanonicalControl, EFFORT_LABELS, BACKEND_URL, @@ -25,6 +25,37 @@ interface SimilarControl { 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 | 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 +} + interface ControlDetailProps { ctrl: CanonicalControl onBack: () => void @@ -57,9 +88,23 @@ export function ControlDetail({ const [loadingSimilar, setLoadingSimilar] = useState(false) const [selectedDuplicates, setSelectedDuplicates] = useState>(new Set()) const [merging, setMerging] = useState(false) + const [traceability, setTraceability] = useState(null) + const [loadingTrace, setLoadingTrace] = useState(false) + + const loadTraceability = useCallback(async () => { + setLoadingTrace(true) + try { + const 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]) useEffect(() => { loadSimilarControls() + loadTraceability() setSelectedDuplicates(new Set()) // eslint-disable-next-line react-hooks/exhaustive-deps }, [ctrl.control_id]) @@ -242,8 +287,79 @@ export function ControlDetail({ )} - {/* Parent Control (atomare Controls) */} - {ctrl.parent_control_uuid && ( + {/* Rechtsgrundlagen / Traceability (atomic controls) */} + {traceability && traceability.parent_links.length > 0 && ( +
+
+ +

+ Rechtsgrundlagen ({traceability.source_count} {traceability.source_count === 1 ? 'Quelle' : 'Quellen'}) +

+ + {loadingTrace && Laden...} +
+
+ {traceability.parent_links.map((link, i) => ( +
+
+ +
+
+ {link.source_regulation && ( + {link.source_regulation} + )} + {link.source_article && ( + {link.source_article} + )} + {!link.source_regulation && link.parent_citation?.source && ( + + {link.parent_citation.source} + {link.parent_citation.article && ` — ${link.parent_citation.article}`} + + )} + + {link.link_type === 'decomposition' ? 'Ableitung' : + link.link_type === 'dedup_merge' ? 'Dedup' : + link.link_type} + +
+

+ via{' '} + + {link.parent_control_id} + + {link.parent_title && ( + — {link.parent_title} + )} +

+ {link.obligation && ( +

+ + {link.obligation.normative_strength === 'must' ? 'MUSS' : + link.obligation.normative_strength === 'should' ? 'SOLL' : 'KANN'} + + {link.obligation.text.slice(0, 200)} + {link.obligation.text.length > 200 ? '...' : ''} +

+ )} +
+
+
+ ))} +
+
+ )} + + {/* Fallback: simple parent display when traceability not loaded yet */} + {ctrl.parent_control_uuid && (!traceability || traceability.parent_links.length === 0) && !loadingTrace && (
@@ -259,12 +375,27 @@ export function ControlDetail({ — {ctrl.parent_control_title} )}

- {ctrl.generation_metadata?.obligation_text && ( -

- Obligation: {String(ctrl.generation_metadata.obligation_text).slice(0, 300)} - {String(ctrl.generation_metadata.obligation_text).length > 300 ? '...' : ''} -

- )} +
+ )} + + {/* Child controls (rich controls that have atomic children) */} + {traceability && traceability.children.length > 0 && ( +
+
+ +

+ Abgeleitete Controls ({traceability.children.length}) +

+
+
+ {traceability.children.map((child) => ( +
+ {child.control_id} + {child.title} + +
+ ))} +
)} diff --git a/admin-compliance/app/sdk/control-library/components/helpers.tsx b/admin-compliance/app/sdk/control-library/components/helpers.tsx index f48f743..0496d9e 100644 --- a/admin-compliance/app/sdk/control-library/components/helpers.tsx +++ b/admin-compliance/app/sdk/control-library/components/helpers.tsx @@ -282,7 +282,7 @@ export function GenerationStrategyBadge({ strategy }: { strategy: string | null if (strategy === 'phase74_gap_fill') { return v5 Gap } - if (strategy === 'pass0b_atomic') { + if (strategy === 'pass0b_atomic' || strategy === 'pass0b') { return Atomar } return {strategy} diff --git a/admin-compliance/app/sdk/control-library/page.tsx b/admin-compliance/app/sdk/control-library/page.tsx index aaa5f32..d1bea3d 100644 --- a/admin-compliance/app/sdk/control-library/page.tsx +++ b/admin-compliance/app/sdk/control-library/page.tsx @@ -53,6 +53,7 @@ export default function ControlLibraryPage() { const [categoryFilter, setCategoryFilter] = useState('') const [audienceFilter, setAudienceFilter] = useState('') const [sourceFilter, setSourceFilter] = useState('') + const [typeFilter, setTypeFilter] = useState('') const [sortBy, setSortBy] = useState<'id' | 'newest' | 'oldest' | 'source'>('id') // CRUD state @@ -94,10 +95,11 @@ export default function ControlLibraryPage() { if (categoryFilter) p.set('category', categoryFilter) if (audienceFilter) p.set('target_audience', audienceFilter) if (sourceFilter) p.set('source', sourceFilter) + if (typeFilter) p.set('control_type', typeFilter) 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, stateFilter, verificationFilter, categoryFilter, audienceFilter, sourceFilter, debouncedSearch]) + }, [severityFilter, domainFilter, stateFilter, verificationFilter, categoryFilter, audienceFilter, sourceFilter, typeFilter, debouncedSearch]) // Load metadata (domains, sources — once + on refresh) const loadMeta = useCallback(async () => { @@ -165,7 +167,7 @@ export default function ControlLibraryPage() { useEffect(() => { loadControls() }, [loadControls]) // Reset page when filters change - useEffect(() => { setCurrentPage(1) }, [severityFilter, domainFilter, stateFilter, verificationFilter, categoryFilter, audienceFilter, sourceFilter, debouncedSearch, sortBy]) + useEffect(() => { setCurrentPage(1) }, [severityFilter, domainFilter, stateFilter, verificationFilter, categoryFilter, audienceFilter, sourceFilter, typeFilter, debouncedSearch, sortBy]) // Pagination const totalPages = Math.max(1, Math.ceil(totalCount / PAGE_SIZE)) @@ -664,6 +666,15 @@ export default function ControlLibraryPage() { ))} + |