feat: control_parent_links population + traceability API + frontend
- _write_atomic_control() now uses RETURNING id and inserts into
control_parent_links (M:N) with source_regulation, source_article,
and obligation_candidate_id parsed from parent's source_citation
- New _parse_citation() helper for JSONB source_citation extraction
- New GET /controls/{id}/traceability endpoint returning full chain:
parent links with obligations, child controls, source_count
- Backend: control_type filter (atomic/rich) for controls + count
- Frontend: Rechtsgrundlagen section in ControlDetail showing all
parent links per source regulation with obligation text + strength
- Frontend: Atomic/Rich filter dropdown in Control Library list
- Frontend: GenerationStrategyBadge recognizes 'pass0b' strategy
- Tests: 3 new tests for parent_link creation + citation parsing,
existing batch test mock updated for RETURNING clause
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -53,6 +53,7 @@ export default function ControlLibraryPage() {
|
||||
const [categoryFilter, setCategoryFilter] = useState<string>('')
|
||||
const [audienceFilter, setAudienceFilter] = useState<string>('')
|
||||
const [sourceFilter, setSourceFilter] = useState<string>('')
|
||||
const [typeFilter, setTypeFilter] = useState<string>('')
|
||||
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() {
|
||||
<option key={s.source} value={s.source}>{s.source} ({s.count})</option>
|
||||
))}
|
||||
</select>
|
||||
<select
|
||||
value={typeFilter}
|
||||
onChange={e => setTypeFilter(e.target.value)}
|
||||
className="text-sm border border-gray-300 rounded-lg px-2 py-1.5 focus:outline-none focus:ring-2 focus:ring-purple-500"
|
||||
>
|
||||
<option value="">Alle Typen</option>
|
||||
<option value="rich">Rich Controls</option>
|
||||
<option value="atomic">Atomare Controls</option>
|
||||
</select>
|
||||
<span className="text-gray-300 mx-1">|</span>
|
||||
<ArrowUpDown className="w-4 h-4 text-gray-400" />
|
||||
<select
|
||||
|
||||
Reference in New Issue
Block a user